CX#8: two surfaces — display_lines vs transcript_lines

- 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.
This commit is contained in:
Hunter Bown
2026-04-25 22:42:01 -05:00
parent 8f05f272d3
commit 585dd2f7d0
3 changed files with 648 additions and 55 deletions
+336 -22
View File
@@ -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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
self.render(width, /*low_motion*/ false, RenderMode::Transcript)
}
fn render(&self, width: u16, low_motion: bool, mode: RenderMode) -> Vec<Line<'static>> {
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<Line<'static>> {
self.render(width, low_motion, RenderMode::Live)
}
pub(super) fn render(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
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<Line<'static>> {
pub(super) fn render(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
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<Line<'static>> {
pub(super) fn render(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
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<Line<'static>> {
pub(super) fn render(
&self,
width: u16,
low_motion: bool,
mode: RenderMode,
) -> Vec<Line<'static>> {
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<Line<'static>> {
fn render_command_mode(command: &str, width: u16, mode: RenderMode) -> Vec<Line<'static>> {
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<Line<'static>> {
fn render_tool_output_mode(
output: &str,
width: u16,
line_limit: usize,
mode: RenderMode,
) -> Vec<Line<'static>> {
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<'
all_lines.extend(wrap_text(line, width.saturating_sub(4).max(1) as usize));
}
let total = all_lines.len();
let effective_limit = match mode {
RenderMode::Live => 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<u32>) -> String {
}
}
fn render_exec_output(output: &str, width: u16, line_limit: usize) -> Vec<Line<'static>> {
fn render_exec_output_mode(
output: &str,
width: u16,
line_limit: usize,
mode: RenderMode,
) -> Vec<Line<'static>> {
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<Line<'
}
let total = all_lines.len();
if matches!(mode, RenderMode::Transcript) {
// Full-content path: emit every wrapped line with no head/tail split,
// no "+N more" affordance.
for (idx, line) in all_lines.iter().enumerate() {
lines.extend(render_card_detail_line(
if idx == 0 { Some("output") } else { None },
line,
tool_value_style(),
width,
));
}
return lines;
}
let head_end = total.min(line_limit);
for (idx, line) in all_lines[..head_end].iter().enumerate() {
lines.extend(render_card_detail_line(
@@ -1821,4 +1958,181 @@ mod tests {
assert_eq!(state_span.content.as_ref(), "issue");
assert_eq!(state_span.style.fg, Some(theme.tool_failed_accent));
}
// === display_lines (lines_with_options) vs transcript_lines parity ===
//
// These lock the contract for CX#8: live view compresses thinking and
// caps tool output, transcript view shows the full body. Both surfaces
// must contain the first paragraph / first line of the underlying
// content so users never lose the lede.
fn line_text(line: &ratatui::text::Line<'static>) -> 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::<Vec<_>>().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::<Vec<_>>()
.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));
}
}
+311 -32
View File
@@ -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<Line<'static>>`. 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<Line<'static>> {
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<Block>,
}
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<Line
}
if in_code_block {
let code_style = Style::default()
.fg(palette::DEEPSEEK_SKY)
.add_modifier(Modifier::ITALIC);
out.extend(render_wrapped_line(raw_line, width, code_style, true));
blocks.push(Block::Code {
line: raw_line.to_string(),
});
continue;
}
if let Some((level, text)) = parse_heading(trimmed) {
let style = Style::default()
.fg(palette::DEEPSEEK_SKY)
.add_modifier(Modifier::BOLD);
out.extend(render_wrapped_line(text, width, style, false));
blocks.push(Block::Heading {
level,
text: text.to_string(),
});
if level == 1 {
out.push(Line::from(Span::styled(
"".repeat(width.min(40)),
Style::default().fg(palette::TEXT_DIM),
)));
blocks.push(Block::HeadingRule);
}
continue;
}
if let Some((bullet, text)) = parse_list_item(trimmed) {
let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY);
let content_style = base_style;
out.extend(render_list_line(
&bullet,
text,
width,
bullet_style,
content_style,
));
blocks.push(Block::ListItem {
bullet,
text: text.to_string(),
});
continue;
}
let link_style = Style::default()
.fg(palette::DEEPSEEK_BLUE)
.add_modifier(Modifier::UNDERLINED);
out.extend(render_line_with_links(
trimmed, width, base_style, link_style,
));
if raw_line.is_empty() {
out.push(Line::from(""));
blocks.push(Block::Blank);
continue;
}
blocks.push(Block::Paragraph {
text: trimmed.to_string(),
});
}
ParsedMarkdown { blocks }
}
/// Render a parsed-markdown AST at the given terminal width.
///
/// This is the width-dependent half: word-wrapping, link styling, code-block
/// formatting. The AST is owned by the caller (typically the transcript cache),
/// so width-only changes can call `render_parsed` again with the same AST and
/// skip the parse step entirely.
#[must_use]
pub fn render_parsed(parsed: &ParsedMarkdown, width: u16, base_style: Style) -> Vec<Line<'static>> {
let width = width.max(1) as usize;
let mut out: Vec<Line<'static>> = 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<Line
out
}
/// Convenience wrapper: parse + render in one call.
///
/// Equivalent to `render_parsed(&parse(content), width, base_style)`. Callers
/// that don't manage their own cache (the Thinking body, the immediate message
/// body) use this.
#[must_use]
pub fn render_markdown(content: &str, width: u16, base_style: Style) -> Vec<Line<'static>> {
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<ParsedMarkdown> {
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<String> {
lines
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
fn collect_text(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.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")]);
}
}
+1 -1
View File
@@ -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::<Vec<_>>()