Add compact activity rendering and preserve transcript source text (#46709)

## What changed

- Add compact renderers for commands, MCP calls, computer activity, patches, and plans, with bounded previews and retained transcript details.
- Preserve logical source text, styles, and hyperlink metadata across wrapping so transcript copying can exclude display gutters while retaining meaningful labels. Wrap diff text at grapheme boundaries.
- Add activity identities, ordered detail retention, and deduplicated warning metadata to history cells.
- Hide background terminal interaction notices from chat while retaining them in the transcript, and keep local image-only user messages visible without duplicating image placeholders.

## Testing

Add regression and snapshot coverage for compact previews, retained patch diagnostics, wrapped source text and hyperlinks, question-and-answer labels, and local image placeholders.

GitOrigin-RevId: fd82fadd8ed3418eaba2bc74c4327d9c63d7e997
This commit is contained in:
Eric Traut
2026-09-19 20:24:20 +00:00
committed by copyberry
parent fcaa035438
commit 321c50fc2c
62 changed files with 2566 additions and 803 deletions
+22 -3
View File
@@ -24,6 +24,7 @@ use crate::history_cell::ThreadRecapHistoryCell;
use crate::history_cell::ThreadRecapLoadingCell;
use crate::history_cell::UserHistoryCell;
use crate::line_truncation::line_width;
use crate::style::accent_color;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId;
@@ -831,7 +832,7 @@ fn recap_history_cell_wraps_next_action_urls_in_narrow_terminals() {
.iter()
.flat_map(|line| &line.spans)
.find(|span| span.content == "Next: "),
Some(&"Next: ".bold().italic()),
Some(&"Next: ".bold().fg(accent_color()).italic()),
);
let rendered = lines
.iter()
@@ -854,13 +855,31 @@ fn recap_history_cell_preserves_line_breaks_and_optional_next() {
.with_next_action(Some(
"Run focused tests and check the empty-input case.".to_string(),
));
let lines = cell.display_lines(/*width*/ 48);
let hyperlink_lines = cell.display_hyperlink_lines(/*width*/ 48);
for line in &hyperlink_lines {
let source = line.source.as_ref().expect("recap source");
assert!(
source
.styled_range(source.range.clone())
.spans
.iter()
.all(|span| {
span.style
.add_modifier
.contains(ratatui::style::Modifier::DIM)
})
);
}
let lines = hyperlink_lines
.into_iter()
.map(|line| line.line)
.collect::<Vec<_>>();
assert_eq!(
lines
.iter()
.flat_map(|line| &line.spans)
.find(|span| span.content == "Next: "),
Some(&"Next: ".bold().italic()),
Some(&"Next: ".bold().fg(accent_color()).italic()),
);
let area = Rect::new(
/*x*/ 0,
@@ -1,7 +1,10 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: normalize_completion_timestamps(& (combined))
expression: combined
---
• Waited for background terminal · just fix
Chat:
[completion time]
Transcript:
• Waited for background terminal · just fix
[completion time]
+40 -8
View File
@@ -172,6 +172,18 @@ async fn exploration_nonzero_exits_remain_visible_beside_successful_reads() {
end_exec(&mut chat, item, "", "", exit_code);
}
insta::assert_snapshot!(active_blob(&chat));
insta::assert_snapshot!(
"compact_exploration_with_failures",
chat.transcript
.active_cell
.as_ref()
.unwrap()
.compact_hyperlink_lines(/*width*/ 24)
.iter()
.map(|line| line.line.to_string())
.collect::<Vec<_>>()
.join("\n")
);
let statuses = chat
.transcript
.active_cell
@@ -1045,7 +1057,7 @@ async fn unified_exec_wait_after_final_agent_message_snapshot() {
complete_assistant_message(&mut chat, "msg-1", "Final response.", /*phase*/ None);
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
let cells = drain_insert_history_normalized(&mut rx);
let cells = drain_insert_history_transcript_normalized(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
@@ -1069,7 +1081,7 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
handle_agent_message_delta(&mut chat, "Streaming response.");
handle_turn_completed(&mut chat, "turn-wait-1", /*duration_ms*/ None);
let cells = drain_insert_history_normalized(&mut rx);
let cells = drain_insert_history_transcript_normalized(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
@@ -1186,11 +1198,31 @@ async fn unified_exec_waiting_multiple_empty_snapshots() {
handle_turn_completed(&mut chat, "turn-wait-3", /*duration_ms*/ None);
let cells = drain_insert_history_normalized(&mut rx);
let combined = cells
let cells = std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(cell),
_ => None,
})
.collect::<Vec<_>>();
let compact = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.map(|cell| {
normalize_completion_timestamps(
cell.as_ref(),
lines_to_single_string(&cell.display_lines(/*width*/ 80)),
)
})
.collect::<String>();
let transcript = cells
.iter()
.map(|cell| {
normalize_completion_timestamps(
cell.as_ref(),
lines_to_single_string(&cell.transcript_lines(/*width*/ 80)),
)
})
.collect::<String>();
let combined = format!("Chat:\n{compact}\nTranscript:\n{transcript}");
assert_chatwidget_snapshot!("unified_exec_waiting_multiple_empty_after", combined);
}
@@ -1223,7 +1255,7 @@ async fn unified_exec_empty_then_non_empty_snapshot() {
terminal_interaction(&mut chat, "call-wait-2a", "proc-2", "");
terminal_interaction(&mut chat, "call-wait-2b", "proc-2", "ls\n");
let cells = drain_insert_history(&mut rx);
let cells = drain_insert_history_transcript(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
@@ -1249,7 +1281,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
.expect("status indicator should be visible");
assert_eq!(status.header(), "Waiting for background terminal");
assert_eq!(status.details(), Some("just fix"));
let pre_cells = drain_insert_history(&mut rx);
let pre_cells = drain_insert_history_transcript(&mut rx);
let active_combined = pre_cells
.iter()
.map(|lines| lines_to_single_string(lines))
@@ -1258,7 +1290,7 @@ async fn unified_exec_non_empty_then_empty_snapshots() {
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
let post_cells = drain_insert_history_normalized(&mut rx);
let post_cells = drain_insert_history_transcript_normalized(&mut rx);
let mut combined = pre_cells
.iter()
.map(|lines| lines_to_single_string(lines))
@@ -367,6 +367,23 @@ pub(super) fn drain_insert_history_transcript(
drain_insert_history_with(rx, |cell| cell.transcript_lines(/*width*/ 80))
}
// Preserve ordering checks for history cells intentionally hidden from compact chat.
pub(super) fn drain_insert_history_transcript_normalized(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> Vec<Vec<ratatui::text::Line<'static>>> {
drain_insert_history_with(rx, |cell| {
cell.transcript_lines(/*width*/ 80)
.into_iter()
.map(|mut line| {
if cell.as_any().is::<history_cell::FinalMessageSeparator>() {
line.spans = vec![normalize_completion_timestamps(cell, &line).into()];
}
line
})
.collect()
})
}
pub(super) fn drain_insert_history_with(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
render: impl Fn(&dyn HistoryCell) -> Vec<ratatui::text::Line<'static>>,
@@ -1361,7 +1361,7 @@ async fn failed_repl_mcp_tool_call_preserves_status_and_result() {
└ Script failed
{"exit_code": 0, "output": "ready", "chunk_id": "chunk-1"}
Script error:
+1 line (ctrl + t to view transcript)
+1 line (ctrl+t to view transcript)
"#);
}
assert_eq!(
@@ -1369,7 +1369,7 @@ async fn failed_repl_mcp_tool_call_preserves_status_and_result() {
Some(&Line::from(vec![
"•".red().bold(),
" ".into(),
"Inspect workspace".cyan(),
"Inspect workspace".fg(crate::style::accent_color()),
])),
"{server}",
);
@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
assertion_line: 175
expression: "chat.transcript.active_cell.as_ref().unwrap().compact_hyperlink_lines(24).iter().map(|line|\nline.line.to_string()).collect::<Vec<_>>().join(\"\\n\")"
---
• Explored · 4 failed
└ Read first.txt
Read missing.txt (e…
1)
+291 -254
View File
@@ -32,7 +32,6 @@
//! preserved across the split so that no color information is lost. Theme foregrounds
//! are resolved against the painted diff fill, without dimming corrected syntax colors.
use crate::style::readable_color_on;
use diffy::Hunk;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -48,7 +47,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
/// Replacement for a tab character in rendered diff content.
const TAB_REPLACEMENT: &str = " ";
@@ -89,16 +88,21 @@ use crate::render::highlight::DiffScopeBackgrounds;
use crate::render::highlight::diff_scope_backgrounds;
use crate::render::highlight::exceeds_highlight_limits;
use crate::render::highlight::highlight_code_to_styled_spans;
use crate::render::line_utils::prefix_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::LineWrapPolicy;
use crate::terminal_hyperlinks::LogicalLineSource;
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
use crate::terminal_hyperlinks::visible_lines;
use crate::terminal_palette::StdoutColorLevel;
use crate::terminal_palette::XTERM_COLORS;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::indexed_color;
use crate::terminal_palette::rgb_color;
use crate::terminal_palette::stdout_color_level;
use crate::width::display_width;
use codex_git_utils::get_git_repo_root;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
@@ -319,7 +323,7 @@ impl Renderable for FileChange {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut lines = vec![];
render_change(self, &mut lines, area.width as usize, /*lang*/ None);
Paragraph::new(lines).render(area, buf);
Paragraph::new(visible_lines(lines)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
@@ -362,8 +366,27 @@ pub(crate) fn create_diff_summary(
cwd: &Path,
wrap_cols: usize,
) -> Vec<RtLine<'static>> {
visible_lines(create_diff_summary_with_links(changes, cwd, wrap_cols))
}
/// Render the same diff while retaining logical code lines across display wrapping and gutters.
pub(crate) fn create_diff_summary_with_links(
changes: &HashMap<PathBuf, FileChange>,
cwd: &Path,
wrap_cols: usize,
) -> Vec<HyperlinkLine> {
let rows = collect_rows(changes);
render_changes_block(rows, wrap_cols, cwd)
render_changes_block(rows, wrap_cols, cwd, /*preview_lines*/ None)
}
/// Preserve each file's path and change counts while bounding its preview body.
pub(crate) fn create_diff_preview_with_links(
changes: &HashMap<PathBuf, FileChange>,
cwd: &Path,
wrap_cols: usize,
preview_lines: usize,
) -> Vec<HyperlinkLine> {
render_changes_block(collect_rows(changes), wrap_cols, cwd, Some(preview_lines))
}
// Shared row for per-file presentation
@@ -416,8 +439,13 @@ fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static
spans
}
fn render_changes_block(rows: Vec<Row<'_>>, wrap_cols: usize, cwd: &Path) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
fn render_changes_block(
rows: Vec<Row<'_>>,
wrap_cols: usize,
cwd: &Path,
preview_lines: Option<usize>,
) -> Vec<HyperlinkLine> {
let mut out: Vec<HyperlinkLine> = Vec::new();
let render_path = |row: &Row<'_>| -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
@@ -450,7 +478,7 @@ fn render_changes_block(rows: Vec<Row<'_>>, wrap_cols: usize, cwd: &Path) -> Vec
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
out.push(RtLine::from(header_spans));
out.push(RtLine::from(header_spans).into());
for (idx, r) in rows.into_iter().enumerate() {
// Insert a blank separator between file chunks (except before the first)
@@ -465,7 +493,7 @@ fn render_changes_block(rows: Vec<Row<'_>>, wrap_cols: usize, cwd: &Path) -> Vec
header.extend(render_path(&r));
header.push(" ".into());
header.extend(render_line_count_summary(r.added, r.removed));
out.push(RtLine::from(header));
out.push(RtLine::from(header).into());
}
// For renames, use the destination extension for highlighting — the
@@ -475,8 +503,17 @@ fn render_changes_block(rows: Vec<Row<'_>>, wrap_cols: usize, cwd: &Path) -> Vec
let mut lines = vec![];
let prefix = " ";
let content_width = wrap_cols.saturating_sub(prefix.len());
render_change(r.change, &mut lines, content_width, lang.as_deref());
out.extend(prefix_lines(lines, prefix.into(), prefix.into()));
render_change_with_preview(
r.change,
&mut lines,
content_width,
lang.as_deref(),
preview_lines,
);
if let Some(limit) = preview_lines {
lines.truncate(limit);
}
out.extend(prefix_hyperlink_lines(lines, prefix.into(), prefix.into()));
}
out
@@ -492,9 +529,19 @@ fn detect_lang_for_path(path: &Path) -> Option<String> {
fn render_change(
change: &FileChange,
out: &mut Vec<RtLine<'static>>,
out: &mut Vec<HyperlinkLine>,
width: usize,
lang: Option<&str>,
) {
render_change_with_preview(change, out, width, lang, /*preview_lines*/ None);
}
fn render_change_with_preview(
change: &FileChange,
out: &mut Vec<HyperlinkLine>,
width: usize,
lang: Option<&str>,
preview_lines: Option<usize>,
) {
let style_context = current_diff_render_style_context();
match change {
@@ -503,30 +550,29 @@ fn render_change(
let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l));
let line_number_width = line_number_width(content.lines().count());
for (i, raw) in content.lines().enumerate() {
if preview_lines.is_some_and(|limit| out.len() >= limit) {
break;
}
let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i));
if let Some(spans) = syn {
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
out.extend(render_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
width,
line_number_width,
Some(spans),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
style_context,
));
} else {
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
out.extend(render_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
width,
line_number_width,
/*syntax_spans*/ None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
style_context,
));
}
}
@@ -535,30 +581,29 @@ fn render_change(
let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l));
let line_number_width = line_number_width(content.lines().count());
for (i, raw) in content.lines().enumerate() {
if preview_lines.is_some_and(|limit| out.len() >= limit) {
break;
}
let syn = syntax_lines.as_ref().and_then(|sl| sl.get(i));
if let Some(spans) = syn {
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
out.extend(render_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
width,
line_number_width,
Some(spans),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
style_context,
));
} else {
out.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
out.extend(render_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
width,
line_number_width,
/*syntax_spans*/ None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
style_context,
));
}
}
@@ -609,6 +654,9 @@ fn render_change(
let line_number_width = line_number_width(max_line_number);
let mut is_first_hunk = true;
for h in patch.hunks() {
if preview_lines.is_some_and(|limit| out.len() >= limit) {
break;
}
if !is_first_hunk {
let spacer = format!("{:width$} ", "", width = line_number_width.max(1));
let spacer_span = RtSpan::styled(
@@ -619,7 +667,7 @@ fn render_change(
style_context.color_level,
),
);
out.push(RtLine::from(vec![spacer_span, "⋮".dim()]));
out.push(RtLine::from(vec![spacer_span, "⋮".dim()]).into());
}
is_first_hunk = false;
@@ -642,6 +690,9 @@ fn render_change(
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for (line_idx, l) in h.lines().iter().enumerate() {
if preview_lines.is_some_and(|limit| out.len() >= limit) {
break;
}
let syntax_spans = hunk_syntax_lines
.as_ref()
.and_then(|syntax_lines| syntax_lines.get(line_idx));
@@ -649,99 +700,80 @@ fn render_change(
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
if let Some(syn) = syntax_spans {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
Some(syn),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
out.extend(render_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
Some(syn),
style_context,
));
} else {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
/*syntax_spans*/ None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
out.extend(render_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
width,
line_number_width,
/*syntax_spans*/ None,
style_context,
));
}
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
if let Some(syn) = syntax_spans {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
Some(syn),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
out.extend(render_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
Some(syn),
style_context,
));
} else {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
/*syntax_spans*/ None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
out.extend(render_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
width,
line_number_width,
/*syntax_spans*/ None,
style_context,
));
}
old_ln += 1;
}
diffy::Line::Context(text) => {
if preview_lines.is_some() {
old_ln += 1;
new_ln += 1;
continue;
}
let s = text.trim_end_matches('\n');
if let Some(syn) = syntax_spans {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
Some(syn),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
out.extend(render_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
Some(syn),
style_context,
));
} else {
out.extend(
push_wrapped_diff_line_inner_with_theme_and_color_level(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
/*syntax_spans*/ None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
),
);
out.extend(render_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
width,
line_number_width,
/*syntax_spans*/ None,
style_context,
));
}
old_ln += 1;
new_ln += 1;
@@ -811,17 +843,15 @@ pub(crate) fn push_wrapped_diff_line_with_style_context(
line_number_width: usize,
style_context: DiffRenderStyleContext,
) -> Vec<RtLine<'static>> {
push_wrapped_diff_line_inner_with_theme_and_color_level(
visible_lines(render_wrapped_diff_line(
line_number,
kind,
text,
width,
line_number_width,
/*syntax_spans*/ None,
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
)
style_context,
))
}
/// Render a syntax-highlighted diff line, wrapped to `width` columns, using
@@ -829,8 +859,8 @@ pub(crate) fn push_wrapped_diff_line_with_style_context(
///
/// Like [`push_wrapped_diff_line_with_style_context`] but overlays
/// `syntax_spans` (from [`highlight_code_to_styled_spans`]) onto the diff
/// coloring. Syntax foregrounds retain their hue while meeting contrast against the
/// painted diff background; dimming is removed so it cannot undo that correction.
/// coloring. Syntax foregrounds stay readable against the painted diff surface;
/// signs and backgrounds distinguish additions and deletions.
pub(crate) fn push_wrapped_diff_line_with_syntax_and_style_context(
line_number: usize,
kind: DiffLineType,
@@ -840,39 +870,33 @@ pub(crate) fn push_wrapped_diff_line_with_syntax_and_style_context(
syntax_spans: &[RtSpan<'static>],
style_context: DiffRenderStyleContext,
) -> Vec<RtLine<'static>> {
push_wrapped_diff_line_inner_with_theme_and_color_level(
visible_lines(render_wrapped_diff_line(
line_number,
kind,
text,
width,
line_number_width,
Some(syntax_spans),
style_context.theme,
style_context.color_level,
style_context.diff_backgrounds,
)
style_context,
))
}
#[allow(clippy::too_many_arguments)]
fn push_wrapped_diff_line_inner_with_theme_and_color_level(
fn render_wrapped_diff_line(
line_number: usize,
kind: DiffLineType,
text: &str,
width: usize,
line_number_width: usize,
syntax_spans: Option<&[RtSpan<'static>]>,
theme: DiffTheme,
color_level: DiffColorLevel,
diff_backgrounds: ResolvedDiffBackgrounds,
) -> Vec<RtLine<'static>> {
let ln_str = line_number.to_string();
// Reserve a fixed number of spaces (equal to the widest line number plus a
// trailing spacer) so the sign column stays aligned across the diff block.
let gutter_width = line_number_width.max(1);
let prefix_cols = gutter_width + 1;
let (sign_char, sign_style, content_style) = match kind {
style: DiffRenderStyleContext,
) -> Vec<HyperlinkLine> {
let DiffRenderStyleContext {
theme,
color_level,
diff_backgrounds,
} = style;
let gutter_width = line_number_width.max(/*other*/ 1);
let (sign, sign_style, content_style) = match kind {
DiffLineType::Insert => (
'+',
style_sign_add(theme, color_level, diff_backgrounds),
@@ -885,84 +909,77 @@ fn push_wrapped_diff_line_inner_with_theme_and_color_level(
),
DiffLineType::Context => (' ', style_context(), style_context()),
};
let line_bg = style_line_bg_for(kind, diff_backgrounds);
let mut gutter_style = style_gutter_for(kind, theme, color_level);
if line_bg.bg.is_none() {
gutter_style.bg = None;
}
// When we have syntax spans, compose them with the diff style for a richer
// view. The sign keeps the diff color while syntax foregrounds are resolved
// against the actual fill. Dimming must not undo their measured contrast.
if let Some(syn_spans) = syntax_spans {
let gutter = format!("{ln_str:>gutter_width$} ");
let sign = format!("{sign_char}");
let styled: Vec<RtSpan<'static>> = syn_spans
let line_style = style_line_bg_for(kind, diff_backgrounds);
let styled = match syntax_spans {
Some(spans) => spans
.iter()
.map(|sp| {
let style = sp
.map(|span| {
let style = span
.style
.fg(readable_color_on(
sp.style.fg.unwrap_or(Color::Reset),
line_bg.bg,
.fg(crate::style::readable_color_on(
span.style.fg.unwrap_or(Color::Reset),
line_style.bg,
))
.remove_modifier(Modifier::DIM);
RtSpan::styled(sp.content.clone().into_owned(), style)
RtSpan::styled(span.content.clone().into_owned(), style)
})
.collect();
// Determine how many display columns remain for content after the
// gutter and sign character.
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
// Wrap the styled content spans to fit within the available columns.
let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols);
let mut lines: Vec<RtLine<'static>> = Vec::new();
for (i, chunk) in wrapped_chunks.into_iter().enumerate() {
let mut row_spans: Vec<RtSpan<'static>> = Vec::new();
if i == 0 {
// First line: gutter + sign + content
row_spans.push(RtSpan::styled(gutter.clone(), gutter_style));
row_spans.push(RtSpan::styled(sign.clone(), sign_style));
} else {
// Continuation: empty gutter + two-space indent (matches
// the plain-text wrapping continuation style).
let cont_gutter = format!("{:gutter_width$} ", "");
row_spans.push(RtSpan::styled(cont_gutter, gutter_style));
}
row_spans.extend(chunk);
lines.push(RtLine::from(row_spans).style(line_bg));
}
return lines;
.collect(),
None => vec![RtSpan::styled(
text.to_string(),
content_style.fg(crate::style::readable_color_on(
content_style.fg.unwrap_or(Color::Reset),
line_style.bg,
)),
)],
};
let source_spans =
std::iter::once(RtSpan::styled(sign.to_string(), sign_style))
.chain(styled.iter().map(|span| {
RtSpan::styled(span.content.replace('\t', TAB_REPLACEMENT), span.style)
}))
.collect::<Vec<_>>();
let mut source = LogicalLineSource::from_line(&RtLine::from(source_spans).style(line_style));
source.wrap_policy = LineWrapPolicy::Hard;
let mut gutter_style = style_gutter_for(kind, theme, color_level);
if line_style.bg.is_none() {
gutter_style.bg = None;
}
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
let content_style = content_style.fg(readable_color_on(
content_style.fg.unwrap_or(Color::Reset),
line_bg.bg,
let continuation = RtLine::from(RtSpan::styled(
format!("{:gutter_width$} ", ""),
gutter_style,
));
let styled = vec![RtSpan::styled(text.to_string(), content_style)];
let wrapped_chunks = wrap_styled_spans(&styled, available_content_cols);
let mut lines: Vec<RtLine<'static>> = Vec::new();
for (i, chunk) in wrapped_chunks.into_iter().enumerate() {
let mut row_spans: Vec<RtSpan<'static>> = Vec::new();
if i == 0 {
let gutter = format!("{ln_str:>gutter_width$} ");
let sign = format!("{sign_char}");
row_spans.push(RtSpan::styled(gutter, gutter_style));
row_spans.push(RtSpan::styled(sign, sign_style));
} else {
let cont_gutter = format!("{:gutter_width$} ", "");
row_spans.push(RtSpan::styled(cont_gutter, gutter_style));
}
row_spans.extend(chunk);
lines.push(RtLine::from(row_spans).style(line_bg));
}
lines
source.continuation_indent = continuation.clone();
let available = width.saturating_sub(gutter_width + 2).max(/*other*/ 1);
let chunks = wrap_styled_spans(&styled, available);
let mut offset = 0;
chunks
.into_iter()
.enumerate()
.map(|(index, chunk)| {
let body_len: usize = chunk.iter().map(|span| span.content.len()).sum();
let prefix = if index == 0 {
RtLine::from(RtSpan::styled(
format!("{line_number:>gutter_width$} "),
gutter_style,
))
} else {
continuation.clone()
};
let mut row_source = source.clone();
row_source.prefix_bytes = prefix.spans.iter().map(|span| span.content.len()).sum();
let mut spans = prefix.spans;
if index == 0 {
spans.push(RtSpan::styled(sign.to_string(), sign_style));
}
spans.extend(chunk);
let end = offset + body_len + usize::from(index == 0);
row_source.range = offset..end;
offset = end;
let mut line = HyperlinkLine::new(RtLine::from(spans).style(line_style));
line.source = Some(row_source);
line
})
.collect()
}
/// Split styled spans into chunks that fit within `max_cols` display columns.
@@ -970,13 +987,16 @@ fn push_wrapped_diff_line_inner_with_theme_and_color_level(
/// Returns one `Vec<RtSpan>` per output line. Styles are preserved across
/// split boundaries so that wrapping never loses syntax coloring.
///
/// The algorithm walks characters using their Unicode display width (with tabs
/// The algorithm walks graphemes using their Unicode display width (with tabs
/// expanded to [`TAB_WIDTH`] columns). When a character would overflow the
/// current line, the accumulated text is flushed and a new line begins. A
/// single character wider than the remaining space forces a line break *before*
/// the character so that progress is always made (avoiding infinite loops on
/// CJK characters or tabs at the end of a line).
fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSpan<'static>>> {
pub(crate) fn wrap_styled_spans(
spans: &[RtSpan<'static>],
max_cols: usize,
) -> Vec<Vec<RtSpan<'static>>> {
let mut result: Vec<Vec<RtSpan<'static>>> = Vec::new();
let mut current_line: Vec<RtSpan<'static>> = Vec::new();
let mut col: usize = 0;
@@ -991,9 +1011,13 @@ fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSp
let mut byte_end = 0;
let mut chars_col = 0;
for ch in remaining.chars() {
for grapheme in remaining.graphemes(/*is_extended*/ true) {
// Tabs have no Unicode width; treat them as TAB_WIDTH columns.
let w = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 });
let w = if grapheme == "\t" {
TAB_WIDTH
} else {
display_width(grapheme)
};
if col + chars_col + w > max_cols {
// Adding this character would exceed the line width.
// Break here; if this is the first character in `remaining`
@@ -1001,7 +1025,7 @@ fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSp
// branch below before consuming it.
break;
}
byte_end += ch.len_utf8();
byte_end += grapheme.len();
chars_col += w;
}
@@ -1012,17 +1036,21 @@ fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSp
result.push(std::mem::take(&mut current_line));
}
// Take at least one character to avoid an infinite loop.
let Some(ch) = remaining.chars().next() else {
let Some(grapheme) = remaining.graphemes(/*is_extended*/ true).next() else {
break;
};
let ch_len = ch.len_utf8();
let ch_len = grapheme.len();
current_line.push(RtSpan::styled(
remaining[..ch_len].replace('\t', TAB_REPLACEMENT),
style,
));
// Use fallback width 1 (not 0) so this branch always advances
// even if `ch` has unknown/zero display width.
col = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 1 });
col = if grapheme == "\t" {
TAB_WIDTH
} else {
display_width(grapheme).max(/*other*/ 1)
};
remaining = &remaining[ch_len..];
continue;
}
@@ -1345,6 +1373,7 @@ mod tests {
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthChar;
#[test]
fn syntax_colors_follow_the_actual_diff_background() {
@@ -1375,16 +1404,17 @@ mod tests {
let lines = [DiffLineType::Insert, DiffLineType::Delete]
.into_iter()
.flat_map(|kind| {
push_wrapped_diff_line_with_syntax_and_style_context(
render_wrapped_diff_line(
/*line_number*/ 1,
kind,
"let value = 1; plain reset",
/*width*/ 40,
/*line_number_width*/ 1,
&spans,
Some(&spans),
style,
)
})
.map(|line| line.line)
.collect::<Vec<_>>();
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 2,
@@ -1393,7 +1423,6 @@ mod tests {
Paragraph::new(lines).render(area, &mut buffer);
assert!(!buffer[(3, 0)].modifier.contains(Modifier::DIM));
assert!(!buffer[(3, 1)].modifier.contains(Modifier::DIM));
assert_eq!(buffer[(3, 1)].fg, rgb_color((235, 210, 170)));
assert_ne!(buffer[(3, 0)].fg, buffer[(3, 1)].fg);
assert_ne!(buffer[(18, 0)].fg, buffer[(18, 1)].fg);
assert_ne!(buffer[(24, 0)].fg, buffer[(24, 1)].fg);
@@ -1847,13 +1876,13 @@ mod tests {
highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting");
let spans = &syntax_spans[0];
let lines = push_wrapped_diff_line_with_syntax_and_style_context(
let lines = render_wrapped_diff_line(
/*line_number*/ 1,
DiffLineType::Insert,
long_rust,
/*width*/ 80,
line_number_width(/*max_line_number*/ 1),
spans,
Some(spans),
current_diff_render_style_context(),
);
@@ -1863,35 +1892,31 @@ mod tests {
lines.len()
);
for line in &lines {
let source = line.source.as_ref().expect("diff source");
let restored = source.styled_range(source.range.clone());
let visible = RtLine::from(line.line.spans[1..].to_vec()).style(line.line.style);
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
restored.width() as u16,
/*height*/ 1,
);
let mut restored_buffer = Buffer::empty(area);
let mut visible_buffer = Buffer::empty(area);
Paragraph::new(restored).render(area, &mut restored_buffer);
Paragraph::new(visible).render(area, &mut visible_buffer);
assert_eq!(restored_buffer, visible_buffer);
}
snapshot_lines(
"syntax_highlighted_insert_wraps",
lines,
visible_lines(lines),
/*width*/ 90,
/*height*/ 10,
);
}
#[test]
fn ui_snapshot_syntax_highlighted_insert_wraps_text() {
let long_rust = "fn very_long_function_name(arg_one: String, arg_two: String, arg_three: String, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_one) }";
let syntax_spans =
highlight_code_to_styled_spans(long_rust, "rust").expect("rust highlighting");
let spans = &syntax_spans[0];
let lines = push_wrapped_diff_line_with_syntax_and_style_context(
/*line_number*/ 1,
DiffLineType::Insert,
long_rust,
/*width*/ 80,
line_number_width(/*max_line_number*/ 1),
spans,
current_diff_render_style_context(),
);
snapshot_lines_text("syntax_highlighted_insert_wraps_text", &lines);
}
#[test]
fn ui_snapshot_diff_gallery_80x24() {
snapshot_diff_gallery("diff_gallery_80x24", /*width*/ 80, /*height*/ 24);
@@ -1913,27 +1938,35 @@ mod tests {
#[test]
fn ui_snapshot_ansi16_insert_delete_no_background() {
let mut lines = push_wrapped_diff_line_inner_with_theme_and_color_level(
let mut lines = push_wrapped_diff_line_with_style_context(
/*line_number*/ 1,
DiffLineType::Insert,
"added in ansi16 mode",
/*width*/ 80,
line_number_width(/*max_line_number*/ 2),
/*syntax_spans*/ None,
DiffTheme::Dark,
DiffColorLevel::Ansi16,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
DiffRenderStyleContext {
theme: DiffTheme::Dark,
color_level: DiffColorLevel::Ansi16,
diff_backgrounds: fallback_diff_backgrounds(
DiffTheme::Dark,
DiffColorLevel::Ansi16,
),
},
);
lines.extend(push_wrapped_diff_line_inner_with_theme_and_color_level(
lines.extend(push_wrapped_diff_line_with_style_context(
/*line_number*/ 2,
DiffLineType::Delete,
"deleted in ansi16 mode",
/*width*/ 80,
line_number_width(/*max_line_number*/ 2),
/*syntax_spans*/ None,
DiffTheme::Dark,
DiffColorLevel::Ansi16,
fallback_diff_backgrounds(DiffTheme::Dark, DiffColorLevel::Ansi16),
DiffRenderStyleContext {
theme: DiffTheme::Dark,
color_level: DiffColorLevel::Ansi16,
diff_backgrounds: fallback_diff_backgrounds(
DiffTheme::Dark,
DiffColorLevel::Ansi16,
),
},
));
snapshot_lines(
@@ -2186,16 +2219,20 @@ mod tests {
#[test]
fn light_theme_wrapped_lines_keep_number_gutter_contrast() {
let lines = push_wrapped_diff_line_inner_with_theme_and_color_level(
let lines = push_wrapped_diff_line_with_style_context(
/*line_number*/ 12,
DiffLineType::Insert,
"abcdefghij",
/*width*/ 8,
line_number_width(/*max_line_number*/ 12),
/*syntax_spans*/ None,
DiffTheme::Light,
DiffColorLevel::TrueColor,
fallback_diff_backgrounds(DiffTheme::Light, DiffColorLevel::TrueColor),
DiffRenderStyleContext {
theme: DiffTheme::Light,
color_level: DiffColorLevel::TrueColor,
diff_backgrounds: fallback_diff_backgrounds(
DiffTheme::Light,
DiffColorLevel::TrueColor,
),
},
);
assert!(
+113
View File
@@ -0,0 +1,113 @@
//! Owned-transcript command previews with full command/output retained for local disclosure.
use super::model::ExecCell;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::activity_preview::DETAIL_PREVIEW_LINES;
use crate::history_cell::activity_preview::clipped_line;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::render::highlight::highlight_bash_to_lines;
use crate::terminal_hyperlinks::HyperlinkLine;
use codex_ansi_escape::ansi_escape_line;
use ratatui::style::Modifier;
use ratatui::style::Stylize;
use ratatui::text::Line;
impl ExecCell {
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(super) fn command_has_hidden_details(&self, width: u16) -> bool {
let [call] = self.group.calls.as_slice() else {
return false;
};
if !self
.group
.details
.lines_after(/*after_calls*/ 1, width, HistoryRenderMode::Rich)
.is_empty()
{
return true;
}
let script = strip_bash_lc_and_escape(&call.command);
if script.lines().nth(/*n*/ 1).is_some()
|| self
.compact_command_lines(u16::MAX)
.first()
.is_some_and(|header| header.width() > usize::from(width))
{
return true;
}
call.output.as_ref().is_some_and(|output| {
output.line_counts().1 > DETAIL_PREVIEW_LINES
|| output
.transcript_lines()
.any(|line| ansi_escape_line(line.as_ref()).width() + 4 > usize::from(width))
})
}
pub(super) fn compact_command_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let [call] = self.group.calls.as_slice() else {
return Vec::new();
};
if call.is_unified_exec_interaction()
&& !self.is_active()
&& call.output.as_ref().is_some_and(|output| {
output.exit_code == 0 && output.lines().all(|line| line.trim().is_empty())
})
{
return Vec::new();
}
let failed = call.output.as_ref().filter(|output| output.exit_code != 0);
let marker = if failed.is_some() {
"•".red().bold()
} else if self.is_active() {
activity_indicator(
call.start_time,
MotionMode::from_animations_enabled(self.animations_enabled()),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "•".dim())
} else {
"•".green().bold()
};
let title = if let Some(output) = failed {
format!("Failed (exit {})", output.exit_code)
} else if self.is_active() {
"Running".to_owned()
} else if call.is_unified_exec_interaction() {
"Background output".to_owned()
} else {
"Ran".to_owned()
};
let script = strip_bash_lc_and_escape(&call.command);
// Highlight before clipping so the compact row keeps the same shell token colors.
let mut command_lines = highlight_bash_to_lines(&script).into_iter();
let mut header = command_lines.next().unwrap_or_default();
header
.spans
.splice(0..0, [marker, " ".into(), title.bold(), " ".into()]);
if command_lines.next().is_some() {
header.push_span(" …");
}
let mut lines = vec![clipped_line(header, width)];
if let Some(output) = &call.output {
let tail: Vec<_> = output.lines().rev().take(DETAIL_PREVIEW_LINES).collect();
for (index, raw) in tail.into_iter().rev().enumerate() {
let mut line = ansi_escape_line(raw.as_ref());
line.spans.insert(
/*index*/ 0,
if index == 0 { " └ " } else { " " }.dim(),
);
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
lines.push(clipped_line(line, width));
}
if lines.len() == 1 && !call.is_unified_exec_interaction() {
lines.push(clipped_line(Line::from(" └ (no output)".dim()), width));
}
}
lines
}
}
+4 -1
View File
@@ -146,7 +146,10 @@ impl LiveCommandOutput {
self.head
.iter()
.map(|line| Cow::Borrowed(line.as_str()))
.chain((omitted > 0).then(|| Cow::Owned(format!("… +{omitted} lines"))))
.chain((omitted > 0).then(|| {
let noun = if omitted == 1 { "line" } else { "lines" };
Cow::Owned(format!("… +{omitted} {noun}"))
}))
.chain(self.tail.iter().map(|line| Cow::Borrowed(line.as_str())))
.chain(
self.has_partial_line
+1
View File
@@ -1,3 +1,4 @@
mod compact;
mod live_output;
mod model;
mod render;
+6
View File
@@ -115,6 +115,12 @@ impl ExecCell {
}
}
/// Preserve live clocks and pending output when a validated older page extends exploration.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn prepend(&mut self, older: Self) {
self.group.prepend(older.group);
}
/// Marks the most recently matching call as finished and returns whether a call was found.
///
/// Callers should treat `false` as a routing mismatch rather than silently ignoring it. The
+238 -132
View File
@@ -1,3 +1,5 @@
//! Command previews and full transcripts, retaining logical text through display wrapping.
use std::time::Instant;
use super::model::CommandOutput;
@@ -11,12 +13,19 @@ use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::tool_output::tool_output_preview;
use crate::render::line_utils::line_to_static;
use crate::style::accent_color;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::LogicalLineSource;
use crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
use crate::terminal_hyperlinks::remap_source_wrapped_line;
use crate::terminal_hyperlinks::visible_lines;
use crate::tool_output::tool_output_hyperlink_preview;
use crate::ui_consts::TRANSCRIPT_HINT;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::adaptive_wrap_line_with_source;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use codex_protocol::parse_command::ParsedCommand;
@@ -41,6 +50,12 @@ pub(crate) struct OutputLinesParams {
pub(crate) include_prefix: bool,
}
struct CommandDisplay {
lines: Vec<HyperlinkLine>,
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
hidden_details: bool,
}
pub(crate) fn new_active_exec_command(
call_id: String,
command: Vec<String>,
@@ -139,7 +154,7 @@ pub(crate) fn output_lines(
} else {
" "
};
line.spans.insert(0, prefix.dim());
line.spans.insert(/*index*/ 0, prefix.dim());
out.push(line);
}
@@ -157,7 +172,7 @@ pub(crate) fn output_lines(
for raw in tail.into_iter().rev() {
let mut line = dimmed_output_line(raw.as_ref());
if include_prefix {
line.spans.insert(0, " ".dim());
line.spans.insert(/*index*/ 0, " ".dim());
}
out.push(line);
}
@@ -176,9 +191,9 @@ fn dimmed_output_line(raw: &str) -> Line<'static> {
line
}
fn output_preview_lines(output: &CommandOutput, width: usize) -> Vec<Line<'static>> {
fn output_preview_lines(output: &CommandOutput, width: usize) -> Vec<HyperlinkLine> {
let (total, _) = output.line_counts();
tool_output_preview(
tool_output_hyperlink_preview(
output.lines().map(|raw| dimmed_output_line(raw.as_ref())),
width,
total,
@@ -197,14 +212,65 @@ fn activity_marker(start_time: Option<Instant>, animations_enabled: bool) -> Spa
impl HistoryCell for ExecCell {
fn append_reasoning(&mut self, cell: Box<dyn HistoryCell>) -> Result<(), Box<dyn HistoryCell>> {
if self.is_exploring_cell() {
self.group.push_reasoning(cell);
self.group.push_detail(std::sync::Arc::from(cell));
Ok(())
} else {
Err(cell)
}
}
fn has_hidden_activity_details(&self, width: u16) -> bool {
if self.is_exploring_cell() {
return true;
}
if self.group.calls.iter().any(ExecCall::is_user_shell_command) {
// User-shell previews already retain wrapped commands and up to fifty output rows.
// Reuse its actual truncation decision instead of comparing differently styled text.
return self
.command_display_lines_with_hidden_details(width)
.hidden_details;
}
self.command_has_hidden_details(width)
}
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
if self.group.calls.iter().any(ExecCall::is_user_shell_command) {
return self.display_hyperlink_lines(width);
}
if self.is_exploring_cell() {
let mut lines = self.exploring_display_lines(width);
lines.truncate(1 + crate::history_cell::activity_preview::DETAIL_PREVIEW_LINES);
let failures = self
.group
.calls
.iter()
.filter(|call| {
call.output
.as_ref()
.is_some_and(|output| output.exit_code != 0)
})
.count();
if failures > 0
&& let Some(header) = lines.first_mut()
{
header
.line
.push_span(format!(" · {failures} failed").red().bold());
}
lines
.into_iter()
.map(|line| crate::history_cell::activity_preview::clipped_line(line.line, width))
.collect()
} else {
self.compact_command_lines(width)
}
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.display_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
if self.is_exploring_cell() {
self.exploring_display_lines(width)
} else {
@@ -213,38 +279,54 @@ impl HistoryCell for ExecCell {
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.detailed_lines(width, HistoryRenderMode::Rich)
visible_lines(self.transcript_hyperlink_lines(width))
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.detailed_hyperlink_lines(width, HistoryRenderMode::Rich)
}
fn activity_ids(&self) -> Vec<String> {
self.iter_calls()
.map(|call| format!("exec:{}", call.call_id))
.collect()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.detailed_lines(u16::MAX, HistoryRenderMode::Raw))
plain_lines(visible_lines(
self.detailed_hyperlink_lines(u16::MAX, HistoryRenderMode::Raw),
))
}
}
impl ExecCell {
fn output_ellipsis_text(omitted: usize) -> String {
format!("… +{omitted} lines ({TRANSCRIPT_HINT})")
let noun = if omitted == 1 { "line" } else { "lines" };
format!("… +{omitted} {noun} ({TRANSCRIPT_HINT})")
}
fn output_ellipsis_line(omitted: usize) -> Line<'static> {
Line::from(vec![Self::output_ellipsis_text(omitted).dim()])
}
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from(vec![
if self.is_active() {
activity_marker(self.active_start_time(), self.animations_enabled())
} else {
"•".dim()
},
" ".into(),
if self.is_active() {
"Exploring".bold()
} else {
"Explored".bold()
},
]));
fn exploring_display_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let mut out: Vec<HyperlinkLine> = Vec::new();
out.push(
Line::from(vec![
if self.is_active() {
activity_marker(self.active_start_time(), self.animations_enabled())
} else {
"•".dim()
},
" ".into(),
if self.is_active() {
"Exploring".bold()
} else {
"Explored".bold()
},
])
.into(),
);
let mut calls = self.group.calls.as_slice();
let mut out_indented = Vec::new();
@@ -344,23 +426,31 @@ impl ExecCell {
);
}
let line = Line::from(line);
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
let initial_indent = Line::from(vec![title.fg(accent_color()), " ".into()]);
let subsequent_indent = " ".repeat(initial_indent.width()).into();
let wrapped = adaptive_wrap_line(
&line,
let wrapped = adaptive_wrap_hyperlink_lines(
&[line.into()],
RtOptions::new(width as usize)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent),
);
push_owned_lines(&wrapped, &mut out_indented);
out_indented.extend(wrapped);
}
}
out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into()));
out.extend(prefix_hyperlink_lines(
out_indented,
" └ ".dim(),
" ".into(),
));
out
}
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
fn command_display_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.command_display_lines_with_hidden_details(width).lines
}
fn command_display_lines_with_hidden_details(&self, width: u16) -> CommandDisplay {
let [call] = &self.group.calls.as_slice() else {
panic!("Expected exactly one call in a command display cell");
};
@@ -384,12 +474,13 @@ impl ExecCell {
"Ran"
};
let mut header_line = if is_interaction {
let header_line = if is_interaction {
Line::from(vec![bullet.clone(), " ".into()])
} else {
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()])
};
let header_prefix_width = header_line.width();
let mut header = HyperlinkLine::from(header_line.clone());
let cmd_display = if call.is_unified_exec_interaction() {
format_unified_exec_interaction(&call.command, call.interaction_input.as_deref())
@@ -402,37 +493,58 @@ impl ExecCell {
let continuation_opts =
RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation);
let mut continuation_lines: Vec<Line<'static>> = Vec::new();
let mut continuation_lines: Vec<HyperlinkLine> = Vec::new();
if let Some((first, rest)) = highlighted_lines.split_first() {
let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1);
let available_first_width = (width as usize)
.saturating_sub(header_prefix_width)
.max(/*other*/ 1);
let first_opts =
RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation);
let mut first_wrapped: Vec<Line<'static>> = Vec::new();
push_owned_lines(&adaptive_wrap_line(first, first_opts), &mut first_wrapped);
let source = LogicalLineSource::from_line(first);
let first_wrapped = adaptive_wrap_line_with_source(first, first_opts);
let mut first_wrapped_iter = first_wrapped.into_iter();
if let Some(first_segment) = first_wrapped_iter.next() {
header_line.extend(first_segment);
let mut header_source = source.clone();
header_source.range = first_segment.range;
header_source.prefix_bytes = first_segment.prefix_bytes
+ header_line
.spans
.iter()
.map(|span| span.content.len())
.sum::<usize>();
header_source.continuation_indent =
Line::from(layout.command_continuation.subsequent_prefix);
let mut line = line_to_static(&first_segment.line);
line.spans.splice(0..0, header_line.spans.clone());
header = HyperlinkLine::new(line);
header.source = Some(header_source);
}
continuation_lines.extend(first_wrapped_iter);
let mut command = HyperlinkLine::new(first.clone());
command.source = Some(source);
continuation_lines.extend(remap_source_wrapped_line(
&command,
first_wrapped_iter.collect(),
));
for line in rest {
push_owned_lines(
&adaptive_wrap_line(line, continuation_opts.clone()),
&mut continuation_lines,
);
continuation_lines.extend(adaptive_wrap_hyperlink_lines(
&[line.clone().into()],
continuation_opts.clone(),
));
}
}
let mut lines: Vec<Line<'static>> = vec![header_line];
let mut lines: Vec<HyperlinkLine> = vec![header];
let mut hidden_details = continuation_lines.len() > layout.command_continuation_max_lines;
let continuation_lines = Self::limit_lines_from_start(
&continuation_lines,
layout.command_continuation_max_lines,
);
if !continuation_lines.is_empty() {
lines.extend(prefix_lines(
lines.extend(prefix_hyperlink_lines(
continuation_lines,
Span::from(layout.command_continuation.initial_prefix).dim(),
Span::from(layout.command_continuation.subsequent_prefix).dim(),
@@ -443,16 +555,22 @@ impl ExecCell {
if !call.is_user_shell_command() {
let preview = output_preview_lines(output, layout.output_block.wrap_width(width));
let preview = if preview.is_empty() && !call.is_unified_exec_interaction() {
vec![Line::from("(no output)".dim())]
vec![Line::from("(no output)".dim()).into()]
} else {
preview
};
lines.extend(prefix_lines(
// Only the omission hint lacks source provenance; visible output rows retain it.
hidden_details |=
preview.iter().any(|line| line.source.is_none()) && output.line_counts().0 > 0;
lines.extend(prefix_hyperlink_lines(
preview,
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
return lines;
return CommandDisplay {
lines,
hidden_details,
};
}
let raw_output = output_lines(
Some(output),
@@ -466,8 +584,8 @@ impl ExecCell {
if raw_output.lines.is_empty() {
if !call.is_unified_exec_interaction() {
lines.extend(prefix_lines(
vec![Line::from("(no output)".dim())],
lines.extend(prefix_hyperlink_lines(
vec![Line::from("(no output)".dim()).into()],
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
@@ -476,18 +594,16 @@ impl ExecCell {
// Wrap first so that truncation is applied to on-screen lines
// rather than logical lines. This ensures that a small number
// of very long lines cannot flood the viewport.
let mut wrapped_output: Vec<Line<'static>> = Vec::new();
let output_wrap_width = layout.output_block.wrap_width(width);
let output_opts =
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
for line in &raw_output.lines {
push_owned_lines(
&adaptive_wrap_line(line, output_opts.clone()),
&mut wrapped_output,
);
}
let wrapped_output = adaptive_wrap_hyperlink_lines(
&plain_hyperlink_lines(raw_output.lines),
output_opts,
);
let prefixed_output = prefix_lines(
let prefixed_output = prefix_hyperlink_lines(
wrapped_output,
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
@@ -501,6 +617,9 @@ impl ExecCell {
Span::from(layout.output_block.subsequent_prefix).dim(),
)),
);
// Source-backed output rows retain provenance through prefixing and wrapping.
// Middle truncation inserts an unbacked omission row only when output is hidden.
hidden_details |= trimmed_output.iter().any(|line| line.source.is_none());
if !trimmed_output.is_empty() {
lines.extend(trimmed_output);
@@ -508,19 +627,22 @@ impl ExecCell {
}
}
lines
CommandDisplay {
lines,
hidden_details,
}
}
fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec<Line<'static>> {
fn limit_lines_from_start(lines: &[HyperlinkLine], keep: usize) -> Vec<HyperlinkLine> {
if lines.len() <= keep {
return lines.to_vec();
}
if keep == 0 {
return vec![Self::ellipsis_line(lines.len())];
return vec![Self::ellipsis_line(lines.len()).into()];
}
let mut out: Vec<Line<'static>> = lines[..keep].to_vec();
out.push(Self::ellipsis_line(lines.len() - keep));
let mut out: Vec<HyperlinkLine> = lines[..keep].to_vec();
out.push(Self::ellipsis_line(lines.len() - keep).into());
out
}
@@ -541,13 +663,13 @@ impl ExecCell {
/// omitted count (from upstream truncation); `ellipsis_prefix`
/// prepends the output gutter prefix to the ellipsis line.
fn truncate_lines_middle(
lines: &[Line<'static>],
lines: &[HyperlinkLine],
max_rows: usize,
width: u16,
omitted_hint: Option<usize>,
ellipsis_prefix: Option<Line<'static>>,
) -> Vec<Line<'static>> {
let width = width.max(1);
) -> Vec<HyperlinkLine> {
let width = width.max(/*other*/ 1);
if max_rows == 0 {
return Vec::new();
}
@@ -555,16 +677,17 @@ impl ExecCell {
.iter()
.map(|line| {
let is_whitespace_only = line
.line
.spans
.iter()
.all(|span| span.content.chars().all(char::is_whitespace));
if is_whitespace_only {
line.width().div_ceil(usize::from(width)).max(1)
line.width().div_ceil(usize::from(width)).max(/*other*/ 1)
} else {
Paragraph::new(Text::from(vec![line.clone()]))
Paragraph::new(Text::from(vec![line.line.clone()]))
.wrap(Wrap { trim: false })
.line_count(width)
.max(1)
.max(/*other*/ 1)
}
})
.collect();
@@ -574,23 +697,23 @@ impl ExecCell {
}
// Reserve space for the transcript hint itself so the returned output
// still respects the row budget on narrow terminals.
let estimated_omitted = omitted_hint.unwrap_or(0)
let estimated_omitted = omitted_hint.unwrap_or(/*default*/ 0)
+ lines
.len()
.saturating_sub(usize::from(omitted_hint.is_some()));
let ellipsis_rows =
Self::output_ellipsis_row_count(estimated_omitted, width, ellipsis_prefix.as_ref());
if ellipsis_rows >= max_rows {
return vec![Self::output_ellipsis_line_with_prefix(
estimated_omitted,
ellipsis_prefix.as_ref(),
)];
return vec![
Self::output_ellipsis_line_with_prefix(estimated_omitted, ellipsis_prefix.as_ref())
.into(),
];
}
let available_rows = max_rows - ellipsis_rows;
let head_budget = available_rows / 2;
let tail_budget = available_rows - head_budget;
let mut head_lines: Vec<Line<'static>> = Vec::new();
let mut head_lines: Vec<HyperlinkLine> = Vec::new();
let mut head_rows = 0usize;
let mut head_end = 0usize;
while head_end < lines.len() {
@@ -603,7 +726,7 @@ impl ExecCell {
head_end += 1;
}
let mut tail_lines_reversed: Vec<Line<'static>> = Vec::new();
let mut tail_lines_reversed: Vec<HyperlinkLine> = Vec::new();
let mut tail_rows = 0usize;
let mut tail_start = lines.len();
while tail_start > head_end {
@@ -618,15 +741,15 @@ impl ExecCell {
}
let mut out = head_lines;
let base = omitted_hint.unwrap_or(0);
let base = omitted_hint.unwrap_or(/*default*/ 0);
let additional = lines
.len()
.saturating_sub(out.len() + tail_lines_reversed.len())
.saturating_sub(usize::from(omitted_hint.is_some()));
out.push(Self::output_ellipsis_line_with_prefix(
base + additional,
ellipsis_prefix.as_ref(),
));
out.push(
Self::output_ellipsis_line_with_prefix(base + additional, ellipsis_prefix.as_ref())
.into(),
);
out.extend(tail_lines_reversed.into_iter().rev());
@@ -634,7 +757,8 @@ impl ExecCell {
}
fn ellipsis_line(omitted: usize) -> Line<'static> {
Line::from(vec![format!("… +{omitted} lines").dim()])
let noun = if omitted == 1 { "line" } else { "lines" };
Line::from(vec![format!("… +{omitted} {noun}").dim()])
}
fn output_ellipsis_row_count(
@@ -650,7 +774,7 @@ impl ExecCell {
.max(1)
}
/// Builds an output ellipsis line (`… +N lines (ctrl + t to view transcript)`)
/// Builds an output ellipsis line (`… +N lines (ctrl+t to view transcript)`)
/// with an optional leading prefix so the ellipsis aligns with the output gutter.
fn output_ellipsis_line_with_prefix(
omitted: usize,
@@ -713,6 +837,9 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new(
#[cfg(test)]
mod tests {
use super::*;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::wrapping::adaptive_wrap_line;
use codex_app_server_protocol::CommandExecutionSource as ExecCommandSource;
use pretty_assertions::assert_eq;
@@ -788,7 +915,7 @@ mod tests {
let cell = ExecCell::new(call, /*animations_enabled*/ false);
// Use a narrow width so each logical line wraps into many on-screen lines.
let lines = cell.command_display_lines(width);
let lines = cell.display_lines(width);
let rendered_rows = Paragraph::new(Text::from(lines.clone()))
.wrap(Wrap { trim: false })
.line_count(width);
@@ -837,18 +964,21 @@ mod tests {
];
let truncated = ExecCell::truncate_lines_middle(
&lines,
&plain_hyperlink_lines(lines),
/*max_rows*/ 2,
/*width*/ 80,
Some(4),
Some(Line::from(" ".dim())),
);
let rendered: Vec<String> = truncated.iter().map(render_line_text).collect();
let rendered: Vec<String> = visible_lines(truncated)
.iter()
.map(render_line_text)
.collect();
assert!(
rendered
.iter()
.any(|line| line.contains("… +6 lines (ctrl + t to view transcript)")),
.any(|line| line.contains("… +6 lines (ctrl+t to view transcript)")),
"expected omitted hint to count hidden lines (not wrapped rows), got: {rendered:?}"
);
}
@@ -876,13 +1006,7 @@ mod tests {
assert_eq!(
rendered,
vec![
"1",
"2",
"… +3 lines (ctrl + t to view transcript)",
"6",
"7",
]
vec!["1", "2", "… +3 lines (ctrl+t to view transcript)", "6", "7",]
);
}
@@ -970,11 +1094,12 @@ mod tests {
let preview = cell.display_lines(/*width*/ 60);
cell.group.calls[0].start_time = None;
cell.mark_failed();
let compact = visible_lines(cell.compact_hyperlink_lines(/*width*/ 60));
let transcript = cell.transcript_lines(/*width*/ 60);
insta::assert_debug_snapshot!(
"truncated_live_output_preview_and_transcript",
(preview, transcript)
(preview, compact, transcript)
);
}
@@ -1010,20 +1135,23 @@ mod tests {
fn command_truncation_ellipsis_does_not_include_transcript_hint() {
let truncated = ExecCell::limit_lines_from_start(
&[
Line::from("first"),
Line::from("second"),
Line::from("third"),
HyperlinkLine::from("first"),
HyperlinkLine::from("second"),
HyperlinkLine::from("third"),
],
/*keep*/ 2,
);
let rendered: Vec<String> = truncated.iter().map(render_line_text).collect();
let rendered: Vec<String> = visible_lines(truncated)
.iter()
.map(render_line_text)
.collect();
assert_eq!(
rendered,
vec![
"first".to_string(),
"second".to_string(),
"… +1 lines".to_string(),
"… +1 line".to_string(),
]
);
}
@@ -1035,11 +1163,14 @@ mod tests {
lines.push(Line::from(" end"));
let truncated = ExecCell::truncate_lines_middle(
&lines, /*max_rows*/ 28, /*width*/ 80, /*omitted_hint*/ None,
&plain_hyperlink_lines(lines.clone()),
/*max_rows*/ 28,
/*width*/ 80,
/*omitted_hint*/ None,
/*ellipsis_prefix*/ None,
);
assert_eq!(truncated, lines);
assert_eq!(visible_lines(truncated), lines);
}
#[test]
@@ -1059,7 +1190,7 @@ mod tests {
let cell = ExecCell::new(call, /*animations_enabled*/ false);
let rendered: Vec<String> = cell
.command_display_lines(/*width*/ 36)
.display_lines(/*width*/ 36)
.iter()
.map(|line| {
line.spans
@@ -1091,12 +1222,12 @@ mod tests {
let cell = ExecCell::new(call, /*animations_enabled*/ false);
let first: Vec<String> = cell
.command_display_lines(/*width*/ 80)
.display_lines(/*width*/ 80)
.iter()
.map(render_line_text)
.collect();
let second: Vec<String> = cell
.command_display_lines(/*width*/ 80)
.display_lines(/*width*/ 80)
.iter()
.map(render_line_text)
.collect();
@@ -1162,7 +1293,7 @@ mod tests {
let cell = ExecCell::new(call, /*animations_enabled*/ false);
let rendered: Vec<String> = cell
.command_display_lines(/*width*/ 36)
.display_lines(/*width*/ 36)
.iter()
.map(|line| {
line.spans
@@ -1178,29 +1309,4 @@ mod tests {
"expected full URL-like token in one rendered line, got: {rendered:?}"
);
}
#[test]
fn desired_transcript_height_accounts_for_wrapped_url_like_rows() {
let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going/for/testing/purposes";
let call = ExecCall {
call_id: "call-id".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo done".into()],
parsed: Vec::new(),
output: Some(CommandOutput::new(/*exit_code*/ 0, url.to_string())),
source: ExecCommandSource::Agent,
start_time: None,
duration: None,
interaction_input: None,
};
let cell = ExecCell::new(call, /*animations_enabled*/ false);
let width: u16 = 36;
let logical_height = cell.transcript_lines(width).len() as u16;
let wrapped_height = cell.desired_transcript_height(width);
assert!(
wrapped_height > logical_height,
"expected transcript height to account for wrapped URL-like rows, logical_height={logical_height}, wrapped_height={wrapped_height}"
);
}
}
@@ -1,6 +1,7 @@
---
source: tui/src/exec_cell/render.rs
expression: "(preview, transcript)"
assertion_line: 1100
expression: "(preview, compact, transcript)"
---
(
[
@@ -23,6 +24,22 @@ expression: "(preview, transcript)"
Span::from("tail output that also wraps").green().bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
]),
],
[
Line::from_iter([
Span::from("•").red().bold(),
Span::from(" "),
Span::from("Failed (exit 1)").bold(),
Span::from(" "),
Span::from("echo").fg(Color::Rgb(137, 180, 250)),
Span::from(" output").fg(Color::Rgb(205, 214, 244)),
]),
Line::from_iter([
Span::from(" └ ").dim(),
Span::from("head error that wraps onto the next row").red().dim(),
Span::from("... 1189804 byte").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
Span::from("…").fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).dim().not_bold().not_italic().not_underlined().not_slow_blink().not_rapid_blink().not_reversed().not_hidden().not_crossed_out(),
]),
],
[
Line::from_iter([
Span::from("$ ").magenta(),
+28 -19
View File
@@ -1,30 +1,33 @@
//! Expanded command history, preserving reasoning between grouped exploration calls.
//!
//! Compact history groups adjacent exploration; expanded history retains chronological details,
//! and raw history continues to omit transcript-only reasoning.
//! Expanded command history with source-backed selection and chronological activity details.
//! Raw history omits transcript-only reasoning while retaining full command output.
use super::model::ExecCell;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryRenderMode;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::line_utils::push_owned_lines;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::adaptive_wrap_lines;
use codex_ansi_escape::ansi_escape_line;
use codex_utils_elapsed::format_duration;
use ratatui::prelude::*;
impl ExecCell {
pub(super) fn detailed_lines(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
self.group.transcript_lines(width, mode, |i, call, lines| {
pub(super) fn detailed_hyperlink_lines(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Vec<HyperlinkLine> {
let mut lines: Vec<HyperlinkLine> = vec![];
for (i, call) in self.iter_calls().enumerate() {
if i > 0 {
lines.push("".into());
}
let script = strip_bash_lc_and_escape(&call.command);
let highlighted_script = highlight_bash_to_lines(&script);
let cmd_display = adaptive_wrap_lines(
&highlighted_script,
let cmd_display = adaptive_wrap_hyperlink_lines(
&plain_hyperlink_lines(highlighted_script),
RtOptions::new(width as usize)
.initial_indent("$ ".magenta().into())
.subsequent_indent(" ".into()),
@@ -33,18 +36,19 @@ impl ExecCell {
if let Some(output) = call.output.as_ref() {
if !call.is_unified_exec_interaction() {
let wrap_width = width.max(1) as usize;
let wrap_width = width.max(/*other*/ 1) as usize;
let wrap_opts = RtOptions::new(wrap_width);
for unwrapped in output
.transcript_lines()
.map(|line| ansi_escape_line(line.as_ref()))
{
let wrapped = adaptive_wrap_line(&unwrapped, wrap_opts.clone());
push_owned_lines(&wrapped, lines);
lines.extend(adaptive_wrap_hyperlink_lines(
&[unwrapped.into()],
wrap_opts.clone(),
));
}
}
if let Some(duration) = call.duration {
let duration = format_duration(duration);
if call.duration.is_some() || output.exit_code != 0 {
let mut result: Line = if output.exit_code == 0 {
Line::from("✓".green().bold())
} else {
@@ -53,10 +57,15 @@ impl ExecCell {
format!(" ({})", output.exit_code).into(),
])
};
result.push_span(format!(" • {duration}").dim());
lines.push(result);
if let Some(duration) = call.duration {
let duration = format_duration(duration);
result.push_span(format!(" • {duration}").dim());
}
lines.push(result.into());
}
}
})
lines.extend(self.group.details.lines_after(i + 1, width, mode));
}
lines
}
}
@@ -0,0 +1,52 @@
//! Retain hidden reasoning between activity calls without breaking their compact group.
//!
//! Positions count preceding calls. Prepending history shifts those positions while sharing the
//! original immutable detail cells, so expanded output keeps its chronological source content.
//! Raw projection follows each cell's contract, retaining terminal input while hiding reasoning.
use super::HistoryCell;
use super::HistoryRenderMode;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
use std::sync::Arc;
#[derive(Debug, Default, Clone)]
pub(crate) struct ActivityDetails {
entries: Vec<(usize, Arc<dyn HistoryCell>)>,
}
impl ActivityDetails {
pub(crate) fn push(&mut self, after_calls: usize, cell: Arc<dyn HistoryCell>) {
self.entries.push((after_calls, cell));
}
pub(crate) fn prepend(&mut self, mut older: Self, older_calls: usize) {
for (position, _) in &mut self.entries {
*position += older_calls;
}
older.entries.append(&mut self.entries);
*self = older;
}
pub(crate) fn lines_after(
&self,
after_calls: usize,
width: u16,
mode: HistoryRenderMode,
) -> Vec<HyperlinkLine> {
self.entries
.iter()
.filter(|(position, _)| *position == after_calls)
.flat_map(|(_, cell)| {
let mut lines = match mode {
HistoryRenderMode::Rich => cell.transcript_hyperlink_lines(width),
HistoryRenderMode::Raw => plain_hyperlink_lines(cell.raw_lines()),
};
if mode == HistoryRenderMode::Rich && !lines.is_empty() {
lines.insert(/*index*/ 0, HyperlinkLine::from(""));
}
lines
})
.collect()
}
}
+12 -32
View File
@@ -1,52 +1,32 @@
//! Ordered calls and transcript-only reasoning for adjacent activity groups.
//! Ordered calls and transcript details for adjacent activity groups.
//!
//! Tool cells own call matching and compact previews. Reasoning is attached after the calls
//! already started, preserving its position as they complete without breaking the compact display.
//! Call positions stay fixed as in-flight calls complete. Prepending older calls shifts the
//! shared detail positions without recreating live clocks or discarding raw terminal input.
use super::ActivityDetails;
use super::HistoryCell;
use super::HistoryRenderMode;
use ratatui::text::Line;
use std::sync::Arc;
#[derive(Debug)]
pub(crate) struct ActivityGroup<T> {
pub(crate) calls: Vec<T>,
reasoning: Vec<(usize, Box<dyn HistoryCell>)>,
pub(crate) details: ActivityDetails,
}
impl<T> ActivityGroup<T> {
pub(crate) fn new(calls: Vec<T>) -> Self {
Self {
calls,
reasoning: Vec::new(),
details: ActivityDetails::default(),
}
}
pub(crate) fn push_reasoning(&mut self, cell: Box<dyn HistoryCell>) {
self.reasoning.push((self.calls.len(), cell));
pub(crate) fn push_detail(&mut self, cell: Arc<dyn HistoryCell>) {
self.details.push(self.calls.len(), cell);
}
pub(crate) fn transcript_lines(
&self,
width: u16,
mode: HistoryRenderMode,
mut render_call: impl FnMut(usize, &T, &mut Vec<Line<'static>>),
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut reasoning = self.reasoning.iter().peekable();
for (i, call) in self.calls.iter().enumerate() {
render_call(i, call, &mut lines);
if mode == HistoryRenderMode::Rich {
while let Some((_, cell)) =
reasoning.next_if(|(after_calls, _)| *after_calls == i + 1)
{
let reasoning_lines = cell.transcript_lines(width);
if !reasoning_lines.is_empty() {
lines.push("".into());
lines.extend(reasoning_lines);
}
}
}
}
lines
pub(crate) fn prepend(&mut self, older: Self) {
self.details.prepend(older.details, older.calls.len());
self.calls.splice(0..0, older.calls);
}
}
@@ -0,0 +1,12 @@
//! Shared bounds for owned-transcript action previews; expansion uses retained source content.
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::terminal_hyperlinks::HyperlinkLine;
use ratatui::text::Line;
pub(crate) const DETAIL_PREVIEW_LINES: usize = 3;
/// Clip a preview row without teaching selection to copy text that is currently hidden.
pub(crate) fn clipped_line(line: Line<'static>, width: u16) -> HyperlinkLine {
truncate_line_with_ellipsis_if_overflow(line, usize::from(width)).into()
}
+3 -2
View File
@@ -1,6 +1,7 @@
//! Approval, denial, and review-status transcript cells.
use super::*;
use crate::style::accent_color;
fn truncate_exec_snippet(full_cmd: &str) -> String {
let mut snippet = match full_cmd.split_once('\n') {
@@ -342,9 +343,9 @@ pub fn new_guardian_timed_out_action_request(summary: String) -> Box<dyn History
Box::new(PrefixedWrappedHistoryCell::new(line, "✗ ".red(), " "))
}
/// Cyan history cell line showing the current review status.
/// Accented history cell line showing the current review status.
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
PlainHistoryCell {
lines: vec![Line::from(message.cyan())],
lines: vec![Line::from(message.fg(accent_color()))],
}
}
+43
View File
@@ -1,4 +1,5 @@
//! Shared history-cell building blocks reused across transcript concerns.
//! Wrapped prefixes remain display-only while annotated rows retain their logical source.
use super::*;
@@ -116,6 +117,48 @@ impl CompositeHistoryCell {
}
impl HistoryCell for CompositeHistoryCell {
fn warning_entries(&self) -> Vec<WarningEntry> {
self.parts
.iter()
.flat_map(|part| part.warning_entries())
.collect()
}
fn live_raw_lines(&self) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for part in &self.parts {
let part = part.live_raw_lines();
if !part.is_empty() {
if !lines.is_empty() {
lines.push(Line::default());
}
lines.extend(part);
}
}
lines
}
fn warning_keys(&self) -> Vec<WarningKey<'_>> {
self.parts
.iter()
.flat_map(|part| part.warning_keys())
.collect()
}
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let mut out = Vec::new();
for part in &self.parts {
let lines = part.compact_hyperlink_lines(width);
if !lines.is_empty() {
if !out.is_empty() {
out.push(HyperlinkLine::from(""));
}
out.extend(lines);
}
}
out
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let mut first = true;
@@ -8,7 +8,7 @@ use super::*;
#[derive(Debug)]
pub(crate) struct ComputerActivityCell {
group: ActivityGroup<McpToolCallCell>,
pub(crate) group: ActivityGroup<McpToolCallCell>,
}
impl Default for ComputerActivityCell {
@@ -20,6 +20,26 @@ impl Default for ComputerActivityCell {
}
impl ComputerActivityCell {
fn detailed_hyperlink_lines(&self, width: u16, mode: HistoryRenderMode) -> Vec<HyperlinkLine> {
let mut lines = Vec::new();
for (index, call) in self.group.calls.iter().enumerate() {
lines.extend(call.transcript_hyperlink_lines(width));
lines.extend(self.group.details.lines_after(index + 1, width, mode));
}
lines
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn call_ids(&self) -> impl Iterator<Item = &str> {
self.group.calls.iter().map(McpToolCallCell::call_id)
}
/// Prepend validated historical calls without recreating pending live calls or their clocks.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn prepend(&mut self, older: Self) {
self.group.prepend(older.group);
}
pub(crate) fn start(&mut self, call: McpToolCallCell) {
if !self
.group
@@ -105,11 +125,26 @@ impl HistoryCell for ComputerActivityCell {
if self.group.calls.is_empty() {
Err(cell)
} else {
self.group.push_reasoning(cell);
self.group.push_detail(std::sync::Arc::from(cell));
Ok(())
}
}
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let mut lines = self.display_lines(width);
if width > 0 && !self.is_active() && self.group.calls.len() > 3 {
// The owned transcript supplies the shared disclosure control. Legacy history keeps
// its built-in summary, which is the final row whenever completed calls are hidden.
lines.pop();
if let Some(line) = lines.last_mut()
&& let Some(prefix) = line.spans.first_mut()
{
*prefix = " └ ".dim();
}
}
plain_hyperlink_lines(lines)
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
@@ -217,19 +252,20 @@ impl HistoryCell for ComputerActivityCell {
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.group
.transcript_lines(width, HistoryRenderMode::Rich, |_, call, lines| {
lines.extend(call.transcript_lines(width));
})
visible_lines(self.transcript_hyperlink_lines(width))
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.detailed_hyperlink_lines(width, HistoryRenderMode::Rich)
}
fn activity_ids(&self) -> Vec<String> {
self.call_ids().map(|id| format!("mcp:{id}")).collect()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.group.transcript_lines(
u16::MAX,
HistoryRenderMode::Raw,
|_, call, lines| {
lines.extend(call.transcript_lines(u16::MAX));
},
plain_lines(visible_lines(
self.detailed_hyperlink_lines(u16::MAX, HistoryRenderMode::Raw),
))
}
@@ -32,6 +32,38 @@ fn render(cell: &ComputerActivityCell, width: u16) -> String {
.join("\n")
}
#[test]
fn prepending_completed_history_preserves_the_pending_call_clock() {
let mut older = ComputerActivityCell::default();
older.complete(
call("older", "Read the page"),
Duration::ZERO,
result("read"),
);
let mut live = ComputerActivityCell::default();
live.start(call("pending", "Inspect selection"));
let start_time = live.group.calls[0].start_time;
live.prepend(older);
assert_eq!(
(
live.call_ids().collect::<Vec<_>>(),
live.group.calls[1].start_time,
live.is_active()
),
(vec!["older", "pending"], start_time, true),
);
live.complete(
call("pending", "Inspect selection"),
Duration::ZERO,
result("inspected"),
);
assert_eq!(
(live.call_ids().collect::<Vec<_>>(), live.is_active()),
(vec!["older", "pending"], false),
);
}
#[test]
fn computer_activity_active_and_interrupted() {
let mut cell = ComputerActivityCell::default();
@@ -83,10 +115,18 @@ fn computer_activity_prioritizes_errors_and_images_without_reordering() {
);
insta::assert_snapshot!("computer_activity_mixed", render(&cell, /*width*/ 90));
insta::assert_snapshot!("computer_activity_narrow", render(&cell, /*width*/ 24));
insta::assert_snapshot!(
"computer_activity_compact",
cell.compact_hyperlink_lines(/*width*/ 24)
.iter()
.map(|line| line.line.to_string())
.collect::<Vec<_>>()
.join("\n")
);
let transcript = cell
.transcript_lines(/*width*/ 100)
.iter()
.map(ToString::to_string)
.map(|line| line.to_string().trim_end().to_owned())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("computer_activity_expanded", transcript);
+26 -8
View File
@@ -1,6 +1,11 @@
//! Background terminal interaction and process-summary history cells.
//!
//! Polling and stdin bookkeeping stay in detailed and raw history; the normal chat view
//! relies on command output and the background-process status instead of repeating notices.
use super::*;
use crate::style::accent_color;
use crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines;
use crate::width::display_width;
#[derive(Debug)]
@@ -19,7 +24,15 @@ impl UnifiedExecInteractionCell {
}
impl HistoryCell for UnifiedExecInteractionCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
Vec::new()
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.transcript_hyperlink_lines(width))
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
if width == 0 {
return Vec::new();
}
@@ -39,9 +52,7 @@ impl HistoryCell for UnifiedExecInteractionCell {
}
let header = Line::from(header_spans);
let mut out: Vec<Line<'static>> = Vec::new();
let header_wrapped = adaptive_wrap_line(&header, RtOptions::new(wrap_width));
push_owned_lines(&header_wrapped, &mut out);
let mut out = adaptive_wrap_hyperlink_lines(&[header.into()], RtOptions::new(wrap_width));
if waited_only {
return out;
@@ -53,8 +64,8 @@ impl HistoryCell for UnifiedExecInteractionCell {
.map(|line| Line::from(line.to_string()))
.collect();
let input_wrapped = adaptive_wrap_lines(
input_lines,
let input_wrapped = adaptive_wrap_hyperlink_lines(
&plain_hyperlink_lines(input_lines),
RtOptions::new(wrap_width)
.initial_indent(Line::from(" └ ".dim()))
.subsequent_indent(Line::from(" ".dim())),
@@ -176,10 +187,17 @@ impl HistoryCell for UnifiedExecProcessesCell {
if needs_suffix && budget > truncation_suffix_width {
let available = budget.saturating_sub(truncation_suffix_width);
let (truncated, _, _) = take_prefix_by_width(&snippet, available);
out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into());
out.push(
vec![
prefix.dim(),
truncated.fg(accent_color()),
truncation_suffix.dim(),
]
.into(),
);
} else {
let (truncated, _, _) = take_prefix_by_width(&snippet, budget);
out.push(vec![prefix.dim(), truncated.cyan()].into());
out.push(vec![prefix.dim(), truncated.fg(accent_color())].into());
}
let chunk_prefix_first = " ↳ ";
+190 -119
View File
@@ -1,6 +1,7 @@
//! MCP tool-call, inventory, and output history cells.
//! Tool output previews share a three-row budget across all result blocks;
//! the expanded transcript retains the full text.
//! Invocation and result rows retain exact logical source ranges through wrapping and gutters.
use super::*;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
@@ -10,16 +11,25 @@ use codex_protocol::mcp::is_node_repl_backed_server;
#[path = "mcp_result.rs"]
mod result;
#[path = "mcp_preview.rs"]
mod preview;
#[path = "computer_activity.rs"]
mod computer_activity;
pub(crate) use computer_activity::ComputerActivityCell;
use crate::style::StatusTone;
use crate::style::accent_color;
use crate::style::accent_style;
use crate::style::status_style;
use crate::terminal_hyperlinks::LogicalLineSource;
use crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines;
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
use crate::terminal_hyperlinks::remap_source_wrapped_line;
use crate::text_formatting::format_json_compact;
use crate::tool_output::ToolOutputPreview;
use codex_app_server_protocol::McpServerConnectionStatus;
use result::McpContentBlock;
use result::McpResultKind;
use result::McpToolResult;
use std::borrow::Cow;
@@ -132,8 +142,12 @@ impl McpToolCallCell {
}
}
fn render_lines(&self, width: u16, mode: McpToolCallRenderMode) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
fn render_hyperlink_lines(
&self,
width: u16,
mode: McpToolCallRenderMode,
) -> Vec<HyperlinkLine> {
let mut lines: Vec<HyperlinkLine> = Vec::new();
let status = self.success();
let node_repl = self.result_kind() == McpResultKind::NodeRepl;
let compact = node_repl && mode == McpToolCallRenderMode::Display;
@@ -169,7 +183,7 @@ impl McpToolCallCell {
.unwrap_or_else(|| {
format!("{}.{}", self.invocation.server, self.invocation.tool)
})
.cyan(),
.fg(accent_color()),
)
} else {
line_to_static(&format_mcp_invocation(&self.invocation))
@@ -186,143 +200,184 @@ impl McpToolCallCell {
if inline_invocation {
compact_header.extend(invocation_line.spans.clone());
lines.push(if compact {
lines.push(mcp_header_line(if compact {
truncate_line_with_ellipsis_if_overflow(compact_header, width as usize)
} else {
compact_header
});
}));
} else {
compact_spans.pop(); // drop trailing space for standalone header
lines.push(Line::from(compact_spans));
lines.push(mcp_header_line(Line::from(compact_spans)));
let opts = RtOptions::new((width as usize).saturating_sub(4))
let opts = RtOptions::new((width as usize).saturating_sub(/*rhs*/ 4))
.initial_indent("".into())
.subsequent_indent(" ".into());
let wrapped = adaptive_wrap_line(&invocation_line, opts);
let body_lines: Vec<Line<'static>> = wrapped.iter().map(line_to_static).collect();
lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into()));
let body_lines =
adaptive_wrap_hyperlink_lines(&[HyperlinkLine::new(invocation_line)], opts);
lines.extend(prefix_hyperlink_lines(
body_lines,
" └ ".dim(),
" ".into(),
));
}
let mut detail_lines: Vec<Line<'static>> = Vec::new();
// Reserve four columns for the tree prefix (" └ "/" ") and ensure the wrapper still has at least one cell to work with.
let detail_wrap_width = (width as usize).saturating_sub(4).max(1);
let mut preview = ToolOutputPreview::new(detail_wrap_width, /*omitted*/ 0);
if let Some(result) = &self.result {
match result {
Ok(McpToolResult { content, .. }) => {
if !content.is_empty() {
for block in content {
// Code-mode image blocks can carry text as well. Preserve that text
// without losing the image indication when it replaces the summary.
if node_repl && block.is_image && block.text().is_some() {
if mode == McpToolCallRenderMode::Display {
preview.push_line(Line::from("Returned image".dim()));
} else {
detail_lines.extend(
textwrap::wrap("Returned image", detail_wrap_width)
.into_iter()
.map(|line| Line::from(line.into_owned().dim())),
);
}
}
let text = if compact && status == Some(true) {
let meaningful_output = block.text().and_then(|text| {
if text.starts_with("Script completed\n") {
return text
.split_once("\nOutput:\n")
.map(|(_, output)| Cow::Borrowed(output));
}
serde_json::from_str::<NodeReplExecOutput>(text)
.ok()
.filter(|output| output.exit_code == 0)
.map(|output| Cow::Owned(output.output))
});
match meaningful_output {
Some(output) if output.is_empty() => continue,
Some(output) => format_json_compact(&output)
.map(Cow::Owned)
.unwrap_or(output),
None => block.text().map_or_else(
|| block.render(),
|text| {
format_json_compact(text)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(text))
},
),
}
} else if node_repl && let Some(output) = block.text() {
if mode == McpToolCallRenderMode::Transcript {
Cow::Borrowed(output.trim_end_matches('\n'))
} else {
format_json_compact(output)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(output))
}
} else {
block.render()
};
if mode == McpToolCallRenderMode::Display {
for line in text.lines() {
preview.push_line(Line::from(line.dim()));
}
continue;
}
for segment in text.split('\n') {
let line = Line::from(segment.to_string().dim());
let wrapped = adaptive_wrap_line(
&line,
RtOptions::new(detail_wrap_width)
.initial_indent("".into())
.subsequent_indent(" ".into()),
);
detail_lines.extend(wrapped.iter().map(line_to_static));
}
}
}
}
Err(err) => {
let err_text = format!("Error: {err}");
if mode == McpToolCallRenderMode::Display {
for line in err_text.lines() {
preview.push_line(Line::from(line.dim()));
}
} else {
let err_line = Line::from(err_text.dim());
let wrapped = adaptive_wrap_line(
&err_line,
RtOptions::new(detail_wrap_width).subsequent_indent(" ".into()),
);
detail_lines.extend(wrapped.iter().map(line_to_static));
}
}
}
}
if mode == McpToolCallRenderMode::Display {
detail_lines = preview.finish();
}
let detail_lines = self.render_detail_lines(width, mode);
if !detail_lines.is_empty() {
let initial_prefix: Span<'static> = if inline_invocation {
let initial_prefix = if inline_invocation {
" └ ".dim()
} else {
" ".into()
};
lines.extend(prefix_lines(detail_lines, initial_prefix, " ".into()));
lines.extend(prefix_hyperlink_lines(
detail_lines,
initial_prefix,
" ".into(),
));
}
lines
}
/// Share one bounded preview across result blocks while retaining full transcript sources.
fn render_detail_lines(&self, width: u16, mode: McpToolCallRenderMode) -> Vec<HyperlinkLine> {
let detail_width = usize::from(width)
.saturating_sub(/*rhs*/ 4)
.max(/*other*/ 1);
let options = RtOptions::new(detail_width).subsequent_indent(" ".into());
let mut lines = Vec::new();
let mut preview = ToolOutputPreview::new(detail_width, /*omitted*/ 0);
match &self.result {
Some(Ok(result)) => {
for block in &result.content {
// Image blocks can carry text as well as their image indication.
if self.result_kind() == McpResultKind::NodeRepl
&& block.is_image
&& block.text().is_some()
{
let marker = HyperlinkLine::new("Returned image".dim().into());
if mode == McpToolCallRenderMode::Display {
preview.push_hyperlink_line(marker);
} else {
lines.extend(remap_source_wrapped_line(
&marker,
crate::wrapping::word_wrap_line_with_source(
&marker.line,
detail_width,
),
));
}
}
let Some(text) = self.result_block_text(block, mode) else {
continue;
};
for segment in text.lines() {
let line = Line::from(segment.to_owned().dim());
if mode == McpToolCallRenderMode::Display {
preview.push_line(line);
} else {
lines.extend(adaptive_wrap_hyperlink_lines(
&[HyperlinkLine::new(line)],
options.clone(),
));
}
}
}
}
Some(Err(error)) => {
let text = format!("Error: {error}");
for segment in text.lines() {
let line = Line::from(segment.to_owned().dim());
if mode == McpToolCallRenderMode::Display {
preview.push_line(line);
} else {
lines.extend(adaptive_wrap_hyperlink_lines(
&[HyperlinkLine::new(line)],
options.clone(),
));
}
}
}
None => {}
}
if mode == McpToolCallRenderMode::Display {
preview.finish_hyperlink_lines()
} else {
lines
}
}
/// Keep compact code-mode summaries while retaining complete transcript code and output.
fn result_block_text<'a>(
&self,
block: &'a McpContentBlock,
mode: McpToolCallRenderMode,
) -> Option<Cow<'a, str>> {
let node_repl = self.result_kind() == McpResultKind::NodeRepl;
if mode == McpToolCallRenderMode::Transcript {
return Some(Cow::Borrowed(block.render_full()));
}
if node_repl && self.success() == Some(true) {
let meaningful_output = block.text().and_then(|text| {
if text.starts_with("Script completed\n") {
return text
.split_once("\nOutput:\n")
.map(|(_, output)| Cow::Borrowed(output));
}
serde_json::from_str::<NodeReplExecOutput>(text)
.ok()
.filter(|output| output.exit_code == 0)
.map(|output| Cow::Owned(output.output))
});
match meaningful_output {
Some(output) if output.is_empty() => None,
Some(output) => Some(
format_json_compact(&output)
.map(Cow::Owned)
.unwrap_or(output),
),
None => Some(block.text().map_or_else(
|| block.render(),
|text| {
format_json_compact(text)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(text))
},
)),
}
} else if node_repl && let Some(output) = block.text() {
Some(
format_json_compact(output)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(output)),
)
} else {
Some(block.render())
}
}
}
impl HistoryCell for McpToolCallCell {
fn activity_ids(&self) -> Vec<String> {
vec![format!("mcp:{}", self.call_id)]
}
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.compact_mcp_lines(width)
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.render_lines(width, McpToolCallRenderMode::Display)
visible_lines(self.display_hyperlink_lines(width))
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.render_lines(width, McpToolCallRenderMode::Transcript)
visible_lines(self.transcript_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.render_hyperlink_lines(width, McpToolCallRenderMode::Display)
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.render_hyperlink_lines(width, McpToolCallRenderMode::Transcript)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
@@ -340,8 +395,8 @@ impl HistoryCell for McpToolCallCell {
match result {
Ok(McpToolResult { content, .. }) => {
for block in content {
let text = block.render();
lines.extend(raw_lines_from_source(&text));
let text = block.render_full();
lines.extend(raw_lines_from_source(text));
}
}
Err(err) => lines.push(Line::from(format!("Error: {err}"))),
@@ -359,6 +414,22 @@ impl HistoryCell for McpToolCallCell {
}
}
/// The first two spans are the status bullet and its space, not invocation source text.
fn mcp_header_line(line: Line<'static>) -> HyperlinkLine {
let mut source = LogicalLineSource::from_line(&Line::from(
line.spans.iter().skip(/*n*/ 2).cloned().collect::<Vec<_>>(),
));
source.prefix_bytes = line
.spans
.iter()
.take(/*n*/ 2)
.map(|span| span.content.len())
.sum();
let mut line = HyperlinkLine::new(line);
line.source = Some(source);
line
}
pub(crate) fn new_active_mcp_tool_call(
call_id: String,
invocation: McpInvocation,
@@ -774,9 +845,9 @@ fn format_mcp_invocation(invocation: &McpInvocation) -> Line<'_> {
.unwrap_or_default();
let invocation_spans = vec![
invocation.server.as_str().cyan(),
invocation.server.as_str().fg(accent_color()),
".".into(),
invocation.tool.as_str().cyan(),
invocation.tool.as_str().fg(accent_color()),
"(".into(),
args_str.dim(),
")".into(),
@@ -0,0 +1,132 @@
//! Compact MCP and code-mode activity previews, independent of retained detail rendering.
use super::McpToolCallCell;
use super::NodeReplExecOutput;
use super::result::McpResultKind;
use crate::history_cell::activity_preview::DETAIL_PREVIEW_LINES;
use crate::history_cell::activity_preview::clipped_line;
use crate::live_wrap::take_prefix_by_width;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::style::accent_color;
use crate::terminal_hyperlinks::HyperlinkLine;
use ratatui::style::Stylize;
use ratatui::text::Line;
use std::borrow::Cow;
use std::collections::VecDeque;
impl McpToolCallCell {
pub(super) fn compact_mcp_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let status = self.success();
let (marker, verb) = match status {
Some(true) => ("•".green().bold(), "Called"),
Some(false) => ("•".red().bold(), "Failed"),
None => (
activity_indicator(
Some(self.start_time),
MotionMode::from_animations_enabled(self.animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "•".dim()),
"Calling",
),
};
let title = self
.invocation
.arguments
.as_ref()
.filter(|_| self.result_kind() == McpResultKind::NodeRepl)
.and_then(|arguments| arguments.get("title"))
.and_then(serde_json::Value::as_str)
.map(|title| title.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|title| !title.is_empty())
.unwrap_or_else(|| format!("{}.{}", self.invocation.server, self.invocation.tool));
let mut lines = vec![clipped_line(
Line::from(vec![
marker,
" ".into(),
verb.bold(),
" ".into(),
title.fg(accent_color()),
]),
width,
)];
let mut details = VecDeque::with_capacity(DETAIL_PREVIEW_LINES);
let push_tail = |details: &mut VecDeque<String>, text: &str| {
for line in text
.lines()
.rev()
.take(DETAIL_PREVIEW_LINES - details.len())
{
// Bound selected rows before allocating; retained output may be megabytes.
let bounded = &line[..line.floor_char_boundary(16 * 1024)];
let (prefix, rest, _) = take_prefix_by_width(bounded, usize::from(width) + 1);
let mut detail = prefix.to_owned();
if !rest.is_empty() || bounded.len() < line.len() {
detail.push('…');
}
details.push_front(detail);
}
};
if let Some(result) = &self.result {
match result {
Ok(result) => {
for block in result.content.iter().rev() {
let output = if self.result_kind() == McpResultKind::NodeRepl
&& status == Some(true)
&& let Some(text) = block.text()
{
if text.starts_with("Script completed\n")
&& let Some((_, output)) = text.split_once("\nOutput:\n")
{
Cow::Borrowed(output)
} else if let Ok(output) =
serde_json::from_str::<NodeReplExecOutput>(text)
&& output.exit_code == 0
{
Cow::Owned(output.output)
} else {
Cow::Borrowed(block.render_full())
}
} else {
Cow::Borrowed(block.render_full())
};
push_tail(&mut details, &output);
// A code-mode image can also carry text; its marker precedes that output.
if block.is_image
&& block.text().is_some()
&& details.len() < DETAIL_PREVIEW_LINES
{
details.push_front("Returned image".to_owned());
}
if details.len() == DETAIL_PREVIEW_LINES {
break;
}
}
}
Err(error) => {
push_tail(&mut details, error);
if error.lines().take(DETAIL_PREVIEW_LINES + 1).count() <= DETAIL_PREVIEW_LINES
{
if let Some(first) = details.front_mut() {
first.insert_str(0, "Error: ");
} else {
details.push_front("Error: ".to_owned());
}
}
}
}
}
for (index, detail) in details.into_iter().enumerate() {
lines.push(clipped_line(
Line::from(vec![
if index == 0 { " └ " } else { " " }.dim(),
detail.dim(),
]),
width,
));
}
lines
}
}
@@ -111,6 +111,19 @@ impl McpToolResult {
}
impl McpContentBlock {
/// Returns all retained text, with honest markers for media and embedded resource bodies.
pub(super) fn render_full(&self) -> &str {
if let Some(text) = &self.original_text {
return text.trim_end_matches('\n');
}
match &self.display {
McpContentDisplay::Text(text) | McpContentDisplay::Json(text) => {
text.trim_end_matches('\n')
}
McpContentDisplay::Summary(summary) => summary,
}
}
/// Returns the untruncated top-level text used by node_repl and cua_repl's compact and
/// transcript views.
///
+137 -9
View File
@@ -4,6 +4,7 @@ use codex_protocol::mcp::CallToolResult;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::sync::Arc;
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
@@ -140,7 +141,7 @@ fn mcp_preview_shares_one_limit_across_blocks_and_preserves_transcript() {
McpInvocation {
server: "search".into(),
tool: "lookup".into(),
arguments: None,
arguments: Some(json!({"title": "Search query"})),
},
/*animations_enabled*/ false,
);
@@ -154,7 +155,12 @@ fn mcp_preview_shares_one_limit_across_blocks_and_preserves_transcript() {
let display = cell.display_lines(/*width*/ 80);
let transcript = cell.transcript_lines(/*width*/ 80);
insta::assert_snapshot!(format!(
"history:\n{}\n\ntranscript:\n{}",
"compact:\n{}\n\nhistory:\n{}\n\ntranscript:\n{}",
visible_lines(cell.compact_hyperlink_lines(/*width*/ 80))
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
display
.iter()
.map(ToString::to_string)
@@ -172,7 +178,7 @@ fn mcp_preview_shares_one_limit_across_blocks_and_preserves_transcript() {
.map(ToString::to_string)
.collect::<Vec<_>>(),
[
"Called search.lookup()",
"Called search.lookup({\"title\":\"Search query\"})",
"first",
"second",
"third",
@@ -213,7 +219,7 @@ fn code_mode_output_shares_a_row_budget_across_blocks() {
└ Page title
Navigation
Main content
+3 lines (ctrl + t to view transcri…
+3 lines (ctrl+t to view transcript)
");
let transcript = cell
.transcript_lines(/*width*/ 100)
@@ -258,7 +264,7 @@ fn code_mode_output_preserves_trailing_failure_diagnostics_in_transcript() {
└ Script failed
Page title
Navigation
+6 lines (ctrl + t to view transcri…
+6 lines (ctrl+t to view transcript)
");
let transcript = cell
.transcript_lines(/*width*/ 80)
@@ -296,6 +302,16 @@ fn code_mode_output_row_budget_applies_after_wrapping_and_to_errors() {
);
cell.complete(Duration::ZERO, completion);
for width in [20, 40, 80] {
let compact = cell.compact_hyperlink_lines(width);
assert!(compact.len() <= 4);
assert!(
compact
.last()
.unwrap()
.line
.to_string()
.ends_with("transcript tail")
);
let display = cell.display_lines(width);
assert_eq!(display.len(), 5); // Header, three output rows, and omission hint.
assert!(
@@ -397,7 +413,7 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
json!({"type": "future_block", "text": "Script completed\nOutput:\nunknown-side output"});
let tool_result = result(vec![
json!({"type": "image", "mimeType": "image/png", "data": PNG, "text": "Script completed\nOutput:\nimage-side output"}),
unknown.clone(),
unknown,
]);
cell.complete(Duration::ZERO, Ok(tool_result.clone()));
@@ -413,6 +429,15 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
vec![" └ Returned", " image"],
);
let compact = cell.compact_hyperlink_lines(/*width*/ 80);
let compact = visible_lines(compact)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let narrow_compact = cell.compact_hyperlink_lines(/*width*/ 16);
assert!(narrow_compact.iter().all(|line| line.width() <= 16));
let display = cell
.display_lines(/*width*/ 200)
.iter()
@@ -425,7 +450,13 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(format!("history:\n{display}\n\ntranscript:\n{transcript}"), @r#"
insta::assert_snapshot!(format!("compact:\n{compact}\n\nhistory:\n{display}\n\ntranscript:\n{transcript}"), @r#"
compact:
• Called Inspect results
└ Returned image
image-side output
unknown-side output
history:
• Inspect results
└ Returned image
@@ -446,8 +477,12 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
cell.raw_lines(),
vec![
Line::from("Called node_repl.js({\"title\":\"Inspect results\"})"),
Line::from("Returned image"),
Line::from(format_json_compact(&unknown.to_string()).unwrap()),
Line::from("Script completed"),
Line::from("Output:"),
Line::from("image-side output"),
Line::from("Script completed"),
Line::from("Output:"),
Line::from("unknown-side output"),
],
);
@@ -535,3 +570,96 @@ fn titled_image_call_keeps_error_and_full_title_when_narrow() {
.contains(title)
);
}
/// Reconstruct only from explicit provenance, checking every displayed source slice on the way.
fn source_lines(lines: &[HyperlinkLine]) -> Vec<String> {
let mut sources: Vec<Arc<str>> = Vec::new();
for line in lines {
let source = line.source.as_ref().expect("MCP row source");
let visible = line.line.to_string();
assert_eq!(
&visible[source.prefix_bytes..source.prefix_bytes + source.range.len()],
&source.text[source.range.clone()],
);
if !sources
.last()
.is_some_and(|previous| Arc::ptr_eq(previous, &source.text))
{
sources.push(Arc::clone(&source.text));
}
}
sources
.into_iter()
.map(|source| source.to_string())
.collect()
}
#[test]
fn code_mode_transcript_preserves_long_code_and_indentation_across_wrapping() {
let code = "const values = await tools.fetch_values({ project: 'selected-project', include_details: true });\n const total = values.reduce((sum, item) => sum + item.value, 0);\n\ntext({ total, label: 'exact code output' });";
let mut cell = new_active_mcp_tool_call(
"call-code".to_owned(),
McpInvocation {
server: "node_repl".to_owned(),
tool: "js".to_owned(),
arguments: None,
},
/*animations_enabled*/ false,
);
cell.complete(
Duration::ZERO,
Ok(result(vec![json!({"type": "text", "text": code})])),
);
let expected = code.split('\n').map(str::to_owned).collect::<Vec<_>>();
for width in [16, 24, 40, 80] {
let lines = cell.transcript_hyperlink_lines(width);
let source = source_lines(&lines);
assert!(source.ends_with(&expected), "{source:?}");
assert!(
lines.len() > expected.len(),
"long code should wrap at width {width}"
);
}
}
#[test]
fn mcp_result_preview_preserves_source_text_and_excludes_tree_gutters() {
let text = "one long result line with repeated spaces and enough content to wrap across several narrow rows";
let mut cell = new_active_mcp_tool_call(
"call-mcp".to_owned(),
McpInvocation {
server: "docs".to_owned(),
tool: "read".to_owned(),
arguments: None,
},
/*animations_enabled*/ false,
);
cell.complete(
Duration::ZERO,
Ok(result(vec![json!({"type": "text", "text": text})])),
);
for width in [16, 24, 40, 80] {
let lines = cell.display_hyperlink_lines(width);
let backed = lines
.into_iter()
.filter(|line| line.source.is_some())
.collect::<Vec<_>>();
let source = source_lines(&backed);
let output = source
.iter()
.find(|line| line.starts_with("one long result"))
.expect("result source");
assert!(
text.starts_with(output),
"preview must retain the original whitespace"
);
if width >= 40 {
assert_eq!(output, text);
}
assert!(
source
.iter()
.all(|line| !line.starts_with('•') && !line.starts_with(" └ "))
);
}
}
+12 -16
View File
@@ -16,7 +16,6 @@ use std::borrow::Cow;
pub(crate) struct UserHistoryCell {
pub message: String,
pub text_elements: Vec<TextElement>,
#[allow(dead_code)]
pub local_image_paths: Vec<PathBuf>,
pub remote_image_urls: Vec<String>,
pub(crate) spoken: bool,
@@ -154,7 +153,9 @@ fn build_user_message_lines_with_elements(
impl UserHistoryCell {
fn image_labels_not_in_message(&self) -> impl Iterator<Item = String> + '_ {
(1..=self.remote_image_urls.len())
// Composer images already have placeholders; command-line images may not.
// Both kinds must keep an image-only user turn visible after submission.
(1..=self.remote_image_urls.len() + self.local_image_paths.len())
.map(local_image_label_text)
.filter(|label| {
!self
@@ -192,29 +193,24 @@ impl HistoryCell for UserHistoryCell {
let style = history_prompt_style();
let element_style = style.fg(accent_color_on(style.bg));
let wrapped_remote_images = if self.remote_image_urls.is_empty() {
None
} else {
Some(plain_hyperlink_lines(adaptive_wrap_lines(
self.image_labels_not_in_message()
.map(|label| Line::from(label).style(element_style)),
RtOptions::new(usize::from(wrap_width))
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
)))
}
.filter(|lines| !lines.is_empty());
let wrapped_images = plain_hyperlink_lines(adaptive_wrap_lines(
self.image_labels_not_in_message()
.map(|label| Line::from(label).style(element_style)),
RtOptions::new(usize::from(wrap_width))
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
));
let wrapped_message = wrap_user_message(message, text_elements, style, wrap_width);
if wrapped_remote_images.is_none() && wrapped_message.is_none() {
if wrapped_images.is_empty() && wrapped_message.is_none() {
return Vec::new();
}
let mut lines = vec![HyperlinkLine::new(Line::from("").style(style))];
if let Some(wrapped_remote_images) = wrapped_remote_images {
if !wrapped_images.is_empty() {
lines.extend(prefix_hyperlink_lines(
wrapped_remote_images,
wrapped_images,
" ".into(),
" ".into(),
));
@@ -3,6 +3,36 @@ use crate::history_cell::markdown_render_cache::MarkdownRenderCacheKey;
use assert_matches::assert_matches;
use pretty_assertions::assert_eq;
#[test]
fn local_image_only_user_message_remains_visible() {
let cell = new_user_prompt(
String::new(),
Vec::new(),
vec![PathBuf::from("fixture.png")],
Vec::new(),
);
let display = cell.display_lines(/*width*/ 40);
assert_eq!(cell.transcript_lines(/*width*/ 40), display);
insta::assert_snapshot!(ratatui::text::Text::from(display));
}
#[test]
fn mixed_image_labels_preserve_existing_placeholders_without_duplicates() {
let placeholder = "[Image #2]";
let cell = new_user_prompt(
format!("{placeholder} compare these"),
vec![TextElement::new(
(0..placeholder.len()).into(),
Some(placeholder.to_string()),
)],
vec![PathBuf::from("one.png"), PathBuf::from("two.png")],
vec!["https://example.test/remote.png".to_string()],
);
insta::assert_snapshot!(ratatui::text::Text::from(cell.display_lines(/*width*/ 40)));
}
#[test]
fn sanitizer_borrows_clean_text_and_removes_control_sequences() {
for (text, expected) in [
@@ -124,11 +154,14 @@ fn replace_cached_lines(
}
#[test]
fn finalized_markdown_reuses_lines_primed_by_transcript_height() {
fn finalized_markdown_reuses_rendered_transcript_lines() {
let cell = AgentMarkdownCell::new("finalized **markdown**".to_string(), Path::new("/tmp"));
let width = 48;
assert_eq!(cell.desired_transcript_height(width), 1);
assert_eq!(
visible_lines(cell.transcript_hyperlink_lines(width)),
cell.display_lines(width)
);
replace_cached_lines(&cell, |_| {});
assert_eq!(
+69 -8
View File
@@ -82,6 +82,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
#[cfg(test)]
use codex_utils_cli::format_env_display;
use ratatui::prelude::*;
#[cfg(test)]
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled;
@@ -100,6 +102,8 @@ use url::Url;
const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000;
mod activity_details;
pub(crate) mod activity_preview;
mod approvals;
mod base;
mod exec;
@@ -116,7 +120,9 @@ mod separators;
mod session;
mod spoken_artifacts;
mod startup_warnings;
mod warnings;
pub(crate) use activity_details::ActivityDetails;
pub(crate) use approvals::*;
pub(crate) use base::*;
pub(crate) use exec::*;
@@ -133,6 +139,20 @@ pub(crate) use search::*;
pub(crate) use separators::*;
pub(crate) use session::*;
pub(crate) use startup_warnings::StartupWarningsCell;
pub(crate) use warnings::WarningEntry;
pub(crate) use warnings::WarningHistoryCell;
pub(crate) use warnings::WarningId;
pub(crate) use warnings::WarningKey;
#[allow(
unused_imports,
reason = "Used by later layers of the TUI refresh stack."
)]
pub(crate) use warnings::warning_count;
#[allow(
unused_imports,
reason = "Used by later layers of the TUI refresh stack."
)]
pub(crate) use warnings::warning_entries;
#[cfg(test)]
mod tests;
@@ -189,21 +209,65 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
Err(cell)
}
/// Diagnostics exposed by the warnings picker, retaining their source and full text.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn warning_entries(&self) -> Vec<WarningEntry> {
Vec::new()
}
/// Stable diagnostic identities, independent of rendered wrapping and duplicate delivery.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn warning_keys(&self) -> Vec<WarningKey<'_>> {
Vec::new()
}
/// Returns the logical lines for the main chat viewport.
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
/// Returns copy-friendly plain logical lines for raw scrollback mode.
fn raw_lines(&self) -> Vec<Line<'static>>;
/// Raw live presentation may omit transcript-only diagnostics without losing their source.
fn live_raw_lines(&self) -> Vec<Line<'static>> {
self.raw_lines()
}
/// Returns rich visible lines plus terminal hyperlink metadata.
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
plain_hyperlink_lines(self.display_lines(width))
}
/// Compact presentation for the owned transcript, which can reveal details in place.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
/// Stable, namespaced member identities used to retain disclosure across grouping and replay.
/// Empty identities indicate ordinary content without a local disclosure control.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn activity_ids(&self) -> Vec<String> {
Vec::new()
}
/// Available activity details, preserving source order and any upstream truncation notices.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn expanded_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.transcript_hyperlink_lines(width)
}
/// Whether differing activity presentations can contain genuinely hidden details.
/// The caller also compares compact and expanded content; overrides suppress differences
/// that only change headings, status decoration, or ordering of already-visible information.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn has_hidden_activity_details(&self, _width: u16) -> bool {
true
}
fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
match mode {
HistoryRenderMode::Rich => visible_lines(self.display_hyperlink_lines(width)),
HistoryRenderMode::Raw => self.raw_lines(),
HistoryRenderMode::Raw => self.live_raw_lines(),
}
}
@@ -214,7 +278,7 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
) -> Vec<HyperlinkLine> {
match mode {
HistoryRenderMode::Rich => self.display_hyperlink_lines(width),
HistoryRenderMode::Raw => plain_hyperlink_lines(self.raw_lines()),
HistoryRenderMode::Raw => plain_hyperlink_lines(self.live_raw_lines()),
}
}
@@ -255,9 +319,6 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
plain_hyperlink_lines(self.transcript_lines(width))
}
/// Returns the number of viewport rows for the transcript overlay.
///
/// Uses the same `Paragraph::line_count` measurement as `desired_height`.
fn desired_transcript_height(&self, width: u16) -> u16 {
let lines = visible_lines(self.transcript_hyperlink_lines(width));
Paragraph::new(Text::from(lines))
@@ -267,10 +328,10 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
.unwrap_or(0)
}
/// Whether the transcript height remains valid across later overlay renders.
/// Whether the cached transcript layout remains valid across later frames.
///
/// Cells backed by external state should return `false` so the pager remeasures them before
/// rendering instead of reusing a height that may now clip their content.
/// Cells backed by external state should return `false` so the shared viewport refreshes
/// their rendered text and source mapping each frame instead of reusing stale content.
fn has_stable_transcript_height(&self) -> bool {
true
}
+96 -31
View File
@@ -1,9 +1,10 @@
//! Informational, warning, update, and policy notice history cells.
use super::*;
use crate::line_truncation::line_width;
use crate::wrapping::url_preserving_wrap_options;
use crate::wrapping::word_wrap_line;
use crate::style::accent_color;
use crate::terminal_hyperlinks::LineWrapPolicy;
use crate::terminal_hyperlinks::remap_source_wrapped_line;
use crate::wrapping::adaptive_wrap_line_to_width;
#[cfg_attr(not(test), allow(dead_code))]
const RECAP_HEADING: &str = "Conversation recap";
@@ -30,19 +31,25 @@ impl HistoryCell for UpdateAvailableHistoryCell {
use ratatui_macros::line;
use ratatui_macros::text;
let update_instruction = if let Some(update_action) = self.update_action {
line!["Run ", update_action.command_str().cyan(), " to update."]
line![
"Run ",
update_action.command_str().fg(accent_color()),
" to update."
]
} else {
line![
"See ",
"https://github.com/openai/codex".cyan().underlined(),
"https://github.com/openai/codex"
.fg(accent_color())
.underlined(),
" for installation options."
]
};
let content = text![
line![
"✨\u{200A}".bold().cyan(),
"Update available!".bold().cyan(),
"✨\u{200A}".bold().fg(accent_color()),
"Update available!".bold().fg(accent_color()),
" ",
format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(),
],
@@ -50,7 +57,7 @@ impl HistoryCell for UpdateAvailableHistoryCell {
"",
"See full release notes:",
"https://github.com/openai/codex/releases/latest"
.cyan()
.fg(accent_color())
.underlined(),
];
@@ -86,25 +93,42 @@ impl HistoryCell for UpdateAvailableHistoryCell {
self.display_hyperlink_lines(width)
}
}
#[allow(clippy::disallowed_methods)]
pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ")
pub(crate) fn new_warning_event(message: String) -> WarningHistoryCell {
let style = crate::style::status_style(crate::style::StatusTone::Attention);
WarningHistoryCell {
key: message.clone(),
diagnostic: message.clone(),
details: PrefixedWrappedHistoryCell::new(
message.set_style(style),
"⚠ ".set_style(style),
" ",
),
}
}
#[allow(clippy::disallowed_methods)]
pub(crate) fn new_server_version_warning(
notice: crate::status::remote_connection::ServerVersionNotice,
) -> PrefixedWrappedHistoryCell {
let mut lines = vec![Line::from(notice.message.yellow())];
) -> WarningHistoryCell {
let key = notice.message.clone();
let style = crate::style::status_style(crate::style::StatusTone::Attention);
let mut lines = vec![Line::from(notice.message.set_style(style))];
if notice.offer_update {
lines.push(Line::from(
"Use /daemon to manage the local background server.".cyan(),
"Use /daemon to manage the local background server.".fg(accent_color()),
));
lines.push(Line::from(
"Updating may interrupt active or queued work.".yellow(),
"Updating may interrupt active or queued work.".set_style(style),
));
}
PrefixedWrappedHistoryCell::new(Text::from(lines), "⚠ ".yellow(), " ")
WarningHistoryCell {
key,
diagnostic: lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
details: PrefixedWrappedHistoryCell::new(Text::from(lines), "⚠ ".set_style(style), " "),
}
}
#[derive(Debug)]
@@ -168,7 +192,7 @@ impl HistoryCell for SafetyAccessBlockCell {
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let mut lines = vec![HyperlinkLine::new(
vec!["ⓘ ".cyan(), self.title.bold()].into(),
vec!["ⓘ ".fg(accent_color()), self.title.bold()].into(),
)];
let body = Line::from(vec![" ".into(), self.body.dim()]);
let wrap_width = width.saturating_sub(2).max(1) as usize;
@@ -182,7 +206,11 @@ impl HistoryCell for SafetyAccessBlockCell {
for &(label, url) in self.actions {
let source = crate::terminal_hyperlinks::annotate_web_urls_in_line(
vec![format!(" {label}: ").dim(), url.cyan().underlined()].into(),
vec![
format!(" {label}: ").dim(),
url.fg(accent_color()).underlined(),
]
.into(),
);
let wrapped = crate::wrapping::word_wrap_line(
&source.line,
@@ -227,7 +255,32 @@ pub(crate) fn new_deprecation_notice(
}
impl HistoryCell for DeprecationNoticeCell {
fn warning_entries(&self) -> Vec<WarningEntry> {
vec![WarningEntry {
id: WarningId::Message(self.summary.clone()),
source: "Deprecation".into(),
details: self
.raw_lines()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
}]
}
fn live_raw_lines(&self) -> Vec<Line<'static>> {
self.raw_lines()
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.transcript_lines(width)
}
fn warning_keys(&self) -> Vec<WarningKey<'_>> {
vec![WarningKey::Message(&self.summary)]
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(vec!["⚠ ".red().bold(), self.summary.clone().red()].into());
@@ -338,6 +391,10 @@ impl ThreadRecapHistoryCell {
impl HistoryCell for ThreadRecapHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.display_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
if width == 0 {
return Vec::new();
}
@@ -347,7 +404,7 @@ impl HistoryCell for ThreadRecapHistoryCell {
if let Some(action) = &self.next_action {
body.extend(prefix_lines(
raw_lines_from_source(action),
"Next: ".bold(),
"Next: ".bold().fg(accent_color()),
"".into(),
));
}
@@ -367,20 +424,28 @@ impl HistoryCell for ThreadRecapHistoryCell {
};
let mut lines = Vec::new();
for line in body {
let mut wrapped = adaptive_wrap_line(&line, options.clone());
if wrapped.iter().any(|line| line_width(line) > wrap_width) {
// Terminal autowrap loses the hanging indent. Rewrap the source line so only
// oversized tokens split, without applying the recap prefix a second time.
wrapped = word_wrap_line(
&line,
url_preserving_wrap_options(options.clone())
.break_words(/*break_words*/ true),
);
let line = HyperlinkLine::new(line);
let mut wrapped = remap_source_wrapped_line(
&line,
adaptive_wrap_line_to_width(&line.line, options.clone()),
);
for source in wrapped.iter_mut().filter_map(|line| line.source.as_mut()) {
source.wrap_policy = LineWrapPolicy::UrlAware;
source.continuation_indent = options.subsequent_indent.clone();
source.right_reserve = 2;
}
push_owned_lines(&wrapped, &mut lines);
lines.extend(wrapped);
options.initial_indent = options.subsequent_indent.clone();
}
lines.into_iter().map(Line::dim).collect()
for line in &mut lines {
let style = line.line.style.dim();
*line = std::mem::take(line).style(style);
}
lines
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
+107 -17
View File
@@ -2,6 +2,7 @@
use super::*;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use codex_ansi_escape::ansi_escape;
use codex_utils_path_uri::LegacyAppPathString;
#[cfg(test)]
@@ -10,13 +11,40 @@ mod tests;
#[derive(Debug)]
pub(crate) struct PatchHistoryCell {
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
activity_id: String,
changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf,
}
impl HistoryCell for PatchHistoryCell {
fn activity_ids(&self) -> Vec<String> {
vec![self.activity_id.clone()]
}
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
crate::diff_render::create_diff_preview_with_links(
&self.changes,
&self.cwd,
usize::from(width),
super::activity_preview::DETAIL_PREVIEW_LINES,
)
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
create_diff_summary(&self.changes, &self.cwd, width as usize)
visible_lines(self.display_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
crate::diff_render::create_diff_summary_with_links(
&self.changes,
&self.cwd,
usize::from(width),
)
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
@@ -35,31 +63,93 @@ pub(crate) fn new_patch_event(
cwd: &Path,
) -> PatchHistoryCell {
PatchHistoryCell {
activity_id: format!("patch:{}", uuid::Uuid::new_v4()),
changes,
cwd: cwd.to_path_buf(),
}
}
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
pub(crate) fn new_patch_apply_failure(stderr: String) -> PatchFailureCell {
PatchFailureCell {
activity_id: format!("patch-failure:{}", uuid::Uuid::new_v4()),
stderr,
}
}
// Failure title
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
/// Failed patch attempts retain available diagnostics for local disclosure and full transcript.
#[derive(Debug)]
pub(crate) struct PatchFailureCell {
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
activity_id: String,
stderr: String,
}
if !stderr.trim().is_empty() {
let output = output_lines(
Some(&CommandOutput::new(/*exit_code*/ 1, stderr)),
OutputLinesParams {
line_limit: TOOL_CALL_MAX_LINES,
only_err: true,
include_angle_pipe: true,
include_prefix: true,
},
);
lines.extend(output.lines);
impl HistoryCell for PatchFailureCell {
fn activity_ids(&self) -> Vec<String> {
vec![self.activity_id.clone()]
}
PlainHistoryCell { lines }
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let mut lines = self.transcript_hyperlink_lines(width);
lines.truncate(1 + super::activity_preview::DETAIL_PREVIEW_LINES);
lines
.into_iter()
.map(|line| super::activity_preview::clipped_line(line.line, width))
.collect()
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let mut lines = vec![Line::from("✘ Failed to apply patch".magenta().bold()).into()];
let error = if self.stderr.trim().is_empty() {
"(error details unavailable)"
} else {
&self.stderr
};
let mut diagnostics = ansi_escape(&error.replace('\t', " ")).lines;
for line in &mut diagnostics {
for span in &mut line.spans {
span.style = span.style.add_modifier(Modifier::DIM);
}
}
lines.extend(crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(
&plain_hyperlink_lines(diagnostics),
RtOptions::new(usize::from(width).max(/*other*/ 1))
.initial_indent(" └ ".dim().into())
.subsequent_indent(" ".into()),
));
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut lines = vec![Line::from("Failed to apply patch")];
lines.extend(plain_lines(ansi_escape(&self.stderr).lines));
lines
}
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
// Failure title
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
if !self.stderr.trim().is_empty() {
let output = output_lines(
Some(&CommandOutput::new(
/*exit_code*/ 1,
self.stderr.clone(),
)),
OutputLinesParams {
line_limit: TOOL_CALL_MAX_LINES,
only_err: true,
include_angle_pipe: true,
include_prefix: true,
},
);
lines.extend(output.lines);
}
lines
}
}
#[derive(Debug)]
@@ -1,6 +1,45 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn compact_patches_retain_full_changes_and_failure_details() {
let patch = new_patch_event(
HashMap::from([
(
PathBuf::from("new.txt"),
FileChange::Add {
content: "one\ntwo\nthree\nfour\nfive\n".into(),
},
),
(
PathBuf::from("existing.txt"),
FileChange::Update {
unified_diff: "@@ -1,3 +1,3 @@\n context\n-before\n+after\n tail\n".into(),
move_path: None,
},
),
]),
Path::new("."),
);
let failure = new_patch_apply_failure("first\nsecond\nthird\nfourth diagnostic".into());
let missing = new_patch_apply_failure(String::new());
let mut snapshots = Vec::new();
for (label, cell) in [
("patch", &patch as &dyn HistoryCell),
("failure", &failure),
("missing diagnostics", &missing),
] {
let compact = visible_lines(cell.compact_hyperlink_lines(/*width*/ 40));
let full = visible_lines(cell.transcript_hyperlink_lines(/*width*/ 40));
snapshots.push(format!(
"{label}\nCompact\n{}\nFull\n{}",
Text::from(compact),
Text::from(full)
));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn viewed_image_retains_original_path_in_details() {
for path in [
+105 -18
View File
@@ -2,6 +2,7 @@
use super::markdown_render_cache::MarkdownRenderCache;
use super::*;
use crate::style::accent_color;
use crate::terminal_hyperlinks::lines_with_sources_eq;
/// Transient active-cell representation of the mutable tail of a proposed-plan stream.
@@ -55,7 +56,11 @@ impl HistoryCell for StreamingPlanTailCell {
/// Render a user‑friendly plan update styled like a checkbox todo list.
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
let UpdatePlanArgs { explanation, plan } = update;
PlanUpdateCell { explanation, plan }
PlanUpdateCell {
activity_id: format!("plan:{}", uuid::Uuid::new_v4()),
explanation,
plan,
}
}
/// Create a proposed-plan cell that snapshots the session cwd for later markdown rendering.
@@ -176,40 +181,112 @@ impl HistoryCell for ProposedPlanStreamCell {
#[derive(Debug)]
pub(crate) struct PlanUpdateCell {
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
activity_id: String,
explanation: Option<String>,
plan: Vec<PlanItemArg>,
}
impl HistoryCell for PlanUpdateCell {
fn activity_ids(&self) -> Vec<String> {
vec![self.activity_id.clone()]
}
fn has_hidden_activity_details(&self, width: u16) -> bool {
self.explanation
.as_ref()
.is_some_and(|explanation| !explanation.trim().is_empty())
|| self.plan.len() > super::activity_preview::DETAIL_PREVIEW_LINES
|| self.plan.iter().any(|item| {
let step = item.step.split_whitespace().collect::<Vec<_>>().join(" ");
// The compact row reserves four columns for indentation and two for its marker.
Line::from(step).width() + 6 > usize::from(width)
})
}
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
use super::activity_preview::DETAIL_PREVIEW_LINES;
use super::activity_preview::clipped_line;
let completed = self
.plan
.iter()
.filter(|item| matches!(item.status, StepStatus::Completed))
.count();
let total = self.plan.len();
let mut lines = vec![clipped_line(
Line::from(vec![
"• ".dim(),
"Updated Plan".bold(),
format!(" · {completed}/{total} complete").dim(),
]),
width,
)];
let mut steps: Vec<_> = self.plan.iter().collect();
steps.sort_by_key(|item| match item.status {
StepStatus::InProgress => 0,
StepStatus::Pending => 1,
StepStatus::Completed => 2,
});
for (index, item) in steps.into_iter().take(DETAIL_PREVIEW_LINES).enumerate() {
let (marker, style) = match item.status {
StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
StepStatus::InProgress => ("□ ", Style::default().fg(accent_color()).bold()),
StepStatus::Pending => ("□ ", Style::default().dim()),
};
let step = item.step.split_whitespace().collect::<Vec<_>>().join(" ");
lines.push(clipped_line(
Line::from(vec![
if index == 0 { " └ " } else { " " }.dim(),
marker.set_style(style),
step.set_style(style),
]),
width,
));
}
if self.plan.is_empty() {
lines.push(clipped_line(
Line::from(" └ (no steps provided)".dim()),
width,
));
}
lines
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let render_note = |text: &str| -> Vec<Line<'static>> {
visible_lines(self.display_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let render_note = |text: &str| -> Vec<HyperlinkLine> {
let wrap_width = width.saturating_sub(4).max(1) as usize;
let note = Line::from(text.to_string().dim().italic());
let wrapped = adaptive_wrap_line(&note, RtOptions::new(wrap_width));
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
let note = HyperlinkLine::new(Line::from(text.to_string().dim().italic()));
crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(
&[note],
RtOptions::new(wrap_width),
)
};
let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
let render_step = |status: &StepStatus, text: &str| -> Vec<HyperlinkLine> {
let (box_str, step_style) = match status {
StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
StepStatus::InProgress => ("□ ", Style::default().cyan().bold()),
StepStatus::InProgress => ("□ ", Style::default().fg(accent_color()).bold()),
StepStatus::Pending => ("□ ", Style::default().dim()),
};
let opts = RtOptions::new(width.saturating_sub(4).max(1) as usize)
.initial_indent(box_str.into())
.subsequent_indent(" ".into());
let step = Line::from(text.to_string().set_style(step_style));
let wrapped = adaptive_wrap_line(&step, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
let step = HyperlinkLine::new(Line::from(text.to_string().set_style(step_style)));
let mut wrapped =
crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(&[step], opts);
crate::terminal_hyperlinks::retain_initial_prefix(&mut wrapped, box_str);
wrapped
};
let mut lines: Vec<Line<'static>> = vec![];
lines.push(vec!["• ".dim(), "Updated Plan".bold()].into());
let mut lines = vec![HyperlinkLine::new(
vec!["• ".dim(), "Updated Plan".bold()].into(),
)];
let mut indented_lines = vec![];
let note = self
@@ -222,17 +299,27 @@ impl HistoryCell for PlanUpdateCell {
};
if self.plan.is_empty() {
indented_lines.push(Line::from("(no steps provided)".dim().italic()));
indented_lines.push(HyperlinkLine::new(Line::from(
"(no steps provided)".dim().italic(),
)));
} else {
for PlanItemArg { step, status } in self.plan.iter() {
indented_lines.extend(render_step(status, step));
}
}
lines.extend(prefix_lines(indented_lines, " └ ".dim(), " ".into()));
lines.extend(prefix_hyperlink_lines(
indented_lines,
" └ ".dim(),
" ".into(),
));
lines
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut lines = vec![Line::from("Updated Plan")];
if let Some(explanation) = self
+5 -2
View File
@@ -2,11 +2,14 @@ use super::*;
use pretty_assertions::assert_eq;
#[test]
fn finalized_plan_reuses_lines_primed_by_transcript_height() {
fn finalized_plan_reuses_rendered_transcript_lines() {
let cell = new_proposed_plan("1. Inspect **markdown**".to_string(), Path::new("/tmp"));
let width = 48;
assert_eq!(cell.desired_transcript_height(width), 5);
assert_eq!(
visible_lines(cell.transcript_hyperlink_lines(width)),
cell.display_lines(width)
);
cell.rendered_lines
.cached
.lock()
@@ -1,6 +1,7 @@
//! Completed request-user-input transcript rendering.
use super::*;
use crate::style::accent_color;
/// Renders a completed (or interrupted) request_user_input exchange in history.
#[derive(Debug)]
@@ -12,6 +13,10 @@ pub(crate) struct RequestUserInputResultCell {
impl HistoryCell for RequestUserInputResultCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.display_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let width = width.max(1) as usize;
let total = self.questions.len();
let answered = self
@@ -28,10 +33,10 @@ impl HistoryCell for RequestUserInputResultCell {
let mut header = vec!["•".dim(), " ".into(), "Questions".bold()];
header.push(format!(" {answered}/{total} answered").dim());
if self.interrupted {
header.push(" (interrupted)".cyan());
header.push(" (interrupted)".fg(accent_color()));
}
let mut lines: Vec<Line<'static>> = vec![header.into()];
let mut lines = vec![HyperlinkLine::new(header.into())];
for question in &self.questions {
let answer = self.answers.get(&question.id);
@@ -47,7 +52,24 @@ impl HistoryCell for RequestUserInputResultCell {
Style::default(),
);
if answer_missing && let Some(last) = question_lines.last_mut() {
last.spans.push(" (unanswered)".dim());
let suffix = " (unanswered)";
last.line.spans.push(suffix.dim());
if let Some(source) = &mut last.source {
// The suffix is deliberately appended after wrapping, matching the existing UI.
let mut logical = source.styled_range(0..source.range.end);
logical.spans.push(suffix.dim());
let logical =
crate::terminal_hyperlinks::LogicalLineSource::from_line(&logical);
source.range.end = logical.text.len();
for line in &mut question_lines {
if let Some(source) = &mut line.source {
source.text = std::sync::Arc::clone(&logical.text);
source.styles = std::sync::Arc::clone(&logical.styles);
source.line_style = logical.line_style;
source.span_style = Style::default();
}
}
}
}
lines.extend(question_lines);
@@ -60,7 +82,7 @@ impl HistoryCell for RequestUserInputResultCell {
width,
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
Style::default().fg(accent_color()),
));
continue;
}
@@ -73,7 +95,7 @@ impl HistoryCell for RequestUserInputResultCell {
width,
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
Style::default().fg(accent_color()),
));
}
if let Some(note) = note {
@@ -81,13 +103,13 @@ impl HistoryCell for RequestUserInputResultCell {
(
" note: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
Style::default().fg(accent_color()),
)
} else {
(
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
Style::default().fg(accent_color()),
)
};
lines.extend(wrap_with_prefix(&note, width, label, continuation, style));
@@ -99,15 +121,19 @@ impl HistoryCell for RequestUserInputResultCell {
lines.extend(wrap_with_prefix(
&summary,
width,
" ↳ ".cyan().dim(),
" ↳ ".fg(accent_color()),
" ".dim(),
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
Style::default().fg(accent_color()),
));
}
lines
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let total = self.questions.len();
let answered = self
@@ -151,22 +177,22 @@ impl HistoryCell for RequestUserInputResultCell {
}
}
/// Wrap a plain string with textwrap and prefix each line, while applying a style to the content.
/// Retain the first-line label as text, keeping continuation alignment display-only.
fn wrap_with_prefix(
text: &str,
width: usize,
initial_prefix: Span<'static>,
subsequent_prefix: Span<'static>,
style: Style,
) -> Vec<Line<'static>> {
let line = Line::from(vec![Span::from(text.to_string()).set_style(style)]);
) -> Vec<HyperlinkLine> {
let label = initial_prefix.content.trim_start_matches(' ').to_owned();
let line = HyperlinkLine::new(Line::from(Span::from(text.to_string()).set_style(style)));
let opts = RtOptions::new(width.max(1))
.initial_indent(Line::from(vec![initial_prefix]))
.subsequent_indent(Line::from(vec![subsequent_prefix]));
let wrapped = adaptive_wrap_line(&line, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
let mut wrapped = crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(&[line], opts);
crate::terminal_hyperlinks::retain_initial_prefix(&mut wrapped, &label);
wrapped
}
/// Split a request_user_input answer into option labels and an optional freeform note.
@@ -185,3 +211,7 @@ fn split_request_user_input_answer(
}
(options, note)
}
#[cfg(test)]
#[path = "request_user_input_tests.rs"]
mod tests;
@@ -0,0 +1,58 @@
//! Result rendering preserves answer styles and wrapped unanswered questions.
use super::*;
use codex_app_server_protocol::ToolRequestUserInputOption;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
#[test]
fn completed_and_interrupted_results() {
let question = ToolRequestUserInputQuestion {
id: "choice".into(),
header: "Approach".into(),
question: "Which approach should we use for the next step?".into(),
is_other: true,
is_secret: false,
options: Some(vec![ToolRequestUserInputOption {
label: "Keep it small".into(),
description: "Use the existing helper".into(),
}]),
};
let mut cell = RequestUserInputResultCell {
questions: vec![question],
answers: HashMap::from([(
"choice".into(),
ToolRequestUserInputAnswer {
answers: vec![
"Keep it small".into(),
"user_note: Preserve the behavior".into(),
],
},
)]),
interrupted: false,
};
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (240, 240, 240),
bg: (24, 24, 24),
},
|| {
let mut snapshots = Vec::new();
for state in ["completed", "interrupted"] {
let lines = cell.display_lines(/*width*/ 40);
let area = Rect::new(
/*x*/ 0,
/*y*/ 0,
/*width*/ 40,
lines.len() as u16,
);
let mut buffer = Buffer::empty(area);
Paragraph::new(lines).render(area, &mut buffer);
snapshots.push(format!("{state}: {buffer:?}"));
cell.answers.clear();
cell.interrupted = true;
}
insta::assert_snapshot!(snapshots.join("\n"));
},
);
}
+11 -1
View File
@@ -3,6 +3,7 @@
use super::*;
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::style::accent_color;
use crate::width::display_width;
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
@@ -79,6 +80,11 @@ impl TooltipHistoryCell {
}
impl HistoryCell for TooltipHistoryCell {
fn compact_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
// Optional tips stay available in detailed history without occupying the conversation.
Vec::new()
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let indent = " ";
let indent_width = display_width(indent);
@@ -105,6 +111,10 @@ impl HistoryCell for TooltipHistoryCell {
pub struct SessionInfoCell(CompositeHistoryCell);
impl HistoryCell for SessionInfoCell {
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.0.compact_hyperlink_lines(width)
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.display_lines(width)
}
@@ -362,7 +372,7 @@ impl HistoryCell for SessionHeaderHistoryCell {
spans.push(Span::styled("fast", self.model_style.magenta()));
}
spans.push(" ".dim());
spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
spans.push(CHANGE_MODEL_HINT_COMMAND.fg(accent_color()));
spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
spans
};
@@ -0,0 +1,9 @@
---
source: tui/src/history_cell/computer_activity_tests.rs
assertion_line: 118
expression: "cell.compact_hyperlink_lines(24).iter().map(|line|\nline.line.to_string()).collect::<Vec<_>>().join(\"\\n\")"
---
• Used computer · 5
actions · 1 failed
├ Failed: No browser …
└ Captured screenshot…
@@ -4,8 +4,9 @@ expression: transcript
---
• Called cua_repl.js({"title":"Open browser","code":"await cua.getState()"})
└ Error: No browser is available
## Computer Use
Full tool documentation
## Computer Use
Full tool documentation
• Called cua_repl.js({"title":"Connect to Chrome","code":"await cua.getState()"})
└ connected
• Called cua_repl.js({"title":"Reproduce the clear-command bug","code":"await cua.getState()"})
@@ -1,16 +1,22 @@
---
source: tui/src/history_cell/mcp_tests.rs
expression: "format!(\"history:\\n{}\\n\\ntranscript:\\n{}\",\ndisplay.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),\ntranscript.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),)"
expression: "format!(\"compact:\\n{}\\n\\nhistory:\\n{}\\n\\ntranscript:\\n{}\",\nvisible_lines(cell.compact_hyperlink_lines(80)).iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),\ndisplay.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),\ntranscript.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),)"
---
compact:
• Called search.lookup
└ third
fourth
fifth
history:
• Called search.lookup()
• Called search.lookup({"title":"Search query"})
└ first
second
third
+2 lines (ctrl + t to view transcript)
+2 lines (ctrl+t to view transcript)
transcript:
• Called search.lookup()
• Called search.lookup({"title":"Search query"})
└ first
second
third
@@ -0,0 +1,6 @@
---
source: tui/src/history_cell/messages_tests.rs
expression: "ratatui::text::Text::from(display)"
---
[Image #1]
@@ -0,0 +1,9 @@
---
source: tui/src/history_cell/messages_tests.rs
expression: "ratatui::text::Text::from(cell.display_lines(40))"
---
[Image #1]
[Image #3]
› [Image #2] compare these
@@ -0,0 +1,51 @@
---
source: tui/src/history_cell/patches_tests.rs
assertion_line: 28
expression: "snapshots.join(\"\\n\\n\")"
---
patch
Compact
• Edited 2 files (+6 -1)
└ existing.txt (+1 -1)
2 -before
2 +after
└ new.txt (+5 -0)
1 +one
2 +two
3 +three
Full
• Edited 2 files (+6 -1)
└ existing.txt (+1 -1)
1 context
2 -before
2 +after
3 tail
└ new.txt (+5 -0)
1 +one
2 +two
3 +three
4 +four
5 +five
failure
Compact
✘ Failed to apply patch
└ first
second
third
Full
✘ Failed to apply patch
└ first
second
third
fourth diagnostic
missing diagnostics
Compact
✘ Failed to apply patch
└ (error details unavailable)
Full
✘ Failed to apply patch
└ (error details unavailable)
@@ -0,0 +1,49 @@
---
source: tui/src/history_cell/request_user_input_tests.rs
assertion_line: 44
expression: "snapshots.join(\"\\n\")"
---
completed: Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 5 },
content: [
"• Questions 1/1 answered ",
" • Which approach should we use for the",
" next step? ",
" answer: Keep it small ",
" note: Preserve the behavior ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 1, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 24, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 12, y: 3, fg: Rgb(99, 168, 248), bg: Reset, underline: Reset, modifier: NONE,
x: 25, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 10, y: 4, fg: Rgb(99, 168, 248), bg: Reset, underline: Reset, modifier: NONE,
x: 31, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
interrupted: Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 4 },
content: [
"• Questions 0/1 answered (interrupted) ",
" • Which approach should we use for the",
" next step? (unanswered) ",
" ↳ interrupted with 1 unanswered ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 1, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 24, y: 0, fg: Rgb(99, 168, 248), bg: Reset, underline: Reset, modifier: NONE,
x: 38, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 14, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 27, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Rgb(99, 168, 248), bg: Reset, underline: Reset, modifier: NONE,
x: 33, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -10,4 +10,4 @@ expression: rendered
Line one of the response, which is
quite long and needs wrapping.
Line two continues the response with
+1 line (ctrl + t to view transcrip…
+1 line (ctrl+t to view transcript)
@@ -5,5 +5,5 @@ expression: rendered
• Ran first_token_is_long_en
│ ough_to_wrap
│ second_token_is_also_lon
│ … +1 lines
│ … +1 line
└ (no output)
@@ -1,7 +1,15 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
assertion_line: 2632
expression: rendered
---
Compact
• Updated Plan · 1/4 complete
└ □ Harden Grafana client err…
□ Add tests for transient f…
□ Document the retry behavi…
Full
• Updated Plan
└ I’ll update Grafana call
error handling by adding
@@ -18,3 +26,5 @@ expression: rendered
□ Add tests for transient
failure scenarios and
surfacing to the UI
□ Document the retry
behavior
@@ -9,4 +9,4 @@ expression: rendered
└ error: first line on
stderr
error: second line on
+1 line (ctrl + t to vi…
+1 line (ctrl+t to view…
@@ -1,5 +1,6 @@
---
source: tui/src/history_cell/tests.rs
assertion_line: 1908
expression: "format!(\"{buf:?}\")"
---
Buffer {
@@ -18,7 +19,7 @@ Buffer {
x: 17, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 18, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 21, y: 3, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
x: 21, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 13, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 40, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -1,5 +1,6 @@
---
source: tui/src/history_cell/tests.rs
assertion_line: 1889
expression: "format!(\"{buf:?}\")"
---
Buffer {
@@ -24,7 +25,7 @@ Buffer {
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 13, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 24, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 27, y: 3, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 33, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 45, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
@@ -6,4 +6,4 @@ expression: rendered
└ 1
2
3
+7 lines (ctrl + t to view transcript)
+7 lines (ctrl+t to view transcript)
+27 -20
View File
@@ -60,7 +60,7 @@ fn connected_server_version_notice_snapshot() {
)
.expect("older remote service should have a notice");
let cell = new_server_version_warning(notice);
insta::assert_snapshot!(render_lines(&cell.display_lines(/*width*/ 100)).join("\n"));
insta::assert_snapshot!(render_lines(&cell.transcript_lines(/*width*/ 100)).join("\n"));
}
#[test]
@@ -95,7 +95,7 @@ fn local_daemon_version_notice_snapshot() {
let cell = new_server_version_warning(notice);
insta::assert_snapshot!(
snapshot,
render_lines(&cell.display_lines(/*width*/ 100)).join("\n")
render_lines(&cell.transcript_lines(/*width*/ 100)).join("\n")
);
}
}
@@ -628,11 +628,10 @@ fn session_configured_event(model: &str) -> ThreadSessionState {
}
#[test]
fn unified_exec_interaction_cell_renders_input() {
fn unified_exec_interaction_cell_retains_detailed_input() {
let input = (1..=16).map(|line| format!("line {line}\n")).collect();
let cell = new_unified_exec_interaction(Some("cat".to_string()), input);
let lines = render_lines(&cell.display_lines(/*width*/ 80));
assert_eq!(lines, render_transcript(&cell));
let lines = render_lines(&cell.transcript_lines(/*width*/ 80));
insta::assert_snapshot!(lines.join("\n"), @"
↳ Interacted with background terminal · cat
└ line 1
@@ -655,7 +654,7 @@ fn unified_exec_interaction_cell_renders_input() {
}
#[test]
fn unified_exec_interaction_cell_renders_wait() {
fn unified_exec_interaction_cell_retains_detailed_wait() {
let cell = new_unified_exec_interaction(/*command_display*/ None, String::new());
let lines = render_transcript(&cell);
assert_eq!(lines, vec!["• Waited for background terminal"]);
@@ -1147,7 +1146,6 @@ fn mcp_tools_output_from_statuses_renders_verbose_inventory() {
fn empty_agent_message_cell_transcript() {
let cell = AgentMessageCell::new(vec![Line::default()], /*is_first_line*/ false);
assert_eq!(cell.transcript_lines(/*width*/ 80), vec![Line::from(" ")]);
assert_eq!(cell.desired_transcript_height(/*width*/ 80), 1);
}
#[test]
@@ -1190,10 +1188,10 @@ fn prefixed_wrapped_history_cell_does_not_split_url_like_token() {
}
#[test]
fn unified_exec_interaction_cell_does_not_split_url_like_stdin_token() {
fn unified_exec_interaction_details_do_not_split_url_like_stdin_token() {
let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
let cell = UnifiedExecInteractionCell::new(Some("true".to_string()), url_like.to_string());
let rendered = render_lines(&cell.display_lines(/*width*/ 24));
let rendered = render_lines(&cell.transcript_lines(/*width*/ 24));
assert_eq!(
rendered
@@ -1243,7 +1241,7 @@ fn prefixed_wrapped_history_cell_height_matches_wrapped_rendering() {
}
#[test]
fn unified_exec_interaction_cell_height_matches_wrapped_rendering() {
fn unified_exec_interaction_details_wrap_long_input() {
let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path";
let cell: Box<dyn HistoryCell> = Box::new(UnifiedExecInteractionCell::new(
Some("true".to_string()),
@@ -1251,16 +1249,18 @@ fn unified_exec_interaction_cell_height_matches_wrapped_rendering() {
));
let width: u16 = 24;
let logical_height = cell.display_lines(width).len() as u16;
let wrapped_height = cell.desired_height(width);
let lines = cell.transcript_hyperlink_lines(width);
let logical_height = lines.len() as u16;
let paragraph = HyperlinkParagraph::new(&lines, Style::default());
let wrapped_height = u16::try_from(paragraph.line_count(width)).unwrap();
assert!(
wrapped_height > logical_height,
"expected wrapped height to exceed logical line count ({logical_height}), got {wrapped_height}"
);
let area = Rect::new(0, 0, width, wrapped_height);
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, wrapped_height);
let mut buf = ratatui::buffer::Buffer::empty(area);
cell.render(area, &mut buf);
paragraph.render(area, &mut buf);
let first_row = (0..area.width)
.map(|x| {
@@ -1453,7 +1453,7 @@ fn code_mode_tool_call_uses_title_and_preserves_full_transcript() {
└ 012345678901234567890123456789012345
678901234567890123456789012345678901
234567890123456789012345678901234567
+1 line (ctrl + t to view transcrip…
+1 line (ctrl+t to view transcript)
transcript:
• Called node_repl.js({"title":"Inspect Spotify workspace","code":"await tools.exec_command({ cmd: 'git status' })"})
@@ -2503,7 +2503,7 @@ fn user_history_cell_height_matches_rendered_lines_with_remote_images() {
.try_into()
.unwrap_or(u16::MAX);
assert_eq!(cell.desired_height(width), rendered_len);
assert_eq!(cell.desired_transcript_height(width), rendered_len);
assert_eq!(cell.transcript_lines(width), cell.display_lines(width));
}
#[test]
@@ -2633,13 +2633,19 @@ fn plan_update_with_note_and_wrapping_snapshot() {
step: "Add tests for transient failure scenarios and surfacing to the UI".into(),
status: StepStatus::Pending,
},
PlanItemArg { step: "Document the retry behavior".into(), status: StepStatus::Pending },
],
};
let cell = new_plan_update(update);
// Narrow width to force wrapping for both the note and steps
let lines = cell.display_lines(/*width*/ 32);
let rendered = render_lines(&lines).join("\n");
let compact =
render_lines(&visible_lines(cell.compact_hyperlink_lines(/*width*/ 32))).join("\n");
let rendered = format!(
"Compact\n{compact}\n\nFull\n{}",
render_lines(&lines).join("\n")
);
insta::assert_snapshot!(rendered);
}
@@ -2736,8 +2742,7 @@ fn reasoning_summary_height_matches_wrapped_rendering_for_url_like_content() {
"expected wrapped height to be at least logical line count ({logical_height}), got {wrapped_height}"
);
let wrapped_transcript_height = cell.desired_transcript_height(width);
assert_eq!(wrapped_transcript_height, wrapped_height);
assert_eq!(cell.transcript_lines(width), cell.display_lines(width));
let area = Rect::new(0, 0, width, wrapped_height);
let mut buf = ratatui::buffer::Buffer::empty(area);
@@ -2944,7 +2949,9 @@ fn deprecation_notice_renders_summary_with_details() {
"Feature flag `foo`".to_string(),
Some("Use flag `bar` instead.".to_string()),
);
let lines = cell.display_lines(/*width*/ 80);
let lines = cell.transcript_lines(/*width*/ 80);
assert_eq!(cell.display_lines(/*width*/ 80), lines);
assert_eq!(cell.live_raw_lines(), cell.raw_lines());
let rendered = render_lines(&lines);
assert_eq!(
rendered,
+93
View File
@@ -0,0 +1,93 @@
//! Render inline warnings and retain diagnostic identities for the later warning footer.
//! Message identities deduplicate replay; MCP identities count affected servers, not summary rows.
use super::*;
use std::collections::BTreeSet;
use std::sync::Arc;
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) enum WarningId {
Message(String),
McpServer(String),
}
/// A retained diagnostic. Identity is independent of wrapping and duplicate delivery.
#[derive(Clone, Debug, Eq, PartialEq)]
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) struct WarningEntry {
pub(crate) id: WarningId,
pub(crate) source: String,
pub(crate) details: String,
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn warning_entries(cells: &[Arc<dyn HistoryCell>]) -> Vec<WarningEntry> {
let mut entries: Vec<WarningEntry> = Vec::new();
for entry in cells.iter().flat_map(|cell| cell.warning_entries()) {
if let Some(existing) = entries.iter_mut().find(|existing| existing.id == entry.id) {
if !existing.details.contains(&entry.details) {
existing.details.push_str("\n\n");
existing.details.push_str(&entry.details);
}
} else {
entries.push(entry);
}
}
entries
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) enum WarningKey<'a> {
Message(&'a str),
McpServer(&'a str),
}
#[derive(Debug)]
pub(crate) struct WarningHistoryCell {
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(super) key: String,
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(super) diagnostic: String,
pub(super) details: PrefixedWrappedHistoryCell,
}
impl HistoryCell for WarningHistoryCell {
fn live_raw_lines(&self) -> Vec<Line<'static>> {
self.details.raw_lines()
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.details.display_lines(width)
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.details.transcript_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
self.details.raw_lines()
}
fn warning_keys(&self) -> Vec<WarningKey<'_>> {
vec![WarningKey::Message(&self.key)]
}
fn warning_entries(&self) -> Vec<WarningEntry> {
vec![WarningEntry {
id: WarningId::Message(self.key.clone()),
source: "Warning".into(),
details: self.diagnostic.clone(),
}]
}
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn warning_count(cells: &[Arc<dyn HistoryCell>]) -> usize {
cells
.iter()
.flat_map(|cell| cell.warning_keys())
.collect::<BTreeSet<_>>()
.len()
}
@@ -1,7 +0,0 @@
---
source: tui/src/diff_render.rs
expression: text
---
1 +fn very_long_function_name(arg_one: String, arg_two: String, arg_three: Strin
g, arg_four: String) -> Result<String, Box<dyn std::error::Error>> { Ok(arg_o
ne) }
@@ -1,13 +1,20 @@
---
source: tui/src/tool_output_tests.rs
expression: "format!(\"history:\\n{}\\n\\ntranscript:\\n{}\",\ncompleted.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),\ncell.transcript_lines(80).iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),)"
assertion_line: 190
expression: "format!(\"history:\\n{}\\n\\ncompact:\\n{}\\n\\ntranscript:\\n{}\",\ncompleted.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),\nvisible_lines(cell.compact_hyperlink_lines(80)).iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),\ncell.transcript_lines(80).iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),)"
---
history:
• Ran echo output
└ first
second
third
+2 lines (ctrl + t to view transcript)
+2 lines (ctrl+t to view transcript)
compact:
• Ran echo output
└ third
fourth
fifth
transcript:
$ echo output
@@ -1,8 +1,9 @@
---
source: tui/src/tool_output_tests.rs
assertion_line: 22
expression: "preview.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\")"
---
first
https://
example.test/a/
+9 lines (ctrl …
+9 lines (ctrl+…
+41
View File
@@ -7,6 +7,7 @@ mod paragraph;
mod source;
pub(crate) use paragraph::HyperlinkParagraph;
pub(crate) use source::LineWrapPolicy;
pub(crate) use source::LogicalLineSource;
use std::num::NonZeroU16;
@@ -278,6 +279,46 @@ pub(crate) fn prefix_hyperlink_lines(
.collect()
}
/// Retain a known first-line label in copy text without changing the existing wrapping.
///
/// Callers pass the suffix of the first row's synthetic prefix that carries meaning, such as
/// a checkbox or `answer:`. Only that row's shared logical source is updated.
pub(crate) fn retain_initial_prefix(lines: &mut [HyperlinkLine], prefix: &str) {
let Some(origin) = lines.first().and_then(|line| line.source.clone()) else {
return;
};
debug_assert!(origin.prefix_bytes >= prefix.len());
let prefix_line = LogicalLineSource::from_line(&lines[0].line)
.styled_range(origin.prefix_bytes - prefix.len()..origin.prefix_bytes);
let mut styles = LogicalLineSource::from_line(&prefix_line).styles.to_vec();
styles.extend(origin.styles.iter().map(|(range, style)| {
(
range.start + prefix.len()..range.end + prefix.len(),
origin.line_style.patch(*style).patch(origin.span_style),
)
}));
let styles = std::sync::Arc::from(styles);
let text: std::sync::Arc<str> = format!("{prefix}{}", origin.text).into();
for (index, line) in lines.iter_mut().enumerate() {
let Some(source) = line
.source
.as_mut()
.filter(|source| std::sync::Arc::ptr_eq(&source.text, &origin.text))
else {
continue;
};
source.text = std::sync::Arc::clone(&text);
source.styles = std::sync::Arc::clone(&styles);
source.line_style = ratatui::style::Style::default();
source.span_style = ratatui::style::Style::default();
source.range = source.range.start + prefix.len()..source.range.end + prefix.len();
if index == 0 {
source.range.start = 0;
source.prefix_bytes -= prefix.len();
}
}
}
pub(crate) fn adaptive_wrap_hyperlink_lines(
lines: &[HyperlinkLine],
options: RtOptions<'static>,
@@ -11,6 +11,9 @@ use std::sync::Arc;
pub(crate) enum LineWrapPolicy {
#[default]
Word,
/// Keep fitting URL tokens intact and split oversized tokens to fit the viewport.
UrlAware,
Hard,
}
/// A displayed line's contiguous fragment of one original logical line.
@@ -72,10 +75,6 @@ impl LogicalLineSource {
source
}
#[allow(
dead_code,
reason = "Used by the activity presentation layer of this stack."
)]
pub(crate) fn styled_range(&self, range: Range<usize>) -> Line<'static> {
Line::from(
self.styles
+49 -21
View File
@@ -1,12 +1,16 @@
//! Compact tool output previews. Keep three screen rows and report hidden logical lines below
//! them; a partially displayed logical line counts as hidden. Full output belongs in the transcript.
//! Preserve logical source ranges and hyperlinks while bounding work before display wrapping.
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::live_wrap::take_prefix_by_width;
use crate::render::line_utils::push_owned_lines;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::LogicalLineSource;
use crate::terminal_hyperlinks::TerminalHyperlink;
use crate::terminal_hyperlinks::remap_source_wrapped_line;
use crate::ui_consts::TRANSCRIPT_HINT;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_line_with_source;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -15,12 +19,12 @@ use textwrap::WordSplitter;
const PREVIEW_LINES: usize = 3;
const MAX_PREVIEW_LINE_BYTES: usize = 16 * 1024;
/// Render only the leading lines; `total_lines` includes any lines no longer retained by the caller.
pub(crate) fn tool_output_preview<'a>(
/// Render a bounded preview with source ranges retained for transcript selection and copying.
pub(crate) fn tool_output_hyperlink_preview<'a>(
lines: impl IntoIterator<Item = Line<'a>>,
width: usize,
total_lines: usize,
) -> Vec<Line<'static>> {
) -> Vec<HyperlinkLine> {
let mut preview = ToolOutputPreview::new(width, /*omitted*/ 0);
let mut consumed = 0;
for line in lines.into_iter().take(PREVIEW_LINES) {
@@ -28,11 +32,11 @@ pub(crate) fn tool_output_preview<'a>(
consumed += 1;
}
preview.omitted += total_lines.saturating_sub(consumed);
preview.finish()
preview.finish_hyperlink_lines()
}
pub(crate) struct ToolOutputPreview {
lines: Vec<Line<'static>>,
lines: Vec<HyperlinkLine>,
width: usize,
omitted: usize,
full: bool,
@@ -42,13 +46,26 @@ impl ToolOutputPreview {
pub(crate) fn new(width: usize, omitted: usize) -> Self {
Self {
lines: Vec::new(),
width: width.max(1),
width: width.max(/*other*/ 1),
omitted,
full: false,
}
}
pub(crate) fn push_line(&mut self, line: Line<'_>) {
self.push_rendered_line(line, /*source*/ None, Vec::new());
}
pub(crate) fn push_hyperlink_line(&mut self, line: HyperlinkLine) {
self.push_rendered_line(line.line, line.source, line.hyperlinks);
}
fn push_rendered_line(
&mut self,
line: Line<'_>,
source: Option<LogicalLineSource>,
hyperlinks: Vec<TerminalHyperlink>,
) {
let remaining = PREVIEW_LINES - self.lines.len();
if self.full || remaining == 0 {
self.omitted += 1;
@@ -65,36 +82,47 @@ impl ToolOutputPreview {
let text = &span.content[..span.content.floor_char_boundary(bytes)];
let (prefix, rest, used) = take_prefix_by_width(text, columns);
bytes -= prefix.len();
bounded.push_span(Span::styled(prefix, span.style));
bounded.push_span(Span::styled(prefix.to_owned(), span.style));
columns = columns.saturating_sub(used);
if !rest.is_empty() || text.len() < span.content.len() {
truncated = true;
break;
}
}
// Hard-wrap even URL tokens here so the preview cannot exceed its row budget.
// The transcript keeps the original, clickable URLs.
let wrapped = word_wrap_line(
&bounded,
// Hard-wrap even URL tokens here so the preview cannot exceed its row budget, while
// remapping each visible fragment to the original hyperlink destination and source.
let bounded = HyperlinkLine {
line: bounded,
hyperlinks,
source,
};
let wrapped = word_wrap_line_with_source(
&bounded.line,
RtOptions::new(self.width).word_splitter(WordSplitter::NoHyphenation),
);
if truncated || wrapped.len() > remaining {
self.omitted += 1;
self.full = true;
}
push_owned_lines(&wrapped[..wrapped.len().min(remaining)], &mut self.lines);
self.lines.extend(remap_source_wrapped_line(
&bounded,
wrapped.into_iter().take(remaining).collect(),
));
}
pub(crate) fn finish(mut self) -> Vec<Line<'static>> {
pub(crate) fn finish_hyperlink_lines(mut self) -> Vec<HyperlinkLine> {
if self.omitted > 0 {
let omitted = self.omitted;
let unit = if omitted == 1 { "line" } else { "lines" };
self.lines.push(truncate_line_with_ellipsis_if_overflow(
format!("+{omitted} {unit} ({TRANSCRIPT_HINT})")
.dim()
.into(),
self.width,
));
self.lines.push(
truncate_line_with_ellipsis_if_overflow(
format!("+{omitted} {unit} ({TRANSCRIPT_HINT})")
.dim()
.into(),
self.width,
)
.into(),
);
}
self.lines
}
+78 -17
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::terminal_hyperlinks::visible_lines;
use pretty_assertions::assert_eq;
#[test]
@@ -8,11 +9,11 @@ fn preview_caps_wrapped_output_and_counts_hidden_logical_lines() {
"https://example.test/a/very/long/path/that/exceeds/the/preview",
"last",
];
let preview = tool_output_preview(
let preview = visible_lines(tool_output_hyperlink_preview(
lines.into_iter().map(Line::from),
/*width*/ 16,
/*total_lines*/ 10,
);
));
assert_eq!(preview.len(), PREVIEW_LINES + 1);
assert!(
preview[..PREVIEW_LINES]
@@ -31,31 +32,81 @@ fn preview_caps_wrapped_output_and_counts_hidden_logical_lines() {
#[test]
fn preview_preserves_short_output_and_blank_lines() {
let lines = vec!["first".red().into(), Line::default(), "last".dim().into()];
let mut preview = ToolOutputPreview::new(/*width*/ 40, /*omitted*/ 0);
for line in lines.clone() {
preview.push_line(line);
}
assert_eq!(visible_lines(preview.finish_hyperlink_lines()), lines);
}
#[test]
fn preview_preserves_hyperlinks_and_original_logical_source() {
let line = Line::from("one two three four five".red());
let source = LogicalLineSource::from_line(&line);
let destination = "https://example.test/original".to_owned();
let mut preview = ToolOutputPreview::new(/*width*/ 6, /*omitted*/ 0);
preview.push_hyperlink_line(HyperlinkLine {
line,
hyperlinks: vec![TerminalHyperlink::web(
/*columns*/ 0..24,
destination.clone(),
)],
source: Some(source.clone()),
});
let preview = preview.finish_hyperlink_lines();
assert_eq!(
tool_output_preview(lines.clone(), /*width*/ 40, lines.len()),
lines,
preview[..PREVIEW_LINES]
.iter()
.map(|line| (
line.line.clone(),
line.hyperlinks.clone(),
line.source.clone()
))
.collect::<Vec<_>>(),
[("one", 0..3), ("two", 6..9), ("three", 10..15)]
.into_iter()
.map(|(text, range)| {
let mut source = source.clone();
source.range = range;
(
Line::from(text.red()),
vec![TerminalHyperlink::web(
/*columns*/ 0..text.len(),
destination.clone(),
)],
Some(source),
)
})
.collect::<Vec<_>>(),
);
assert_eq!(
(
preview[PREVIEW_LINES].source.clone(),
preview[PREVIEW_LINES].hyperlinks.clone()
),
(None, Vec::new()),
);
}
#[test]
fn preview_counts_newline_dense_output() {
let mut rendered = 0;
let preview = tool_output_preview(
std::iter::repeat_n(Line::default(), 100_000).inspect(|_| rendered += 1),
let preview = visible_lines(tool_output_hyperlink_preview(
std::iter::repeat_n(Line::default(), /*n*/ 100_000).inspect(|_| rendered += 1),
/*width*/ 40,
/*total_lines*/ 100_000,
);
));
assert_eq!(rendered, PREVIEW_LINES);
assert_eq!(
preview.iter().map(ToString::to_string).collect::<Vec<_>>(),
["", "", "", "+99997 lines (ctrl + t to view transcri…"],
["", "", "", "+99997 lines (ctrl+t to view transcript)"],
);
}
#[test]
fn preview_caps_combining_text_across_styled_spans() {
let marks = "\u{301}".repeat(MAX_PREVIEW_LINE_BYTES / 4);
let preview = tool_output_preview(
let preview = tool_output_hyperlink_preview(
[
Line::from(vec!["e".red(), marks.clone().dim(), marks.clone().blue()]),
"later".into(),
@@ -63,15 +114,20 @@ fn preview_caps_combining_text_across_styled_spans() {
/*width*/ 80,
/*total_lines*/ 2,
);
let expected_line = Line::from(vec![
"e".red(),
marks.clone().dim(),
marks[..marks.len() - 2].blue(),
]);
assert_eq!(
preview,
preview[0].source,
Some(LogicalLineSource::from_line(&expected_line)),
);
assert_eq!(
visible_lines(preview),
vec![
Line::from(vec![
"e".red(),
marks.clone().dim(),
marks[..marks.len() - 2].blue(),
]),
"+2 lines (ctrl + t to view transcript)".dim().into(),
expected_line,
"+2 lines (ctrl+t to view transcript)".dim().into()
],
);
}
@@ -103,12 +159,17 @@ fn command_preview_matches_streamed_and_completed_output() {
let completed = cell.display_lines(/*width*/ 80);
assert_eq!(&live[1..], &completed[1..]);
insta::assert_snapshot!(format!(
"history:\n{}\n\ntranscript:\n{}",
"history:\n{}\n\ncompact:\n{}\n\ntranscript:\n{}",
completed
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
visible_lines(cell.compact_hyperlink_lines(/*width*/ 80))
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
cell.transcript_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
+1 -1
View File
@@ -9,4 +9,4 @@
/// - User history lines account for this many columns (e.g., "▌ ") when wrapping.
pub(crate) const LIVE_PREFIX_COLS: u16 = 2;
pub(crate) const FOOTER_INDENT_COLS: usize = LIVE_PREFIX_COLS as usize;
pub(crate) const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript";
pub(crate) const TRANSCRIPT_HINT: &str = "ctrl+t to view transcript";
+20
View File
@@ -752,6 +752,26 @@ pub(crate) fn adaptive_wrap_line_with_source<'a>(
}
}
/// Preserve fitting URL tokens while splitting oversized tokens within the requested width.
/// Source ranges and hanging indents survive the fallback, without terminal autowrap.
pub(crate) fn adaptive_wrap_line_to_width<'a>(
line: &'a Line<'a>,
options: RtOptions<'a>,
) -> Vec<WrappedLine<'a>> {
let wrapped = adaptive_wrap_line_with_source(line, options.clone());
if wrapped
.iter()
.any(|row| line_width(&row.line) > options.width)
{
word_wrap_line_with_source(
line,
url_preserving_wrap_options(options).break_words(/*break_words*/ true),
)
} else {
wrapped
}
}
/// Wraps multiple input lines with URL-aware heuristics, applying
/// `initial_indent` to the first line and `subsequent_indent` to the
/// rest. Each line is independently checked for URLs; URL detection on