mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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,
|
||||
|
||||
+6
-3
@@ -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]
|
||||
|
||||
@@ -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}",
|
||||
);
|
||||
|
||||
+9
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
mod compact;
|
||||
mod live_output;
|
||||
mod model;
|
||||
mod render;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()))],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = " ↳ ";
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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(" └ "))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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(¬e, 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
|
||||
|
||||
@@ -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(¬e, 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"));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
+9
@@ -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…
|
||||
+3
-2
@@ -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()"})
|
||||
|
||||
+10
-4
@@ -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
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell/messages_tests.rs
|
||||
expression: "ratatui::text::Text::from(display)"
|
||||
---
|
||||
|
||||
[Image #1]
|
||||
+9
@@ -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
|
||||
+51
@@ -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)
|
||||
+49
@@ -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,
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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…
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ expression: rendered
|
||||
└ 1
|
||||
2
|
||||
3
|
||||
+7 lines (ctrl + t to view transcript)
|
||||
+7 lines (ctrl+t to view transcript)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
-7
@@ -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) }
|
||||
+9
-2
@@ -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
|
||||
|
||||
+2
-1
@@ -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+…
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user