Show full patches and terminal input in TUI history (#42107)

## What changed

- Render complete patch contents directly in TUI history cells.
- Render all input sent to background terminals inline.
- Remove the preview row and byte limits and their transcript hints so inline and transcript views contain the same content.

## Testing

Update snapshots to cover patches and terminal input longer than the former 12-row preview limit.

GitOrigin-RevId: 9960818a9b51fc0c7f84d9def77b9b1bd8acad6c
This commit is contained in:
Benjamin Carlsson
2026-09-01 17:37:47 +00:00
committed by copyberry
parent f40e08478c
commit 6a479e1813
7 changed files with 77 additions and 708 deletions
+22 -7
View File
@@ -1628,7 +1628,7 @@ async fn apply_patch_events_emit_history_cells() {
changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
content: (1..=16).map(|line| format!("line {line}\n")).collect(),
},
);
let ev = ApplyPatchApprovalRequestEvent {
@@ -1649,24 +1649,39 @@ async fn apply_patch_events_emit_history_cells() {
changes2.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
content: (1..=16).map(|line| format!("line {line}\n")).collect(),
},
);
handle_patch_apply_begin(&mut chat, "c1", "turn-c1", changes2);
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected apply block cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"),
"expected single-file header with filename (Added/Edited): {blob:?}"
);
insta::assert_snapshot!(blob, @"
• Added foo.txt (+16 -0)
1 +line 1
2 +line 2
3 +line 3
4 +line 4
5 +line 5
6 +line 6
7 +line 7
8 +line 8
9 +line 9
10 +line 10
11 +line 11
12 +line 12
13 +line 13
14 +line 14
15 +line 15
16 +line 16
");
// 3) End apply success -> success cell
let mut end_changes = HashMap::new();
end_changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
content: (1..=16).map(|line| format!("line {line}\n")).collect(),
},
);
handle_patch_apply_end(
+16 -155
View File
@@ -41,7 +41,6 @@ use ratatui::style::Stylize;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -49,8 +48,6 @@ use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use unicode_width::UnicodeWidthChar;
mod preview;
/// Replacement for a tab character in rendered diff content.
const TAB_REPLACEMENT: &str = " ";
/// Display width of a tab character in columns.
@@ -312,25 +309,13 @@ impl DiffSummary {
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,
usize::MAX,
);
render_change(self, &mut lines, area.width as usize, /*lang*/ None);
Paragraph::new(lines).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let mut lines = vec![];
render_change(
self,
&mut lines,
width as usize,
/*lang*/ None,
usize::MAX,
);
render_change(self, &mut lines, width as usize, /*lang*/ None);
lines.len() as u16
}
}
@@ -369,15 +354,7 @@ pub(crate) fn create_diff_summary(
wrap_cols: usize,
) -> Vec<RtLine<'static>> {
let rows = collect_rows(changes);
render_changes_block(rows, wrap_cols, cwd, usize::MAX)
}
pub(crate) fn create_diff_preview(
changes: &HashMap<PathBuf, FileChange>,
cwd: &Path,
wrap_cols: usize,
) -> Vec<RtLine<'static>> {
render_changes_block(collect_rows(changes), wrap_cols, cwd, preview::PREVIEW_ROWS)
render_changes_block(rows, wrap_cols, cwd)
}
// Shared row for per-file presentation
@@ -430,12 +407,7 @@ 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,
mut remaining_rows: usize,
) -> Vec<RtLine<'static>> {
fn render_changes_block(rows: Vec<Row<'_>>, wrap_cols: usize, cwd: &Path) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
let render_path = |row: &Row<'_>| -> Vec<RtSpan<'static>> {
@@ -471,7 +443,6 @@ fn render_changes_block(
}
out.push(RtLine::from(header_spans));
let mut omitted = false;
for (idx, r) in rows.into_iter().enumerate() {
// Insert a blank separator between file chunks (except before the first)
if idx > 0 {
@@ -488,11 +459,6 @@ fn render_changes_block(
out.push(RtLine::from(header));
}
if remaining_rows == 0 && (omitted || r.added > 0 || r.removed > 0) {
omitted = true;
continue;
}
// For renames, use the destination extension for highlighting — the
// diff content reflects the new file, not the old one.
let lang_path = r.move_path.unwrap_or(r.path);
@@ -500,47 +466,10 @@ fn render_changes_block(
let mut lines = vec![];
let prefix = " ";
let content_width = wrap_cols.saturating_sub(prefix.len());
omitted |= render_change(
r.change,
&mut lines,
content_width,
lang.as_deref(),
remaining_rows,
);
for line in prefix_lines(lines, prefix.into(), prefix.into()) {
if remaining_rows != usize::MAX {
let rows = Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.line_count(wrap_cols as u16);
if rows > remaining_rows {
out.extend(preview::rendered_prefix(
line,
wrap_cols as u16,
remaining_rows as u16,
));
remaining_rows = 0;
omitted = true;
break;
}
remaining_rows -= rows;
}
out.push(line);
}
if omitted {
remaining_rows = 0;
}
render_change(r.change, &mut lines, content_width, lang.as_deref());
out.extend(prefix_lines(lines, prefix.into(), prefix.into()));
}
if omitted {
out.push(
format!(
" … Diff preview limited ({}).",
crate::ui_consts::TRANSCRIPT_HINT
)
.dim()
.into(),
);
}
out
}
@@ -557,27 +486,14 @@ fn render_change(
out: &mut Vec<RtLine<'static>>,
width: usize,
lang: Option<&str>,
max_rows: usize,
) -> bool {
let mut omitted = false;
) {
let style_context = current_diff_render_style_context();
match change {
FileChange::Add { content } => {
// Pre-highlight the entire file content as a whole.
let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l));
let line_number_width = line_number_width(content.lines().count());
let end = if max_rows == usize::MAX {
content.len()
} else {
preview::visible_byte_count(
content.split_inclusive('\n'),
width.saturating_sub(line_number_width + 2),
max_rows,
)
};
let visible_content = &content[..end];
omitted = visible_content.len() < content.len();
let syntax_lines =
lang.and_then(|l| highlight_code_to_styled_spans(visible_content, l));
for (i, raw) in visible_content.lines().enumerate() {
for (i, raw) in content.lines().enumerate() {
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(
@@ -607,21 +523,9 @@ fn render_change(
}
}
FileChange::Delete { content } => {
let syntax_lines = lang.and_then(|l| highlight_code_to_styled_spans(content, l));
let line_number_width = line_number_width(content.lines().count());
let end = if max_rows == usize::MAX {
content.len()
} else {
preview::visible_byte_count(
content.split_inclusive('\n'),
width.saturating_sub(line_number_width + 2),
max_rows,
)
};
let visible_content = &content[..end];
omitted = visible_content.len() < content.len();
let syntax_lines =
lang.and_then(|l| highlight_code_to_styled_spans(visible_content, l));
for (i, raw) in visible_content.lines().enumerate() {
for (i, raw) in content.lines().enumerate() {
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(
@@ -696,9 +600,6 @@ 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 out.len() >= max_rows {
return true;
}
if !is_first_hunk {
let spacer = format!("{:width$} ", "", width = line_number_width.max(1));
let spacer_span = RtSpan::styled(
@@ -713,46 +614,11 @@ fn render_change(
}
is_first_hunk = false;
let visible_bytes = if max_rows == usize::MAX {
usize::MAX
} else {
preview::visible_byte_count(
h.lines().iter().map(|line| match line {
diffy::Line::Insert(text)
| diffy::Line::Delete(text)
| diffy::Line::Context(text) => *text,
}),
width.saturating_sub(line_number_width + 2),
max_rows.saturating_sub(out.len()),
)
};
let visible_lines: Vec<_> = h
.lines()
.iter()
.scan(visible_bytes, |remaining, line| {
if *remaining == 0 {
return None;
}
let text = match line {
diffy::Line::Insert(text)
| diffy::Line::Delete(text)
| diffy::Line::Context(text) => *text,
};
let end = (*remaining).min(text.len());
*remaining -= end;
omitted |= end < text.len();
Some(match line {
diffy::Line::Insert(_) => diffy::Line::Insert(&text[..end]),
diffy::Line::Delete(_) => diffy::Line::Delete(&text[..end]),
diffy::Line::Context(_) => diffy::Line::Context(&text[..end]),
})
})
.collect();
// Highlight each hunk as a single block so syntect parser
// state is preserved across consecutive lines.
let hunk_syntax_lines = diff_lang.and_then(|language| {
let hunk_text: String = visible_lines
let hunk_text: String = h
.lines()
.iter()
.map(|line| match line {
diffy::Line::Insert(text)
@@ -761,12 +627,12 @@ fn render_change(
})
.collect();
let syntax_lines = highlight_code_to_styled_spans(&hunk_text, language)?;
(syntax_lines.len() == visible_lines.len()).then_some(syntax_lines)
(syntax_lines.len() == h.lines().len()).then_some(syntax_lines)
});
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for (line_idx, l) in visible_lines.iter().enumerate() {
for (line_idx, l) in h.lines().iter().enumerate() {
let syntax_spans = hunk_syntax_lines
.as_ref()
.and_then(|syntax_lines| syntax_lines.get(line_idx));
@@ -873,15 +739,10 @@ fn render_change(
}
}
}
if omitted || visible_lines.len() < h.lines().len() {
omitted = true;
break;
}
}
}
}
}
omitted
}
/// Format a path for display relative to the current working directory when
-90
View File
@@ -1,90 +0,0 @@
//! Bounds inline diff work before allocating wrapped rows or syntax spans.
use super::TAB_WIDTH;
use ratatui::buffer::Buffer;
use ratatui::buffer::CellWidth;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthChar;
pub(super) const PREVIEW_ROWS: usize = 12;
const MAX_PREVIEW_BYTES: usize = 64 * 1024;
/// Counts a UTF-8 source prefix within a pre-render character-width budget.
/// The caller also limits final viewport rows after styling and wrapping.
/// Include the start of an oversized line, stopping its scan at either budget.
/// The byte ceiling also bounds zero-width content that consumes no columns.
/// Lines must retain their terminators so the count can slice the source.
pub(super) fn visible_byte_count<'a>(
lines: impl Iterator<Item = &'a str>,
content_width: usize,
max_rows: usize,
) -> usize {
let content_width = content_width.max(/*other*/ 1);
let mut remaining_rows = max_rows;
let mut count = 0;
for line in lines {
if remaining_rows == 0 {
break;
}
let mut rows = 1;
let mut col = 0;
let end = line.floor_char_boundary(MAX_PREVIEW_BYTES - count);
let visible = &line[..end];
for (offset, ch) in visible.trim_end_matches(['\r', '\n']).char_indices() {
let width = ch.width().unwrap_or(if ch == '\t' { TAB_WIDTH } else { 0 });
if col > 0 && col + width > content_width {
rows += 1;
col = 0;
}
if rows > remaining_rows {
return count + offset;
}
col += width;
}
remaining_rows -= rows;
count += visible.len();
if end < line.len() {
return count;
}
}
count
}
/// Keep a partial line using the viewport renderer's own grapheme and word
/// wrapping. Only the remaining preview rows are allocated; full views bypass it.
pub(super) fn rendered_prefix(
line: Line<'static>,
width: u16,
max_rows: u16,
) -> Vec<Line<'static>> {
let style = line.style;
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, max_rows);
let mut buffer = Buffer::empty(area);
Paragraph::new(line)
.wrap(Wrap { trim: false })
.render(area, &mut buffer);
buffer
.content
.chunks(usize::from(width.max(/*other*/ 1)))
.map(|cells| {
let end = cells
.iter()
.rposition(|cell| cell.symbol() != " ")
.map_or(/*default*/ 0, |index| index + 1);
let mut spans = Vec::new();
let mut column = 0;
while column < end {
let cell = &cells[column];
spans.push(Span::styled(cell.symbol().to_string(), cell.style()));
// Buffer cells following a wide grapheme are continuation cells.
column += usize::from(cell.cell_width().max(/*other*/ 1));
}
Line::from(spans).style(style)
})
.collect()
}
+16 -64
View File
@@ -1,12 +1,7 @@
//! Background terminal history cells, with bounded inline input and full transcript views.
//! Background terminal interaction and process-summary history cells.
use super::*;
use crate::line_truncation::truncate_line_to_width;
use crate::width::display_width;
use crate::wrapping::word_wrap_lines;
const INPUT_PREVIEW_ROWS: usize = 12;
const INPUT_PREVIEW_BYTES: usize = 64 * 1024;
#[derive(Debug)]
pub(crate) struct UnifiedExecInteractionCell {
@@ -21,8 +16,10 @@ impl UnifiedExecInteractionCell {
stdin,
}
}
}
fn render_lines(&self, width: u16, max_input_rows: usize) -> Vec<Line<'static>> {
impl HistoryCell for UnifiedExecInteractionCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
@@ -50,62 +47,21 @@ impl UnifiedExecInteractionCell {
return out;
}
// Limit work before wrapping, including payloads with no newlines or visible width.
let input = if max_input_rows == usize::MAX {
self.stdin.as_str()
} else {
&self.stdin[..self.stdin.floor_char_boundary(INPUT_PREVIEW_BYTES)]
};
let input_lines = input.lines().take(max_input_rows.saturating_add(1));
let options = RtOptions::new(wrap_width)
.initial_indent(Line::from(" └ ".dim()))
.subsequent_indent(Line::from(" ".dim()));
let input_lines: Vec<Line<'static>> = self
.stdin
.lines()
.map(|line| Line::from(line.to_string()))
.collect();
let mut remaining_rows = max_input_rows;
let mut omitted = input.len() < self.stdin.len();
for line in adaptive_wrap_lines(input_lines, options) {
if max_input_rows != usize::MAX {
let rows = Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.line_count(width);
if rows > remaining_rows {
// Keep fitting URLs intact; shorten only the overflowing final line.
out.extend(
word_wrap_lines([line], wrap_width)
.into_iter()
.take(remaining_rows)
// Control sequences differ in textwrap and ratatui width.
.map(|line| truncate_line_to_width(line, wrap_width)),
);
omitted = true;
break;
}
remaining_rows -= rows;
}
out.push(line);
}
if omitted {
out.push(
format!(
" … Input preview limited ({}).",
crate::ui_consts::TRANSCRIPT_HINT
)
.dim()
.into(),
);
}
let input_wrapped = adaptive_wrap_lines(
input_lines,
RtOptions::new(wrap_width)
.initial_indent(Line::from(" └ ".dim()))
.subsequent_indent(Line::from(" ".dim())),
);
out.extend(input_wrapped);
out
}
}
impl HistoryCell for UnifiedExecInteractionCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.render_lines(width, INPUT_PREVIEW_ROWS)
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.render_lines(width, usize::MAX)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut out = Vec::new();
@@ -140,10 +96,6 @@ impl HistoryCell for UnifiedExecInteractionCell {
}
}
#[cfg(test)]
#[path = "exec_tests.rs"]
mod tests;
pub(crate) fn new_unified_exec_interaction(
command_display: Option<String>,
stdin: String,
-139
View File
@@ -1,139 +0,0 @@
//! Regression coverage for bounded background-terminal input previews.
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn unified_exec_input_preview_limits_wrapped_payload() {
let input = "abcdefghijklmnopqrstuvwxyz0123456789".repeat(/*n*/ 2_000);
let cell = new_unified_exec_interaction(Some("cat".to_string()), input.clone());
let preview = cell.display_lines(/*width*/ 40);
insta::assert_snapshot!(
preview.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n"),
@"
↳ Interacted with background terminal ·
cat
└ abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
abcdefghijklmnopqrstuvwxyz0123456789
… Input preview limited (ctrl + t to view transcript).
",
);
assert_eq!(
cell.raw_lines(),
vec![
Line::from("Interacted with background terminal: cat"),
Line::from(input.clone()),
],
);
let transcript = cell.transcript_lines(/*width*/ 40);
assert_eq!(
transcript[2..],
adaptive_wrap_lines(
input.lines(),
RtOptions::new(/*width*/ 40)
.initial_indent(Line::from(" └ ".dim()))
.subsequent_indent(Line::from(" ".dim())),
),
);
}
#[test]
fn unified_exec_input_preview_preserves_fitting_lines() {
for input in [
"line\n".repeat(/*n*/ 20),
format!(
"prefixprefixprefix https://example.test/a-b\n{}",
"line\n".repeat(/*n*/ 20)
),
] {
let cell = new_unified_exec_interaction(/*command_display*/ None, input);
let mut expected = cell.transcript_lines(/*width*/ 40);
expected.truncate(/*len*/ 13);
expected.push(
" … Input preview limited (ctrl + t to view transcript)."
.dim()
.into(),
);
assert_eq!(cell.display_lines(/*width*/ 40), expected);
}
}
#[test]
fn unified_exec_input_preview_respects_viewport_rows() {
for width in [1, 2, 4, 5, 8, 40, 80] {
for input in [
"a".repeat(/*n*/ 4_000),
format!("https://example.com/{}", "a".repeat(/*n*/ 4_000)),
"界👩‍💻e\u{301}\t ".repeat(/*n*/ 1_000),
"\x1b[A".repeat(/*n*/ 1_000),
] {
let cell = new_unified_exec_interaction(/*command_display*/ None, input);
let preview = cell.display_lines(width);
let header =
new_unified_exec_interaction(/*command_display*/ None, "x".to_string())
.display_lines(width);
let content = &preview[header.len() - 1..preview.len() - 1];
assert!(
Paragraph::new(content.to_vec())
.wrap(Wrap { trim: false })
.line_count(width)
<= INPUT_PREVIEW_ROWS,
"input preview exceeds row budget at width {width}",
);
}
}
}
#[test]
fn unified_exec_input_preview_bounds_zero_width_payload() {
let input = "\u{301}".repeat(/*n*/ 100_000);
let cell = new_unified_exec_interaction(/*command_display*/ None, input.clone());
let preview = cell.display_lines(/*width*/ 80);
assert!(
preview
.last()
.unwrap()
.to_string()
.contains("Input preview limited")
);
assert!(
preview
.iter()
.map(|line| line.to_string().len())
.sum::<usize>()
< 66_000
);
assert_eq!(
cell.raw_lines(),
vec![
Line::from("Interacted with background terminal"),
Line::from(input)
],
);
}
#[test]
fn unified_exec_input_preview_keeps_short_and_exact_budget_input() {
for input in [
"".to_string(),
"ls\npwd".to_string(),
"x\n".repeat(/*n*/ 12),
] {
let cell = new_unified_exec_interaction(Some("cat".to_string()), input);
assert_eq!(
cell.display_lines(/*width*/ 80),
cell.transcript_lines(/*width*/ 80)
);
assert_eq!(cell.display_lines(/*width*/ 0), Vec::<Line<'static>>::new());
}
}
-5
View File
@@ -1,7 +1,6 @@
//! Patch summaries and image-tool transcript helpers.
use super::*;
use crate::diff_render::create_diff_preview;
use codex_utils_path_uri::LegacyAppPathString;
#[derive(Debug)]
@@ -12,10 +11,6 @@ pub(crate) struct PatchHistoryCell {
impl HistoryCell for PatchHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
create_diff_preview(&self.changes, &self.cwd, width as usize)
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
create_diff_summary(&self.changes, &self.cwd, width as usize)
}
+23 -248
View File
@@ -49,244 +49,6 @@ fn test_cwd() -> PathBuf {
std::env::temp_dir()
}
#[test]
fn patch_preview_shows_minified_prefix_but_preserves_full_views() {
let changes = HashMap::from([(
PathBuf::from("metrics.json"),
FileChange::Add {
content: format!("[{}0]", "123,".repeat(/*n*/ 10_000)),
},
)]);
let cwd = test_cwd();
let cell = new_patch_event(changes.clone(), &cwd);
insta::assert_snapshot!(render_lines(&cell.display_lines(/*width*/ 80)).join("\n"), @"
• Added metrics.json (+1 -0)
1 +[123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,
123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,1
23,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,12
3,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123
,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,
123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,1
23,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,12
3,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123
,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,
123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,1
23,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,12
3,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123,123
… Diff preview limited (ctrl + t to view transcript).
");
assert_eq!(
cell.transcript_lines(/*width*/ 80),
create_diff_summary(&changes, &cwd, /*wrap_cols*/ 80),
);
assert_eq!(
cell.raw_lines(),
plain_lines(create_diff_summary(&changes, &cwd, RAW_DIFF_SUMMARY_WIDTH)),
);
}
#[test]
fn patch_preview_limits_add_delete_and_update_content() {
for content in [
"value\n".repeat(/*n*/ 20),
format!("short\n{}\ntail\n", "界123\t".repeat(/*n*/ 100)),
] {
for change in [
FileChange::Add {
content: content.clone(),
},
FileChange::Delete {
content: content.clone(),
},
FileChange::Update {
unified_diff: diffy::create_patch("", &content).to_string(),
move_path: Some(PathBuf::from("renamed.txt")),
},
FileChange::Update {
unified_diff: diffy::create_patch(&content, "").to_string(),
move_path: None,
},
FileChange::Update {
unified_diff: diffy::DiffOptions::new()
.set_context_len(/*context_len*/ 20)
.create_patch(&content, &format!("{content}new\n"))
.to_string(),
move_path: None,
},
] {
let changes = HashMap::from([(PathBuf::from("values.txt"), change)]);
let cwd = test_cwd();
let cell = new_patch_event(changes.clone(), &cwd);
let full = create_diff_summary(&changes, &cwd, /*wrap_cols*/ 80);
let mut expected = full[..13].to_vec();
expected.push(
" … Diff preview limited (ctrl + t to view transcript)."
.dim()
.into(),
);
assert_eq!(cell.display_lines(/*width*/ 80), expected);
assert_eq!(cell.transcript_lines(/*width*/ 80), full);
}
}
}
#[test]
fn patch_preview_shares_content_budget_and_keeps_file_summaries() {
let changes = HashMap::from([
(
PathBuf::from("a.txt"),
FileChange::Add {
content: "a\n".repeat(/*n*/ 11) + &"©️ ".repeat(/*n*/ 36),
},
),
(
PathBuf::from("b.txt"),
FileChange::Add {
content: "b\n".repeat(/*n*/ 10),
},
),
(
PathBuf::from("c.txt"),
FileChange::Add {
content: "c\n".into(),
},
),
]);
let cell = new_patch_event(changes, &test_cwd());
insta::assert_snapshot!(render_lines(&cell.display_lines(/*width*/ 80)).join("\n"), @"
• Edited 3 files (+23 -0)
└ a.txt (+12 -0)
1 +a
2 +a
3 +a
4 +a
5 +a
6 +a
7 +a
8 +a
9 +a
10 +a
11 +a
12 +©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️ ©️
└ b.txt (+10 -0)
└ c.txt (+1 -0)
… Diff preview limited (ctrl + t to view transcript).
");
}
#[test]
fn patch_preview_budgets_wrapped_rows_at_narrow_widths() {
for content in [
"0123456789".repeat(/*n*/ 1_000),
"ガ".repeat(/*n*/ 1_000),
"0123456789\n".repeat(/*n*/ 20),
format!("{}\n", "界\tvalue".repeat(/*n*/ 100)).repeat(/*n*/ 20),
format!("{}\n", "\u{a9}\u{fe0f}".repeat(/*n*/ 50)).repeat(/*n*/ 20),
] {
let changes = HashMap::from([(PathBuf::from("values.txt"), FileChange::Add { content })]);
let cell = new_patch_event(changes, &test_cwd());
for width in [8, 16, 40, 80] {
let lines = cell.display_lines(width);
let body = lines[1..lines.len() - 1].to_vec();
let rows = Paragraph::new(body)
.wrap(Wrap { trim: false })
.line_count(width);
assert_eq!(rows, 12);
}
}
}
#[test]
fn patch_preview_preserves_small_diffs_and_exact_budget() {
for count in [1, 12] {
let mut changes = HashMap::from([(
PathBuf::from("small.rs"),
FileChange::Add {
content: "let value = 1;\n".repeat(count),
},
)]);
changes.extend([
(
PathBuf::from("z-empty"),
FileChange::Add {
content: String::new(),
},
),
(
PathBuf::from("z-renamed"),
FileChange::Update {
unified_diff: String::new(),
move_path: Some(PathBuf::from("renamed")),
},
),
]);
let cwd = test_cwd();
let cell = new_patch_event(changes.clone(), &cwd);
assert_eq!(
cell.display_lines(/*width*/ 80),
create_diff_summary(&changes, &cwd, /*wrap_cols*/ 80),
);
}
}
#[test]
fn patch_preview_caps_zero_width_source_before_later_content() {
let make_changes: [fn(String) -> FileChange; 3] = [
|content| FileChange::Add { content },
|content| FileChange::Delete { content },
|content| FileChange::Update {
unified_diff: diffy::create_patch("", &content).to_string(),
move_path: None,
},
];
let cwd = test_cwd();
let content = format!("head\nxy{}\ntail\n", "\u{301}".repeat(/*n*/ 40_000));
let mut summaries = Vec::new();
for make_change in make_changes {
let changes = HashMap::from([
(PathBuf::from("a.txt"), make_change(content.clone())),
(PathBuf::from("b.txt"), make_change("later\n".into())),
]);
let cell = new_patch_event(changes.clone(), &cwd);
let preview = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
// The next two-byte combining mark straddles the 64 KiB boundary.
assert_eq!(preview.matches('\u{301}').count(), 32_764);
summaries.push(preview.replace('\u{301}', ""));
assert_eq!(
cell.transcript_lines(/*width*/ 80),
create_diff_summary(&changes, &cwd, /*wrap_cols*/ 80)
);
}
insta::assert_snapshot!(summaries.join("\n\n"), @"
• Edited 2 files (+4 -0)
└ a.txt (+3 -0)
1 +head
2 +xy
└ b.txt (+1 -0)
… Diff preview limited (ctrl + t to view transcript).
• Edited 2 files (+0 -4)
└ a.txt (+0 -3)
1 -head
2 -xy
└ b.txt (+0 -1)
… Diff preview limited (ctrl + t to view transcript).
• Edited 2 files (+4 -0)
└ a.txt (+3 -0)
1 +head
2 +xy
└ b.txt (+1 -0)
… Diff preview limited (ctrl + t to view transcript).
");
}
#[test]
fn streaming_agent_tail_blank_line_uses_one_viewport_row() {
let cell = StreamingAgentTailCell::new(
@@ -803,16 +565,29 @@ fn session_configured_event(model: &str) -> ThreadSessionState {
#[test]
fn unified_exec_interaction_cell_renders_input() {
let cell = new_unified_exec_interaction(Some("echo hello".to_string()), "ls\npwd".to_string());
let lines = render_transcript(&cell);
assert_eq!(
lines,
vec![
"↳ Interacted with background terminal · echo hello",
" └ ls",
" pwd",
],
);
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));
insta::assert_snapshot!(lines.join("\n"), @"
↳ Interacted with background terminal · cat
└ line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
line 10
line 11
line 12
line 13
line 14
line 15
line 16
");
}
#[test]