From bf06d5e8f37a0651a5a8b34d9cdf5f6d8eb1e043 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 22 Jul 2026 12:23:44 -0600 Subject: [PATCH] feat(render): wire table state machine and finalize hook --- src/config/app_config.rs | 8 +- src/config/session.rs | 20 +++- src/render/markdown.rs | 236 +++++++++++++++++++++++++++++++++++++-- src/render/stream.rs | 5 + 4 files changed, 255 insertions(+), 14 deletions(-) diff --git a/src/config/app_config.rs b/src/config/app_config.rs index d269ef0..28a0a4f 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -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}"); } diff --git a/src/config/session.rs b/src/config/session.rs index 5800b19..02379f1 100644 --- a/src/config/session.rs +++ b/src/config/session.rs @@ -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()); } diff --git a/src/render/markdown.rs b/src/render/markdown.rs index 70a460f..15008ab 100644 --- a/src/render/markdown.rs +++ b/src/render/markdown.rs @@ -106,7 +106,6 @@ fn detect_line_kind(line: &str) -> LineKind { LineKind::Paragraph } -#[allow(dead_code)] fn parse_table_row(line: &str) -> Vec { let inner = line .trim() @@ -115,7 +114,6 @@ fn parse_table_row(line: &str) -> Vec { inner.split('|').map(|c| c.trim().to_string()).collect() } -#[allow(dead_code)] fn parse_alignments(separator_row: &str) -> Vec { 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, styles: MarkdownStyles, - #[allow(dead_code)] table_state: Option, } @@ -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, @@ -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), diff --git a/src/render/stream.rs b/src/render/stream.rs index c80079f..eac11ab 100644 --- a/src/render/stream.rs +++ b/src/render/stream.rs @@ -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; } }