feat(render): wire table state machine and finalize hook
This commit is contained in:
@@ -421,7 +421,13 @@ impl AppConfig {
|
||||
if *IS_STDOUT_TERMINAL {
|
||||
let render_options = self.render_options()?;
|
||||
let mut markdown_render = MarkdownRender::init(render_options)?;
|
||||
println!("{}", markdown_render.render(text));
|
||||
let body = markdown_render.render(text);
|
||||
let tail = markdown_render.finalize();
|
||||
if tail.is_empty() {
|
||||
println!("{body}");
|
||||
} else {
|
||||
println!("{body}\n{tail}");
|
||||
}
|
||||
} else {
|
||||
println!("{text}");
|
||||
}
|
||||
|
||||
+15
-5
@@ -368,14 +368,24 @@ impl Session {
|
||||
for message in &self.messages {
|
||||
match message.role {
|
||||
MessageRole::System => {
|
||||
lines.push(
|
||||
render
|
||||
.render(&message.content.render_input(resolve_url_fn, agent_info)),
|
||||
);
|
||||
let body = render
|
||||
.render(&message.content.render_input(resolve_url_fn, agent_info));
|
||||
let tail = render.finalize();
|
||||
if tail.is_empty() {
|
||||
lines.push(body);
|
||||
} else {
|
||||
lines.push(format!("{body}\n{tail}"));
|
||||
}
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
if let MessageContent::Text(text) = &message.content {
|
||||
lines.push(render.render(text));
|
||||
let body = render.render(text);
|
||||
let tail = render.finalize();
|
||||
if tail.is_empty() {
|
||||
lines.push(body);
|
||||
} else {
|
||||
lines.push(format!("{body}\n{tail}"));
|
||||
}
|
||||
}
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
+228
-8
@@ -106,7 +106,6 @@ fn detect_line_kind(line: &str) -> LineKind {
|
||||
LineKind::Paragraph
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn parse_table_row(line: &str) -> Vec<String> {
|
||||
let inner = line
|
||||
.trim()
|
||||
@@ -115,7 +114,6 @@ fn parse_table_row(line: &str) -> Vec<String> {
|
||||
inner.split('|').map(|c| c.trim().to_string()).collect()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn parse_alignments(separator_row: &str) -> Vec<CellAlignment> {
|
||||
parse_table_row(separator_row)
|
||||
.iter()
|
||||
@@ -242,7 +240,6 @@ fn render_hrule(styles: &MarkdownStyles) -> String {
|
||||
"────────".with(styles.hrule).to_string()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn colorize_box_chars(text: &str, color: Color) -> String {
|
||||
let sample = "X".with(color).to_string();
|
||||
let paint_idx = match sample.find('X') {
|
||||
@@ -335,7 +332,6 @@ fn apply_inline(text: &str, styles: &MarkdownStyles) -> String {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
enum TableState {
|
||||
PendingHeader(String),
|
||||
Active {
|
||||
@@ -345,6 +341,12 @@ enum TableState {
|
||||
},
|
||||
}
|
||||
|
||||
enum TableAction {
|
||||
Consumed(String),
|
||||
FlushAndContinue(String),
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
pub struct MarkdownRender {
|
||||
options: RenderOptions,
|
||||
syntax_set: SyntaxSet,
|
||||
@@ -354,7 +356,6 @@ pub struct MarkdownRender {
|
||||
prev_line_type: LineType,
|
||||
wrap_width: Option<u16>,
|
||||
styles: MarkdownStyles,
|
||||
#[allow(dead_code)]
|
||||
table_state: Option<TableState>,
|
||||
}
|
||||
|
||||
@@ -419,6 +420,26 @@ impl MarkdownRender {
|
||||
|
||||
fn render_line_mut(&mut self, line: &str) -> String {
|
||||
let (line_type, line_kind, code_syntax, is_code) = self.check_line(line);
|
||||
|
||||
let table_prefix = if self.options.raw_markdown {
|
||||
None
|
||||
} else {
|
||||
let effective_kind = if is_code {
|
||||
LineKind::Paragraph
|
||||
} else {
|
||||
line_kind
|
||||
};
|
||||
match self.handle_table_state(line, effective_kind) {
|
||||
TableAction::Consumed(s) => {
|
||||
self.prev_line_type = line_type;
|
||||
self.code_syntax = code_syntax;
|
||||
return s;
|
||||
}
|
||||
TableAction::FlushAndContinue(s) => Some(s),
|
||||
TableAction::Passthrough => None,
|
||||
}
|
||||
};
|
||||
|
||||
let output = if is_code {
|
||||
self.highlight_code_line(line, &code_syntax)
|
||||
} else if self.options.raw_markdown {
|
||||
@@ -428,7 +449,84 @@ impl MarkdownRender {
|
||||
};
|
||||
self.prev_line_type = line_type;
|
||||
self.code_syntax = code_syntax;
|
||||
output
|
||||
|
||||
match table_prefix {
|
||||
Some(prefix) => format!("{prefix}\n{output}"),
|
||||
None => output,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_as_paragraph(&self, line: &str) -> String {
|
||||
self.render_rich_markdown_line(line, LineKind::Paragraph)
|
||||
}
|
||||
|
||||
fn handle_table_state(&mut self, line: &str, kind: LineKind) -> TableAction {
|
||||
match (self.table_state.take(), kind) {
|
||||
(None, LineKind::TableRow) => {
|
||||
self.table_state = Some(TableState::PendingHeader(line.to_string()));
|
||||
TableAction::Consumed(String::new())
|
||||
}
|
||||
(None, LineKind::TableSeparator) => TableAction::Passthrough,
|
||||
(None, _) => TableAction::Passthrough,
|
||||
(Some(TableState::PendingHeader(header_line)), LineKind::TableSeparator) => {
|
||||
let header = parse_table_row(&header_line);
|
||||
let alignments = parse_alignments(line);
|
||||
self.table_state = Some(TableState::Active {
|
||||
header,
|
||||
alignments,
|
||||
rows: Vec::new(),
|
||||
});
|
||||
TableAction::Consumed(String::new())
|
||||
}
|
||||
(Some(TableState::PendingHeader(header_line)), LineKind::TableRow) => {
|
||||
let a = self.render_as_paragraph(&header_line);
|
||||
let b = self.render_as_paragraph(line);
|
||||
TableAction::Consumed(format!("{a}\n{b}"))
|
||||
}
|
||||
(Some(TableState::PendingHeader(header_line)), _) => {
|
||||
let flushed = self.render_as_paragraph(&header_line);
|
||||
TableAction::FlushAndContinue(flushed)
|
||||
}
|
||||
(
|
||||
Some(TableState::Active {
|
||||
header,
|
||||
alignments,
|
||||
mut rows,
|
||||
}),
|
||||
LineKind::TableRow,
|
||||
) => {
|
||||
rows.push(parse_table_row(line));
|
||||
self.table_state = Some(TableState::Active {
|
||||
header,
|
||||
alignments,
|
||||
rows,
|
||||
});
|
||||
TableAction::Consumed(String::new())
|
||||
}
|
||||
(
|
||||
Some(TableState::Active {
|
||||
header,
|
||||
alignments,
|
||||
rows,
|
||||
}),
|
||||
_,
|
||||
) => {
|
||||
let rendered = self.render_table(header, alignments, rows);
|
||||
TableAction::FlushAndContinue(rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finalize(&mut self) -> String {
|
||||
match self.table_state.take() {
|
||||
None => String::new(),
|
||||
Some(TableState::PendingHeader(line)) => self.render_as_paragraph(&line),
|
||||
Some(TableState::Active {
|
||||
header,
|
||||
alignments,
|
||||
rows,
|
||||
}) => self.render_table(header, alignments, rows),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_rich_markdown_line(&self, line: &str, kind: LineKind) -> String {
|
||||
@@ -436,7 +534,6 @@ impl MarkdownRender {
|
||||
self.wrap_line(styled, false)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn render_table(
|
||||
&self,
|
||||
header: Vec<String>,
|
||||
@@ -749,7 +846,6 @@ pub struct MarkdownStyles {
|
||||
link_url: Color,
|
||||
strikethrough: Color,
|
||||
hrule: Color,
|
||||
#[allow(dead_code)]
|
||||
table_border: Color,
|
||||
}
|
||||
|
||||
@@ -1364,6 +1460,130 @@ std::error::Error>> {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_renders_full_table_and_flushes_on_paragraph() {
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
let text = "| A | B |\n|---|---|\n| 1 | 2 |\n\nafter\n";
|
||||
let output = render.render(text);
|
||||
for cell in ["A", "B", "1", "2"] {
|
||||
assert!(output.contains(cell), "cell {cell:?} rendered: {output:?}");
|
||||
}
|
||||
assert!(
|
||||
output
|
||||
.chars()
|
||||
.any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
|
||||
"output has box-drawing chars: {output:?}",
|
||||
);
|
||||
assert!(output.contains("after"), "trailing paragraph preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_defers_output_until_flush() {
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
let header = render.render_line_mut("| A | B |");
|
||||
assert!(header.is_empty(), "header row silently buffered");
|
||||
let sep = render.render_line_mut("|---|---|");
|
||||
assert!(sep.is_empty(), "separator silently buffered");
|
||||
let data = render.render_line_mut("| 1 | 2 |");
|
||||
assert!(data.is_empty(), "data row silently buffered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalize_emits_pending_active_table() {
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
render.render_line_mut("| A | B |");
|
||||
render.render_line_mut("|---|---|");
|
||||
render.render_line_mut("| 1 | 2 |");
|
||||
let tail = render.finalize();
|
||||
assert!(tail.contains("A"));
|
||||
assert!(tail.contains("1"));
|
||||
assert!(tail.contains("2"));
|
||||
assert!(tail.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalize_flushes_pending_header_as_paragraph() {
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
render.render_line_mut("| A | B |");
|
||||
let tail = render.finalize();
|
||||
assert!(tail.contains("A"));
|
||||
assert!(tail.contains("B"));
|
||||
assert!(tail.contains("|"), "raw pipes preserved: {tail:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalize_is_empty_when_no_pending_table() {
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
render.render_line_mut("plain text");
|
||||
assert!(render.finalize().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_row_without_separator_flushes_as_paragraphs() {
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
let text = "| A | B |\n| C | D |\nafter\n";
|
||||
let output = render.render(text);
|
||||
assert!(
|
||||
output.contains("| A | B |"),
|
||||
"raw pipes preserved for first: {output:?}",
|
||||
);
|
||||
assert!(
|
||||
output.contains("| C | D |"),
|
||||
"raw pipes preserved for second: {output:?}",
|
||||
);
|
||||
assert!(output.contains("after"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_tables_in_one_input() {
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
let text =
|
||||
"| A |\n|---|\n| 1 |\n\n| B |\n|---|\n| 2 |\n";
|
||||
let output = render.render(text);
|
||||
let tail = render.finalize();
|
||||
let combined = format!("{output}{tail}");
|
||||
for cell in ["A", "B", "1", "2"] {
|
||||
assert!(combined.contains(cell), "cell {cell:?}: {combined:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_line_immutable_does_not_mutate_table_state() {
|
||||
let options = RenderOptions::default();
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
let _ = render.render_line("| foo | ba");
|
||||
assert!(
|
||||
render.table_state.is_none(),
|
||||
"render_line is immutable; state stays clean",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_markdown_mode_bypasses_table_rendering() {
|
||||
let options = RenderOptions {
|
||||
raw_markdown: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut render = MarkdownRender::init(options).unwrap();
|
||||
let text = "| A | B |\n|---|---|\n| 1 | 2 |\n";
|
||||
let output = render.render(text);
|
||||
assert!(
|
||||
output.contains("| A | B |"),
|
||||
"raw pipes preserved: {output:?}",
|
||||
);
|
||||
assert!(
|
||||
render.table_state.is_none(),
|
||||
"no state entered in raw mode",
|
||||
);
|
||||
}
|
||||
|
||||
fn test_styles() -> MarkdownStyles {
|
||||
MarkdownStyles {
|
||||
heading: (Color::Yellow, true),
|
||||
|
||||
@@ -154,6 +154,11 @@ async fn markdown_stream_inner(
|
||||
writer.flush()?;
|
||||
}
|
||||
SseEvent::Done => {
|
||||
let tail = render.finalize();
|
||||
if !tail.is_empty() {
|
||||
queue!(writer, style::Print("\n"), style::Print(&tail))?;
|
||||
writer.flush()?;
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user