feat(render): wire table state machine and finalize hook

This commit is contained in:
2026-07-22 12:23:44 -06:00
parent cdfaa0f111
commit bf06d5e8f3
4 changed files with 255 additions and 14 deletions
+7 -1
View File
@@ -421,7 +421,13 @@ impl AppConfig {
if *IS_STDOUT_TERMINAL { if *IS_STDOUT_TERMINAL {
let render_options = self.render_options()?; let render_options = self.render_options()?;
let mut markdown_render = MarkdownRender::init(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 { } else {
println!("{text}"); println!("{text}");
} }
+15 -5
View File
@@ -368,14 +368,24 @@ impl Session {
for message in &self.messages { for message in &self.messages {
match message.role { match message.role {
MessageRole::System => { MessageRole::System => {
lines.push( let body = render
render .render(&message.content.render_input(resolve_url_fn, agent_info));
.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 => { MessageRole::Assistant => {
if let MessageContent::Text(text) = &message.content { 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()); lines.push("".into());
} }
+228 -8
View File
@@ -106,7 +106,6 @@ fn detect_line_kind(line: &str) -> LineKind {
LineKind::Paragraph LineKind::Paragraph
} }
#[allow(dead_code)]
fn parse_table_row(line: &str) -> Vec<String> { fn parse_table_row(line: &str) -> Vec<String> {
let inner = line let inner = line
.trim() .trim()
@@ -115,7 +114,6 @@ fn parse_table_row(line: &str) -> Vec<String> {
inner.split('|').map(|c| c.trim().to_string()).collect() inner.split('|').map(|c| c.trim().to_string()).collect()
} }
#[allow(dead_code)]
fn parse_alignments(separator_row: &str) -> Vec<CellAlignment> { fn parse_alignments(separator_row: &str) -> Vec<CellAlignment> {
parse_table_row(separator_row) parse_table_row(separator_row)
.iter() .iter()
@@ -242,7 +240,6 @@ fn render_hrule(styles: &MarkdownStyles) -> String {
"────────".with(styles.hrule).to_string() "────────".with(styles.hrule).to_string()
} }
#[allow(dead_code)]
fn colorize_box_chars(text: &str, color: Color) -> String { fn colorize_box_chars(text: &str, color: Color) -> String {
let sample = "X".with(color).to_string(); let sample = "X".with(color).to_string();
let paint_idx = match sample.find('X') { let paint_idx = match sample.find('X') {
@@ -335,7 +332,6 @@ fn apply_inline(text: &str, styles: &MarkdownStyles) -> String {
}) })
} }
#[allow(dead_code)]
enum TableState { enum TableState {
PendingHeader(String), PendingHeader(String),
Active { Active {
@@ -345,6 +341,12 @@ enum TableState {
}, },
} }
enum TableAction {
Consumed(String),
FlushAndContinue(String),
Passthrough,
}
pub struct MarkdownRender { pub struct MarkdownRender {
options: RenderOptions, options: RenderOptions,
syntax_set: SyntaxSet, syntax_set: SyntaxSet,
@@ -354,7 +356,6 @@ pub struct MarkdownRender {
prev_line_type: LineType, prev_line_type: LineType,
wrap_width: Option<u16>, wrap_width: Option<u16>,
styles: MarkdownStyles, styles: MarkdownStyles,
#[allow(dead_code)]
table_state: Option<TableState>, table_state: Option<TableState>,
} }
@@ -419,6 +420,26 @@ impl MarkdownRender {
fn render_line_mut(&mut self, line: &str) -> String { fn render_line_mut(&mut self, line: &str) -> String {
let (line_type, line_kind, code_syntax, is_code) = self.check_line(line); 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 { let output = if is_code {
self.highlight_code_line(line, &code_syntax) self.highlight_code_line(line, &code_syntax)
} else if self.options.raw_markdown { } else if self.options.raw_markdown {
@@ -428,7 +449,84 @@ impl MarkdownRender {
}; };
self.prev_line_type = line_type; self.prev_line_type = line_type;
self.code_syntax = code_syntax; 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 { fn render_rich_markdown_line(&self, line: &str, kind: LineKind) -> String {
@@ -436,7 +534,6 @@ impl MarkdownRender {
self.wrap_line(styled, false) self.wrap_line(styled, false)
} }
#[allow(dead_code)]
fn render_table( fn render_table(
&self, &self,
header: Vec<String>, header: Vec<String>,
@@ -749,7 +846,6 @@ pub struct MarkdownStyles {
link_url: Color, link_url: Color,
strikethrough: Color, strikethrough: Color,
hrule: Color, hrule: Color,
#[allow(dead_code)]
table_border: Color, 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 { fn test_styles() -> MarkdownStyles {
MarkdownStyles { MarkdownStyles {
heading: (Color::Yellow, true), heading: (Color::Yellow, true),
+5
View File
@@ -154,6 +154,11 @@ async fn markdown_stream_inner(
writer.flush()?; writer.flush()?;
} }
SseEvent::Done => { SseEvent::Done => {
let tail = render.finalize();
if !tail.is_empty() {
queue!(writer, style::Print("\n"), style::Print(&tail))?;
writer.flush()?;
}
break 'outer; break 'outer;
} }
} }