feat(render): wire table state machine and finalize hook
This commit is contained in:
@@ -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
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user