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:
+336
-22
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<_>>()
|
||||
|
||||
Reference in New Issue
Block a user