Group consecutive transcript prompts into one background block (#56280)

## Why

Consecutive user prompts in the rich transcript have extra blank spacing that breaks up their shared background.

## What changed

- Join adjacent visible user prompts into one background block, skipping hidden entries when determining adjacency.
- Keep prompts separated when intervening content becomes visible through detailed mode or search, and restore grouping when it is hidden again.
- Preserve selectable source offsets and joined spacing when text is rewrapped, and reuse cached layouts when checking adjacency.

## Testing

Add regression tests for prompt grouping, layout cache reuse, source offsets across rewrapping, detailed reasoning, and search-driven visibility. Update transcript snapshots to reflect the tighter spacing.

GitOrigin-RevId: faca8001244b0ac883d9d670864c8f7856ac2bad
This commit is contained in:
Benjamin Carlsson
2026-10-22 22:39:41 +00:00
committed by copyberry
parent d124fe9de1
commit 35e2102808
12 changed files with 413 additions and 67 deletions
@@ -4,20 +4,20 @@ expression: "snapshots.join(\"\\n\\n\")"
---
80 columns
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
› first prompt
› second prompt
Browsing · ↑↓/jk scroll · ←→/hl prompts · ⌃t details · ↵ rewind · esc back
32 columns
/ T R A N S C R I P T / / / / /
› first prompt
› second prompt
Browsing · ↵ rewind · esc back
@@ -5,15 +5,15 @@ expression: "normalize_snapshot_paths(buffer_text(crate::custom_terminal::test_s
› first question
› second question
› third question
› Ask Codex to do anything
gpt-test default · /tmp/project
@@ -1,8 +1,9 @@
---
source: tui/src/app/tests.rs
assertion_line: 8342
expression: transcript
---
/init - create an AGENTS.md file with instructions for Codex
/status - show current session configuration
/permissions - choose what Codex is allowed to do
/model - choose what model and reasoning effort to use
/review - review any changes and find issues
@@ -10,8 +11,6 @@ expression: transcript
› retained prompt
[Image #1]
[Image #2]
@@ -6,10 +6,10 @@ expression: buffer_text(&buffer)
› first
› second
⌃space select
↑/↓ to scroll · pgup/pgdn to page · home/end to jump
q close · f3 find · esc browse prompts
@@ -0,0 +1,35 @@
---
source: tui/src/transcript_view_tests.rs
expression: "format!(\"{buffer:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 32, height: 12 },
content: [
" ",
"› First steering message ",
" ",
"› Second steering message ",
" ",
" ",
"• Assistant response ",
" ",
" ",
"› Later user message ",
" ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 1, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: NONE,
x: 0, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 2, y: 6, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 8, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 9, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: NONE,
x: 0, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
+135 -51
View File
@@ -58,6 +58,48 @@ pub(super) fn activity_layout(
}
}
fn cell_content_layout(
cell: &dyn HistoryCell,
width: u16,
mode: HistoryRenderMode,
presentation: CellPresentation,
shortcut: Option<ShortcutHint>,
) -> TextLayout {
let mut layout = if presentation.disclosure {
activity_layout(
ActivityTranscriptLines {
activity: if presentation.expanded {
cell.expanded_hyperlink_lines(width)
} else {
cell.compact_hyperlink_lines(width)
},
auxiliary: Vec::new(),
disclosure: cell.activity_disclosure(width),
},
width,
presentation.expanded,
shortcut,
)
} else {
TextLayout::new(
crate::history_cell::fullscreen_session_lines(cell, width, presentation.detailed, mode),
width,
)
};
if let Some(protocol) = presentation.image_protocol
&& let Some(path) = cell.image_path()
{
layout.image =
crate::transcript_image::TranscriptImage::load(path.as_path(), width, protocol)
.map(Arc::new);
}
layout
}
fn is_rich_user_prompt(cell: &dyn HistoryCell, mode: HistoryRenderMode) -> bool {
mode == HistoryRenderMode::Rich && cell.as_any().is::<crate::history_cell::UserHistoryCell>()
}
#[derive(Default)]
pub(super) struct LayoutCache {
entries: Vec<CachedLayout>,
@@ -86,6 +128,7 @@ struct CachedLayout {
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) struct CellPresentation {
separated: bool,
shares_prompt_background: bool,
turn_tip_space: bool,
expanded: bool,
disclosure: bool,
@@ -94,6 +137,32 @@ pub(super) struct CellPresentation {
}
impl TranscriptView {
fn content_presentation(
&mut self,
cell: &dyn HistoryCell,
full_content: bool,
mode: HistoryRenderMode,
) -> CellPresentation {
let ids = cell.activity_ids();
let disclosure = !self.detailed && mode == HistoryRenderMode::Rich && !ids.is_empty();
let manually_expanded = self.disclosure.is_expanded(&ids);
let reveal_image = ids.is_empty() || manually_expanded || self.detailed;
if manually_expanded {
self.disclosure.expanded.extend(ids);
}
CellPresentation {
separated: false,
shares_prompt_background: false,
turn_tip_space: false,
expanded: disclosure && (full_content || manually_expanded),
disclosure,
detailed: full_content,
image_protocol: self
.image_protocol
.filter(|_| mode == HistoryRenderMode::Rich && reveal_image),
}
}
pub(super) fn layout(
&mut self,
cells: &[Arc<dyn HistoryCell>],
@@ -168,62 +237,47 @@ impl TranscriptView {
return Some(Arc::new(TextLayout::new(Vec::new(), width)));
}
let mode = self.mode;
let ids = cell.activity_ids();
let disclosure = !self.detailed && mode == HistoryRenderMode::Rich && !ids.is_empty();
let manually_expanded = self.disclosure.is_expanded(&ids);
let reveal_image = ids.is_empty() || manually_expanded || self.detailed;
let expanded = disclosure && (full_content || manually_expanded);
if manually_expanded {
self.disclosure.expanded.extend(ids);
}
let separated = index > 0 && !cell.is_stream_continuation();
let presentation = CellPresentation {
separated,
turn_tip_space: self.turn_tip_key == Some(EntryKey::cell(cell)),
expanded,
disclosure,
detailed: full_content,
image_protocol: self
.image_protocol
.filter(|_| mode == HistoryRenderMode::Rich && reveal_image),
};
let mut presentation = self.content_presentation(cell.as_ref(), full_content, mode);
let shortcut = self
.disclosure
.keymap
.primary_hint(KeymapContext::Global, "open_transcript");
let shares_prompt_background = is_rich_user_prompt(cell.as_ref(), mode)
&& (0..index)
.rev()
.find(|&previous| {
let previous = &cells[previous];
let key = EntryKey::cell(previous);
if let Some(layout) = self.search.match_layout(key).or_else(|| {
self.snapshot()
.and_then(|snapshot| snapshot.pinned.get(&key))
.cloned()
}) {
return layout.row_count() > 0;
}
let previous_presentation =
self.content_presentation(previous.as_ref(), self.detailed, mode);
self.cache
.has_visible_content(previous, width, previous_presentation, || {
cell_content_layout(
previous.as_ref(),
width,
mode,
previous_presentation,
shortcut,
)
})
})
.is_some_and(|previous| is_rich_user_prompt(cells[previous].as_ref(), mode));
presentation.separated =
index > 0 && !cell.is_stream_continuation() && !shares_prompt_background;
presentation.shares_prompt_background = shares_prompt_background;
presentation.turn_tip_space = self.turn_tip_key == Some(EntryKey::cell(cell));
Some(self.cache.get(cell, width, presentation, || {
let mut layout = if disclosure {
activity_layout(
ActivityTranscriptLines {
activity: if expanded {
cell.expanded_hyperlink_lines(width)
} else {
cell.compact_hyperlink_lines(width)
},
auxiliary: Vec::new(),
disclosure: cell.activity_disclosure(width),
},
width,
expanded,
shortcut,
)
} else {
TextLayout::new(
crate::history_cell::fullscreen_session_lines(
cell.as_ref(),
width,
full_content,
mode,
),
width,
)
};
if let Some(protocol) = presentation.image_protocol
&& let Some(path) = cell.image_path()
{
layout.image =
crate::transcript_image::TranscriptImage::load(path.as_path(), width, protocol)
.map(Arc::new);
let mut layout =
cell_content_layout(cell.as_ref(), width, mode, presentation, shortcut);
if shares_prompt_background {
layout = layout.without_leading_blank_row();
}
layout
}))
@@ -294,6 +348,36 @@ impl LayoutCache {
layout
}
fn has_visible_content(
&mut self,
cell: &Arc<dyn HistoryCell>,
width: u16,
presentation: CellPresentation,
render: impl FnOnce() -> TextLayout,
) -> bool {
let source = Arc::downgrade(cell);
let animation_tick = cell.transcript_animation_tick();
if let Some(index) = self.entries.iter().position(|entry| {
entry.width == width
&& entry.presentation.expanded == presentation.expanded
&& entry.presentation.disclosure == presentation.disclosure
&& entry.presentation.detailed == presentation.detailed
&& entry.presentation.image_protocol == presentation.image_protocol
&& entry.source.ptr_eq(&source)
&& (entry.rendered_frame == self.frame
|| (cell.has_stable_transcript_height()
&& entry.animation_tick == animation_tick))
}) {
let visible = self.entries[index].layout.row_count() > 0;
if index + 1 != self.entries.len() {
let entry = self.entries.remove(index);
self.entries.push(entry);
}
return visible;
}
self.get(cell, width, presentation, render).row_count() > 0
}
pub(super) fn clear(&mut self) {
self.entries.clear();
}
@@ -47,6 +47,7 @@ fn layouts_refresh_for_width_animation_and_mutable_frames() {
width,
CellPresentation {
separated: false,
shares_prompt_background: false,
turn_tip_space: false,
expanded: false,
disclosure: false,
@@ -104,6 +105,7 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() {
/*width*/ 20,
CellPresentation {
separated: false,
shares_prompt_background: false,
turn_tip_space: false,
expanded: false,
disclosure: false,
@@ -125,6 +127,7 @@ fn recent_entries_are_reused_and_old_entries_are_evicted() {
/*width*/ 20,
CellPresentation {
separated: false,
shares_prompt_background: false,
turn_tip_space: false,
expanded: false,
disclosure: false,
@@ -103,7 +103,12 @@ impl TranscriptView {
} else if self.detailed {
self.layout(cells, anchor.index)?
} else {
self.entry_layout(cells, anchor.index, /*full_content*/ true)?
// A candidate replaces the current match when found, so its spacing must not
// depend on an expansion that will disappear at that point.
let current = self.search.current.take();
let layout = self.entry_layout(cells, anchor.index, /*full_content*/ true);
self.search.current = current;
layout?
};
self.search.scanning_layout = Some((anchor.key, Arc::clone(&layout)));
Some(layout)
@@ -30,6 +30,87 @@ fn finish_scan(view: &mut TranscriptView, cells: &[Arc<dyn HistoryCell>]) {
panic!("search did not yield after bounded scanning frames");
}
#[test]
fn search_match_separates_user_prompts_only_while_visible() {
use crate::history_cell::ReasoningSummaryCell;
use crate::history_cell::new_user_prompt;
use std::path::Path;
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (235, 235, 235),
bg: (18, 20, 30),
},
|| {
let prompt = |text| {
Arc::new(new_user_prompt(text, Vec::new(), Vec::new(), Vec::new()))
as Arc<dyn HistoryCell>
};
let cells: Vec<Arc<dyn HistoryCell>> = vec![
prompt("first".into()),
Arc::new(ReasoningSummaryCell::new(
"Reasoning".into(),
"needle detail".into(),
Path::new("."),
/*transcript_only*/ true,
)),
prompt("needle second".into()),
];
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 30, /*height*/ 12,
);
let mut view = TranscriptView::default();
view.render(area, &mut Buffer::empty(area), &cells);
view.begin_search();
view.paste_search("needle");
finish_scan(&mut view, &cells);
assert_eq!(view.search.current.as_ref().unwrap().anchor.index, 2);
view.handle_key(
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
&cells,
);
finish_scan(&mut view, &cells);
assert_eq!(view.search.current.as_ref().unwrap().anchor.index, 1);
assert!(view.current_layout(&cells, /*index*/ 2).unwrap().separated);
view.handle_key(
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
&cells,
);
finish_scan(&mut view, &cells);
assert_eq!(view.search.current.as_ref().unwrap().anchor.index, 2);
let mut buffer = Buffer::empty(area);
view.render(area, &mut buffer, &cells);
let rendered = crate::transcript_view::tests::text(&buffer);
let rows = rendered.lines().collect::<Vec<_>>();
let first = rows.iter().position(|row| row.contains("› first")).unwrap();
let second = rows
.iter()
.position(|row| row.contains("› needle second"))
.unwrap();
let background = buffer[(area.width - 1, first as u16)].bg;
assert_ne!(background, ratatui::style::Color::Reset);
let joined = (first..=second)
.map(|row| {
let marker = if buffer[(area.width - 1, row as u16)].bg == background {
'#'
} else {
'.'
};
format!("{marker}|{}|", rows[row])
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(joined, @r"
#|› first|
#||
#|› needle second|
");
},
);
}
#[test]
fn find_keeps_context_and_allows_reading_without_losing_the_query() {
let cells = vec![
@@ -1,6 +1,5 @@
---
source: tui/src/transcript_view/prompt_header_tests.rs
assertion_line: 68
expression: "snapshots.join(\"\\n\\n\")"
---
Latest
@@ -25,11 +24,11 @@ three
four
Prompt visible
› First question
one
two
Short screen
eight
+21
View File
@@ -49,6 +49,7 @@ pub(super) struct TextLayout {
width: u16,
pub(super) separated: bool,
pub(super) disclosure: bool,
leading_blank_hidden: bool,
disclosure_control: Option<DisclosureControl>,
}
@@ -139,6 +140,9 @@ impl TextLayout {
layout.with_disclosure_control_at(control.label.clone(), control.source_offset);
}
layout.image = self.image.clone();
if self.leading_blank_hidden {
layout = layout.without_leading_blank_row();
}
if self.separated {
layout.with_leading_separator()
} else {
@@ -207,6 +211,22 @@ impl TextLayout {
self
}
/// Hide one leading blank row without changing selectable source offsets.
pub(super) fn without_leading_blank_row(mut self) -> Self {
if self
.rows
.first()
.is_some_and(|row| line_width(&row.line.line) == 0)
{
self.rows.remove(/*index*/ 0);
self.leading_blank_hidden = true;
if let Some(control) = &mut self.disclosure_control {
control.row = control.row.saturating_sub(/*rhs*/ 1);
}
}
self
}
/// Reserve one presentation-only row before existing spacing and source text.
pub(super) fn with_leading_spacer(mut self) -> Self {
if !self.rows.is_empty() {
@@ -531,6 +551,7 @@ impl TextLayout {
width,
separated: false,
disclosure: false,
leading_blank_hidden: false,
disclosure_control: None,
}
}
+119
View File
@@ -1,6 +1,7 @@
//! Viewport behavior and cache lifetime across scrolling, selection, and redraws.
use super::*;
use crate::history_cell::new_user_prompt;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -38,6 +39,15 @@ pub(super) fn cell(text: impl Into<String>) -> Arc<dyn HistoryCell> {
})
}
fn prompt(text: impl Into<String>) -> Arc<dyn HistoryCell> {
Arc::new(new_user_prompt(
text.into(),
Vec::new(),
Vec::new(),
Vec::new(),
))
}
pub(super) fn render(
view: &mut TranscriptView,
cells: &[Arc<dyn HistoryCell>],
@@ -63,6 +73,115 @@ pub(super) fn text(buffer: &Buffer) -> String {
.join("\n")
}
#[test]
fn consecutive_user_prompts_share_one_background_block() {
use crate::history_cell::AgentMessageCell;
use crate::history_cell::new_spoken_user_prompt;
use codex_protocol::user_input::TextElement;
let cells: Vec<Arc<dyn HistoryCell>> = vec![
prompt("First steering message"),
prompt("Second steering message"),
Arc::new(AgentMessageCell::new(
vec!["Assistant response".into()],
/*is_first_line*/ true,
)),
Arc::new(new_spoken_user_prompt(String::new())),
Arc::new(new_user_prompt(
" ".into(),
vec![TextElement::new((0..3).into(), Some(" ".into()))],
Vec::new(),
Vec::new(),
)),
prompt("Later user message"),
];
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (235, 235, 235),
bg: (18, 20, 30),
},
|| {
let mut view = TranscriptView::default();
let buffer = render(&mut view, &cells, /*width*/ 32, /*height*/ 12);
insta::assert_snapshot!(
"consecutive_user_prompts_share_one_background_block",
format!("{buffer:?}")
);
},
);
}
#[test]
fn prompt_adjacency_uses_cached_retained_presentations() {
let renders = Arc::new(AtomicUsize::new(0));
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(TestCell {
text: "response".into(),
renders: Arc::clone(&renders),
}),
prompt("steering"),
];
let mut view = TranscriptView::default();
view.current_layout(&cells, /*index*/ 1);
view.current_layout(&cells, /*index*/ 1);
assert_eq!(renders.load(Ordering::Relaxed), 1);
}
#[test]
fn joined_prompt_preserves_source_offsets_across_rewrap() {
let cells = vec![prompt("older"), prompt("one\n\ntwo")];
let mut view = TranscriptView::default();
let unjoined = [Arc::clone(&cells[1])];
let source = view
.current_layout(&unjoined, /*index*/ 0)
.unwrap()
.text()
.to_owned();
let joined = view.current_layout(&cells, /*index*/ 1).unwrap();
assert_eq!(
(joined.text(), joined.rewrap(/*width*/ 8).text()),
(source.as_str(), source.as_str())
);
}
#[test]
fn detailed_only_reasoning_separates_user_prompts() {
use crate::history_cell::ReasoningSummaryCell;
use std::path::Path;
let cells: Vec<Arc<dyn HistoryCell>> = vec![
prompt("first"),
Arc::new(ReasoningSummaryCell::new(
"Reasoning".into(),
"visible detail".into(),
Path::new("."),
/*transcript_only*/ true,
)),
prompt("second"),
];
let mut detailed = TranscriptView {
detailed: true,
..TranscriptView::default()
};
assert!(
detailed
.current_layout(&cells, /*index*/ 2)
.unwrap()
.separated
);
let mut compact = TranscriptView::default();
assert!(
!compact
.entry_layout(&cells, /*index*/ 2, /*full_content*/ true)
.unwrap()
.separated
);
}
#[test]
fn page_keys_follow_pager_scroll_bindings() {
let cells = [cell(