From 585dd2f7d002d226e6bd023430b1a1b24dda89bf Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 25 Apr 2026 22:42:01 -0500 Subject: [PATCH] =?UTF-8?q?CX#8:=20two=20surfaces=20=E2=80=94=20display=5F?= =?UTF-8?q?lines=20vs=20transcript=5Flines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HistoryCell::Thinking — live shows first ~4 lines + Ctrl+O affordance; transcript_lines() returns full content with all paragraphs. - ExecCell — live caps with head/tail + omission marker; transcript emits all wrapped lines without truncation. - Tool/Patch/Mcp/Review cells — live caps + affordance; transcript uncapped. - User/Assistant/System/Plan/Diff/etc — display == transcript. - Pager (Ctrl+O / Ctrl+T) flows through transcript_lines via history_cell_to_text — opening the pager on a thinking or capped tool cell shows the full body. Updated affordance assertion to match the post-CX#9 wording (press Ctrl+O for full text). 911/911 tests pass; clippy -D warnings clean; fmt clean. --- crates/tui/src/tui/history.rs | 358 ++++++++++++++++++++++++-- crates/tui/src/tui/markdown_render.rs | 343 +++++++++++++++++++++--- crates/tui/src/tui/ui_text.rs | 2 +- 3 files changed, 648 insertions(+), 55 deletions(-) diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 8660b280..8c47987f 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -28,6 +28,19 @@ const THINKING_SUMMARY_LINE_LIMIT: usize = 4; const TOOL_DONE_SYMBOL: &str = "•"; const TOOL_FAILED_SYMBOL: &str = "•"; +/// Render mode controlling whether tool/thinking cells render their compact +/// "live" form (with caps and collapsed reasoning) or their full transcript +/// form (uncapped, suitable for the pager / clipboard / message export). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RenderMode { + /// Live in-stream view: thinking is collapsed to a summary, tool output is + /// truncated with a "press v for details" affordance. + Live, + /// Full transcript view: every line of reasoning and tool output is + /// emitted, no caps, no affordance. + Transcript, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ThinkingVisualState { Live, @@ -81,6 +94,13 @@ impl Default for TranscriptRenderOptions { impl HistoryCell { /// Render the cell into a set of terminal lines. + /// + /// This is the live-display path used by widgets that don't already pass + /// `TranscriptRenderOptions`. Tool output is capped, but thinking is shown + /// in full because callers using bare `lines()` historically expected the + /// uncollapsed body. For the in-stream transcript view prefer + /// `lines_with_options`; for the pager / clipboard prefer + /// `transcript_lines`. pub fn lines(&self, width: u16) -> Vec> { match self { HistoryCell::User { content } => render_message( @@ -161,6 +181,35 @@ impl HistoryCell { } } + /// Render the cell in transcript mode: full content, no caps, no + /// "press v for details" affordances. + /// + /// Use this for the pager (`v` / `Ctrl+O`), clipboard exports, and any + /// surface that wants the complete body rather than the live summary. + /// For most variants (User / Assistant / System) this matches `lines()`; + /// `Thinking` and `Tool` are where the live and transcript surfaces + /// diverge. + pub fn transcript_lines(&self, width: u16) -> Vec> { + match self { + HistoryCell::User { .. } + | HistoryCell::Assistant { .. } + | HistoryCell::System { .. } => self.lines(width), + HistoryCell::Thinking { + content, + streaming, + duration_secs, + } => render_thinking( + content, + width, + *streaming, + *duration_secs, + /*collapsed*/ false, + /*low_motion*/ false, + ), + HistoryCell::Tool(cell) => cell.transcript_lines(width), + } + } + /// Whether this cell is the continuation of a streaming assistant message. #[must_use] pub fn is_stream_continuation(&self) -> bool { @@ -274,14 +323,25 @@ impl ToolCell { } pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + self.render(width, low_motion, RenderMode::Live) + } + + /// Full-content rendering for the pager / clipboard. Tool output that + /// would be capped + suffixed with "press v for details" in the live view + /// is emitted in full here. + pub fn transcript_lines(&self, width: u16) -> Vec> { + self.render(width, /*low_motion*/ false, RenderMode::Transcript) + } + + fn render(&self, width: u16, low_motion: bool, mode: RenderMode) -> Vec> { match self { - ToolCell::Exec(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::Exec(cell) => cell.render(width, low_motion, mode), ToolCell::Exploring(cell) => cell.lines_with_motion(width, low_motion), ToolCell::PlanUpdate(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::PatchSummary(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::Review(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::PatchSummary(cell) => cell.render(width, low_motion, mode), + ToolCell::Review(cell) => cell.render(width, low_motion, mode), ToolCell::DiffPreview(cell) => cell.lines_with_motion(width, low_motion), - ToolCell::Mcp(cell) => cell.lines_with_motion(width, low_motion), + ToolCell::Mcp(cell) => cell.render(width, low_motion, mode), ToolCell::ViewImage(cell) => cell.lines_with_motion(width, low_motion), ToolCell::WebSearch(cell) => cell.lines_with_motion(width, low_motion), ToolCell::Generic(cell) => cell.lines_with_motion(width, low_motion), @@ -310,8 +370,18 @@ pub struct ExecCell { } impl ExecCell { - /// Render the execution cell into lines. + /// Render the execution cell into lines (live view, capped output). + #[cfg(test)] pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + self.render(width, low_motion, RenderMode::Live) + } + + pub(super) fn render( + &self, + width: u16, + low_motion: bool, + mode: RenderMode, + ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header( "Shell", @@ -337,12 +407,17 @@ impl ExecCell { width, )); } else { - lines.extend(render_command(&self.command, width)); + lines.extend(render_command_mode(&self.command, width, mode)); } if self.interaction.is_none() { if let Some(output) = self.output.as_ref() { - lines.extend(render_exec_output(output, width, TOOL_OUTPUT_LINE_LIMIT)); + lines.extend(render_exec_output_mode( + output, + width, + TOOL_OUTPUT_LINE_LIMIT, + mode, + )); } else if self.status != ToolStatus::Running { lines.push(Line::from(Span::styled( " (no output)", @@ -495,8 +570,12 @@ pub struct PatchSummaryCell { } impl PatchSummaryCell { - /// Render the patch summary cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + pub(super) fn render( + &self, + width: u16, + low_motion: bool, + mode: RenderMode, + ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header( "Patch", @@ -511,13 +590,19 @@ impl PatchSummaryCell { tool_value_style(), width, )); - lines.extend(render_tool_output( + lines.extend(render_tool_output_mode( &self.summary, width, TOOL_COMMAND_LINE_LIMIT, + mode, )); if let Some(error) = self.error.as_ref() { - lines.extend(render_tool_output(error, width, TOOL_COMMAND_LINE_LIMIT)); + lines.extend(render_tool_output_mode( + error, + width, + TOOL_COMMAND_LINE_LIMIT, + mode, + )); } lines } @@ -533,7 +618,12 @@ pub struct ReviewCell { } impl ReviewCell { - pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + pub(super) fn render( + &self, + width: u16, + low_motion: bool, + mode: RenderMode, + ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header( "Review", @@ -557,7 +647,12 @@ impl ReviewCell { } if let Some(error) = self.error.as_ref() { - lines.extend(render_tool_output(error, width, TOOL_COMMAND_LINE_LIMIT)); + lines.extend(render_tool_output_mode( + error, + width, + TOOL_COMMAND_LINE_LIMIT, + mode, + )); return lines; } @@ -687,8 +782,12 @@ pub struct McpToolCell { } impl McpToolCell { - /// Render the MCP tool cell into lines. - pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { + pub(super) fn render( + &self, + width: u16, + low_motion: bool, + mode: RenderMode, + ) -> Vec> { let mut lines = Vec::new(); lines.push(render_tool_header( "Tool", @@ -714,7 +813,12 @@ impl McpToolCell { } if let Some(content) = self.content.as_ref() { - lines.extend(render_tool_output(content, width, TOOL_COMMAND_LINE_LIMIT)); + lines.extend(render_tool_output_mode( + content, + width, + TOOL_COMMAND_LINE_LIMIT, + mode, + )); } lines } @@ -1238,13 +1342,17 @@ fn render_message( lines } -fn render_command(command: &str, width: u16) -> Vec> { +fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec> { let mut lines = Vec::new(); + let cap = match mode { + RenderMode::Live => TOOL_COMMAND_LINE_LIMIT, + RenderMode::Transcript => usize::MAX, + }; for (count, chunk) in wrap_text(command, width.saturating_sub(4).max(1) as usize) .into_iter() .enumerate() { - if count >= TOOL_COMMAND_LINE_LIMIT { + if count >= cap { lines.push(details_affordance_line( "command clipped; press v for details", Style::default().fg(palette::TEXT_MUTED), @@ -1265,7 +1373,12 @@ fn render_compact_kv(label: &str, value: &str, style: Style, width: u16) -> Vec< render_card_detail_line(Some(label.trim_end_matches(':')), value, style, width) } -fn render_tool_output(output: &str, width: u16, line_limit: usize) -> Vec> { +fn render_tool_output_mode( + output: &str, + width: u16, + line_limit: usize, + mode: RenderMode, +) -> Vec> { let mut lines = Vec::new(); if output.trim().is_empty() { lines.push(Line::from(Span::styled( @@ -1279,9 +1392,13 @@ fn render_tool_output(output: &str, width: u16, line_limit: usize) -> Vec line_limit, + RenderMode::Transcript => usize::MAX, + }; for (idx, line) in all_lines.into_iter().enumerate() { - if idx >= line_limit { - let omitted = total.saturating_sub(line_limit); + if idx >= effective_limit { + let omitted = total.saturating_sub(effective_limit); if omitted > 0 { lines.push(details_affordance_line( &format!("+{omitted} more lines; press v for details"), @@ -1318,7 +1435,12 @@ fn format_review_location(path: Option<&String>, line: Option) -> String { } } -fn render_exec_output(output: &str, width: u16, line_limit: usize) -> Vec> { +fn render_exec_output_mode( + output: &str, + width: u16, + line_limit: usize, + mode: RenderMode, +) -> Vec> { let mut lines = Vec::new(); if output.trim().is_empty() { lines.push(Line::from(Span::styled( @@ -1334,6 +1456,21 @@ fn render_exec_output(output: &str, width: u16, line_limit: usize) -> Vec) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn lines_text(lines: &[ratatui::text::Line<'static>]) -> String { + lines.iter().map(line_text).collect::>().join("\n") + } + + #[test] + fn long_thinking_display_is_shorter_than_transcript() { + // Build a multi-paragraph thinking body so the live view has + // something to compress. The first paragraph is the lede; both + // surfaces must keep it. + let body = "First paragraph lede.\n\ + Second sentence of the first paragraph.\n\n\ + Second paragraph: deeper analysis follows.\n\ + More detail in paragraph two.\n\n\ + Third paragraph: even more reasoning.\n\ + With another line.\n\n\ + Fourth paragraph: the conclusion.\n\ + And one more line for good measure."; + let cell = HistoryCell::Thinking { + content: body.to_string(), + streaming: false, + duration_secs: Some(3.2), + }; + + let live = cell.lines_with_options( + 80, + TranscriptRenderOptions { + low_motion: true, + ..TranscriptRenderOptions::default() + }, + ); + let transcript = cell.transcript_lines(80); + + assert!( + live.len() < transcript.len(), + "live thinking should compress (live = {} lines, transcript = {} lines)", + live.len(), + transcript.len() + ); + + let live_text = lines_text(&live); + let transcript_text = lines_text(&transcript); + + assert!( + live_text.contains("First paragraph lede"), + "live thinking must keep the lede: {live_text}" + ); + assert!( + transcript_text.contains("First paragraph lede"), + "transcript thinking must keep the lede" + ); + assert!( + transcript_text.contains("Fourth paragraph"), + "transcript thinking must keep the full body" + ); + assert!( + !live_text.contains("Fourth paragraph"), + "live thinking must drop the tail when collapsed" + ); + assert!( + live_text.contains("press Ctrl+O for full text"), + "live thinking must offer the pager affordance" + ); + assert!( + !transcript_text.contains("press Ctrl+O for full text"), + "transcript thinking must not include the live affordance" + ); + } + + #[test] + fn short_thinking_display_equals_transcript() { + // A single-line thinking body has nothing to compress; live and + // transcript surfaces should agree. + let cell = HistoryCell::Thinking { + content: "One brief reasoning step.".to_string(), + streaming: false, + duration_secs: Some(0.4), + }; + + let live = cell.lines_with_options( + 80, + TranscriptRenderOptions { + low_motion: true, + ..TranscriptRenderOptions::default() + }, + ); + let transcript = cell.transcript_lines(80); + + let live_text = lines_text(&live); + let transcript_text = lines_text(&transcript); + + assert_eq!( + live_text, transcript_text, + "short thinking must render identically on both surfaces" + ); + assert!( + !live_text.contains("press Ctrl+O for full text"), + "short thinking must not show the collapse affordance" + ); + } + + #[test] + fn tool_exec_live_caps_output_transcript_does_not() { + // Synthesize an exec output that comfortably exceeds the live cap + // (TOOL_OUTPUT_LINE_LIMIT = 6). The live view should hit the cap + // and emit a "+N more lines; press v for details" affordance; the + // transcript view should emit every wrapped line uncapped. + let total_output_lines = 30usize; + let output = (0..total_output_lines) + .map(|i| format!("output line {i:02}")) + .collect::>() + .join("\n"); + + let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "noisy_script.sh".to_string(), + status: ToolStatus::Success, + output: Some(output), + started_at: None, + duration_ms: Some(120), + source: ExecSource::Assistant, + interaction: None, + })); + + let live = cell.lines_with_options( + 80, + TranscriptRenderOptions { + low_motion: true, + ..TranscriptRenderOptions::default() + }, + ); + let transcript = cell.transcript_lines(80); + + let live_text = lines_text(&live); + let transcript_text = lines_text(&transcript); + + assert!( + live.len() < transcript.len(), + "live exec output must be shorter than transcript exec output (live={}, transcript={})", + live.len(), + transcript.len() + ); + assert!( + live_text.contains("press v for details"), + "live exec output must surface the pager affordance: {live_text}" + ); + assert!( + !transcript_text.contains("press v for details"), + "transcript exec output must not include the pager affordance" + ); + // First line is always emitted on both surfaces. + assert!(live_text.contains("output line 00")); + assert!(transcript_text.contains("output line 00")); + // The middle should only appear in the transcript, since the live + // view truncates the head/tail around the cap. + assert!( + transcript_text.contains("output line 15"), + "transcript must include the middle of the exec output" + ); + // Last line should appear in both because the live view shows + // head + tail around an omission marker. + let last = format!("output line {:02}", total_output_lines - 1); + assert!(transcript_text.contains(&last)); + } } diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 4c66a233..57b2226b 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -1,4 +1,33 @@ -//! Simple markdown rendering for TUI transcript lines. +//! Markdown rendering for TUI transcript lines. +//! +//! ## Width-independent parse vs width-dependent render (CX#6) +//! +//! The previous renderer was a single function `render_markdown(content, width)` +//! that scanned the source, classified each line (heading / list / code-fence / +//! paragraph / link), and word-wrapped to `Line<'static>` in one pass. That meant +//! every terminal resize forced a full re-parse of the source for every visible +//! cell — wasted work on the streaming cell whose content is changing anyway. +//! +//! The codex tui solves this by splitting parse from render. We mirror that: +//! +//! * [`parse`] turns the markdown source into a [`ParsedMarkdown`] AST: a vector +//! of width-independent [`Block`]s. The block kind already records all the +//! classification decisions (heading level, list bullet, code block membership) +//! that don't depend on width. +//! * [`render_parsed`] takes a `ParsedMarkdown` plus a width and a base style and +//! produces `Vec>`. It only does word-wrap and span styling. +//! +//! [`render_markdown`] is kept as a thin convenience that does both — useful for +//! callers (Thinking body, message body) that don't want to manage the cache. +//! +//! The transcript cache layer (see `tui/transcript.rs`) caches the parsed AST per +//! cell and re-runs only the render step on width changes. That makes resize a +//! re-flow operation rather than a re-parse + re-flow operation. + +use std::sync::Arc; + +#[cfg(any(test, feature = "perf-counters"))] +use std::sync::atomic::{AtomicU64, Ordering}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; @@ -6,9 +35,83 @@ use unicode_width::UnicodeWidthStr; use crate::palette; -pub fn render_markdown(content: &str, width: u16, base_style: Style) -> Vec> { - let mut out = Vec::new(); - let width = width.max(1) as usize; +/// Per-process counter incremented every time [`parse`] runs. Used by tests to +/// prove that width-only changes hit the cached-AST path and skip parsing. +/// +/// Available in test builds and behind the `perf-counters` feature flag so +/// release builds pay no cost. +#[cfg(any(test, feature = "perf-counters"))] +static PARSE_INVOCATIONS: AtomicU64 = AtomicU64::new(0); + +#[cfg(any(test, feature = "perf-counters"))] +#[must_use] +pub fn parse_invocation_count() -> u64 { + PARSE_INVOCATIONS.load(Ordering::Relaxed) +} + +#[cfg(any(test, feature = "perf-counters"))] +pub fn reset_parse_invocation_count() { + PARSE_INVOCATIONS.store(0, Ordering::Relaxed); +} + +/// One classified line of markdown source, width-independent. +/// +/// All decisions that depend only on the source text (heading level, bullet +/// kind, whether we're inside a fenced code block, paragraph text) are made at +/// parse time. Width-dependent layout (word-wrap, prefix indent) is deferred to +/// the render step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Block { + /// `# heading text`. Includes the heading level (1..6). + Heading { level: usize, text: String }, + /// A horizontal rule emitted under a level-1 heading. + HeadingRule, + /// A bullet (`-`/`*`) or ordered (`1.`) list item with its prefix and body. + ListItem { bullet: String, text: String }, + /// A line inside a fenced code block. Fences themselves are dropped. + Code { line: String }, + /// A non-empty paragraph line that may contain inline links. + Paragraph { text: String }, + /// An empty source line, preserved so paragraph spacing survives. + Blank, +} + +/// Width-independent parsed-markdown AST for one cell's source. +/// +/// Wrapped in `Arc` at the cache layer so the cache can hand the same AST to +/// many render calls without copying. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedMarkdown { + blocks: Vec, +} + +impl ParsedMarkdown { + /// Borrow the parsed blocks (mostly useful for tests). + #[must_use] + pub fn blocks(&self) -> &[Block] { + &self.blocks + } + + /// Whether the parse was empty (no source at all). + #[must_use] + pub fn is_empty(&self) -> bool { + self.blocks.is_empty() + } +} + +/// Parse markdown source into a width-independent block AST. +/// +/// This is a small line-oriented parser tuned for the patterns we render: +/// fenced code blocks, ATX headings, dash/star/numbered list items, and plain +/// paragraphs with optional links. It does not attempt to handle every CommonMark +/// edge case — that's intentional. The renderer will treat anything we don't +/// classify as `Block::Paragraph`. +#[must_use] +pub fn parse(content: &str) -> ParsedMarkdown { + #[cfg(any(test, feature = "perf-counters"))] + PARSE_INVOCATIONS.fetch_add(1, Ordering::Relaxed); + + let mut blocks = Vec::new(); let mut in_code_block = false; for raw_line in content.lines() { @@ -19,48 +122,97 @@ pub fn render_markdown(content: &str, width: u16, base_style: Style) -> Vec Vec> { + let width = width.max(1) as usize; + let mut out: Vec> = Vec::with_capacity(parsed.blocks.len()); + + for block in &parsed.blocks { + match block { + Block::Heading { text, .. } => { + let style = Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::BOLD); + out.extend(render_wrapped_line(text, width, style, false)); + } + Block::HeadingRule => { + out.push(Line::from(Span::styled( + "─".repeat(width.min(40)), + Style::default().fg(palette::TEXT_DIM), + ))); + } + Block::ListItem { bullet, text } => { + let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY); + out.extend(render_list_line( + bullet, + text, + width, + bullet_style, + base_style, + )); + } + Block::Code { line } => { + let code_style = Style::default() + .fg(palette::DEEPSEEK_SKY) + .add_modifier(Modifier::ITALIC); + out.extend(render_wrapped_line(line, width, code_style, true)); + } + Block::Paragraph { text } => { + let link_style = Style::default() + .fg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::UNDERLINED); + out.extend(render_line_with_links(text, width, base_style, link_style)); + } + Block::Blank => { + // Preserve paragraph spacing. The original renderer also pushed + // a blank line for empty source lines that fell through the + // paragraph branch; mirror that exactly. + out.push(Line::from("")); + } } } @@ -71,6 +223,27 @@ pub fn render_markdown(content: &str, width: u16, base_style: Style) -> Vec Vec> { + let parsed = parse(content); + render_parsed(&parsed, width, base_style) +} + +/// Cache-friendly parsed AST for [`HistoryCell`] rendering. +/// +/// Wraps the `ParsedMarkdown` in `Arc` so the transcript cache can hand the +/// same parse to many render passes (e.g. across spacers / overlays) without +/// reallocation. +#[must_use] +pub fn parse_arc(content: &str) -> Arc { + Arc::new(parse(content)) +} + fn parse_heading(line: &str) -> Option<(usize, &str)> { let trimmed = line.trim_start(); let hashes = trimmed.chars().take_while(|c| *c == '#').count(); @@ -257,3 +430,109 @@ fn wrap_text(text: &str, width: usize) -> Vec { lines } + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Style; + + fn collect_text(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + }) + .collect() + } + + #[test] + fn render_markdown_matches_parse_then_render() { + // The convenience wrapper must produce byte-identical output to the + // explicit two-step path. Without this guarantee the transcript cache + // and the live render diverge. + let source = "# Title\n\nA paragraph with a https://example.com link.\n\n- one\n- two\n```\ncode\n```"; + let direct = render_markdown(source, 40, Style::default()); + let parsed = parse(source); + let two_step = render_parsed(&parsed, 40, Style::default()); + assert_eq!(collect_text(&direct), collect_text(&two_step)); + } + + #[test] + fn parse_is_width_independent() { + // Same source, two parses, must produce identical AST. (Sanity: + // parse must not depend on hidden global state like terminal width.) + let source = "Hello\n\n## Heading\n- list\n"; + let a = parse(source); + let b = parse(source); + assert_eq!(a, b); + } + + #[test] + fn render_parsed_word_wrap_changes_with_width() { + // The same AST must produce different layouts at different widths; + // otherwise the split is decorative, not functional. + let parsed = parse("alpha beta gamma delta epsilon zeta"); + let wide = render_parsed(&parsed, 80, Style::default()); + let narrow = render_parsed(&parsed, 10, Style::default()); + assert!( + narrow.len() > wide.len(), + "narrow should produce more lines" + ); + } + + #[test] + fn parse_invocations_increment() { + reset_parse_invocation_count(); + let _ = parse("hello\n"); + let _ = parse("world\n"); + assert_eq!(parse_invocation_count(), 2); + } + + #[test] + fn render_parsed_does_not_call_parse() { + // Width-only changes must hit only the render path. This is the + // perf invariant CX#6 was filed for. + let parsed = parse("multiline\nsource\nwith several\nlines\n"); + reset_parse_invocation_count(); + let _ = render_parsed(&parsed, 80, Style::default()); + let _ = render_parsed(&parsed, 40, Style::default()); + let _ = render_parsed(&parsed, 20, Style::default()); + assert_eq!( + parse_invocation_count(), + 0, + "render_parsed must not call parse" + ); + } + + #[test] + fn fenced_code_block_collected_in_parse() { + let parsed = parse("text\n```\ncode line one\ncode line two\n```\nmore\n"); + let blocks = parsed.blocks(); + // text paragraph, two code lines, more paragraph (fences are dropped) + let code_lines: Vec<_> = blocks + .iter() + .filter_map(|b| match b { + Block::Code { line } => Some(line.as_str()), + _ => None, + }) + .collect(); + assert_eq!(code_lines, vec!["code line one", "code line two"]); + } + + #[test] + fn ordered_and_unordered_list_items_parse() { + let parsed = parse("- alpha\n* beta\n1. gamma\n"); + let items: Vec<_> = parsed + .blocks() + .iter() + .filter_map(|b| match b { + Block::ListItem { bullet, text } => Some((bullet.as_str(), text.as_str())), + _ => None, + }) + .collect(); + assert_eq!(items, vec![("-", "alpha"), ("-", "beta"), ("1.", "gamma")]); + } +} diff --git a/crates/tui/src/tui/ui_text.rs b/crates/tui/src/tui/ui_text.rs index 628f8929..8be85943 100644 --- a/crates/tui/src/tui/ui_text.rs +++ b/crates/tui/src/tui/ui_text.rs @@ -6,7 +6,7 @@ use unicode_width::UnicodeWidthChar; use crate::tui::history::HistoryCell; pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String { - cell.lines(width) + cell.transcript_lines(width) .into_iter() .map(line_to_string) .collect::>()