From 35e21028080ccbda9de04d2239b0a0b2336d868f Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Thu, 22 Oct 2026 22:30:26 +0000 Subject: [PATCH] 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 --- ...ipt__tests__browsing__browsing_footer.snap | 8 +- ..._transcript__tests__prompt_navigation.snap | 8 +- ..._owned_prompt_revert_retained_history.snap | 5 +- ..._composer__transcript_flag_off_viewer.snap | 4 +- ...er_prompts_share_one_background_block.snap | 35 ++++ codex-rs/tui/src/transcript_view/layout.rs | 186 +++++++++++++----- .../tui/src/transcript_view/layout_tests.rs | 3 + .../transcript_view/search_presentation.rs | 7 +- .../tui/src/transcript_view/search_tests.rs | 81 ++++++++ ..._to_visible_prompts_and_short_screens.snap | 3 +- codex-rs/tui/src/transcript_view/text.rs | 21 ++ codex-rs/tui/src/transcript_view_tests.rs | 119 +++++++++++ 12 files changed, 413 insertions(+), 67 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__consecutive_user_prompts_share_one_background_block.snap diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__browsing__browsing_footer.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__browsing__browsing_footer.snap index d636eb2f1f..4309eabd51 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__browsing__browsing_footer.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__browsing__browsing_footer.snap @@ -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 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__prompt_navigation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__prompt_navigation.snap index 2f2f6be0a3..22f51248e0 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__prompt_navigation.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__prompt_navigation.snap @@ -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 diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__owned_prompt_revert_retained_history.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__owned_prompt_revert_retained_history.snap index a9c65b5c3f..7f03e8d849 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__owned_prompt_revert_retained_history.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__owned_prompt_revert_retained_history.snap @@ -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] diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap index dba00b21a7..f80ec32568 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__consecutive_user_prompts_share_one_background_block.snap b/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__consecutive_user_prompts_share_one_background_block.snap new file mode 100644 index 0000000000..99b437e868 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__transcript_view__tests__consecutive_user_prompts_share_one_background_block.snap @@ -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, + ] +} diff --git a/codex-rs/tui/src/transcript_view/layout.rs b/codex-rs/tui/src/transcript_view/layout.rs index f91e3d0225..dd81a38047 100644 --- a/codex-rs/tui/src/transcript_view/layout.rs +++ b/codex-rs/tui/src/transcript_view/layout.rs @@ -58,6 +58,48 @@ pub(super) fn activity_layout( } } +fn cell_content_layout( + cell: &dyn HistoryCell, + width: u16, + mode: HistoryRenderMode, + presentation: CellPresentation, + shortcut: Option, +) -> 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::() +} + #[derive(Default)] pub(super) struct LayoutCache { entries: Vec, @@ -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], @@ -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, + 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(); } diff --git a/codex-rs/tui/src/transcript_view/layout_tests.rs b/codex-rs/tui/src/transcript_view/layout_tests.rs index 366a978f07..2cb45fbc8d 100644 --- a/codex-rs/tui/src/transcript_view/layout_tests.rs +++ b/codex-rs/tui/src/transcript_view/layout_tests.rs @@ -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, diff --git a/codex-rs/tui/src/transcript_view/search_presentation.rs b/codex-rs/tui/src/transcript_view/search_presentation.rs index 51bd8a9527..fc8963bce3 100644 --- a/codex-rs/tui/src/transcript_view/search_presentation.rs +++ b/codex-rs/tui/src/transcript_view/search_presentation.rs @@ -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) diff --git a/codex-rs/tui/src/transcript_view/search_tests.rs b/codex-rs/tui/src/transcript_view/search_tests.rs index dd427d3291..1ddcb869b9 100644 --- a/codex-rs/tui/src/transcript_view/search_tests.rs +++ b/codex-rs/tui/src/transcript_view/search_tests.rs @@ -30,6 +30,87 @@ fn finish_scan(view: &mut TranscriptView, cells: &[Arc]) { 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 + }; + let cells: Vec> = 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::>(); + 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::>() + .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![ diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__prompt_header__tests__prompt_header_tracks_reading_and_yields_to_visible_prompts_and_short_screens.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__prompt_header__tests__prompt_header_tracks_reading_and_yields_to_visible_prompts_and_short_screens.snap index 12a29bfec8..d5d4045501 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__prompt_header__tests__prompt_header_tracks_reading_and_yields_to_visible_prompts_and_short_screens.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__prompt_header__tests__prompt_header_tracks_reading_and_yields_to_visible_prompts_and_short_screens.snap @@ -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 diff --git a/codex-rs/tui/src/transcript_view/text.rs b/codex-rs/tui/src/transcript_view/text.rs index e2bc38ddd9..4356e233e0 100644 --- a/codex-rs/tui/src/transcript_view/text.rs +++ b/codex-rs/tui/src/transcript_view/text.rs @@ -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, } @@ -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, } } diff --git a/codex-rs/tui/src/transcript_view_tests.rs b/codex-rs/tui/src/transcript_view_tests.rs index 92b3eb1caf..eaaa366619 100644 --- a/codex-rs/tui/src/transcript_view_tests.rs +++ b/codex-rs/tui/src/transcript_view_tests.rs @@ -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) -> Arc { }) } +fn prompt(text: impl Into) -> Arc { + Arc::new(new_user_prompt( + text.into(), + Vec::new(), + Vec::new(), + Vec::new(), + )) +} + pub(super) fn render( view: &mut TranscriptView, cells: &[Arc], @@ -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> = 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> = 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> = 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(