From 0e5d72cc57dad5c78c9a6120dc53faebbc599f63 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:18:48 -0700 Subject: [PATCH] tui: bring the transcript closer to display mode (#4848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit before Screenshot 2025-10-06 at 3 06 52 PM after Screenshot 2025-10-06 at 3 07 02 PM --- codex-rs/tui/src/app_backtrack.rs | 3 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 3 +- .../src/bottom_pane/list_selection_view.rs | 3 +- codex-rs/tui/src/custom_terminal.rs | 34 ++-- codex-rs/tui/src/diff_render.rs | 2 +- codex-rs/tui/src/exec_cell/render.rs | 28 +-- codex-rs/tui/src/history_cell.rs | 168 ++++++++---------- codex-rs/tui/src/pager_overlay.rs | 114 +++++++----- codex-rs/tui/src/render/mod.rs | 10 +- codex-rs/tui/src/render/renderable.rs | 24 ++- codex-rs/tui/src/session_log.rs | 2 +- codex-rs/tui/src/streaming/controller.rs | 22 +-- codex-rs/tui/src/style.rs | 7 +- 13 files changed, 233 insertions(+), 187 deletions(-) diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b5c1300b..5528fa7b 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -134,8 +134,9 @@ impl App { /// Useful when switching sessions to ensure prior history remains visible. pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { if !self.transcript_cells.is_empty() { + let width = tui.terminal.last_known_screen_size.width; for cell in &self.transcript_cells { - tui.insert_history_lines(cell.transcript_lines()); + tui.insert_history_lines(cell.display_lines(width)); } } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ad977072..2574ded4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -38,7 +38,6 @@ use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; use crate::style::user_message_style; -use crate::terminal_palette; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; @@ -1533,7 +1532,7 @@ impl WidgetRef for ChatComposer { } } } - let style = user_message_style(terminal_palette::default_bg()); + let style = user_message_style(); let mut block_rect = composer_rect; block_rect.y = composer_rect.y.saturating_sub(1); block_rect.height = composer_rect.height.saturating_add(1); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 9f0ce3df..238f74d6 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -20,7 +20,6 @@ use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; -use crate::terminal_palette; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -350,7 +349,7 @@ impl Renderable for ListSelectionView { .areas(area); Block::default() - .style(user_message_style(terminal_palette::default_bg())) + .style(user_message_style()) .render(content_area, buf); let header_height = self diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index aa79e980..bbd89006 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -120,6 +120,8 @@ where /// Last known position of the cursor. Used to find the new area when the viewport is inlined /// and the terminal resized. pub last_known_cursor_pos: Position, + + use_custom_flush: bool, } impl Drop for Terminal @@ -158,6 +160,7 @@ where viewport_area: Rect::new(0, cursor_pos.y, 0, 0), last_known_screen_size: screen_size, last_known_cursor_pos: cursor_pos, + use_custom_flush: true, }) } @@ -190,15 +193,24 @@ where pub fn flush(&mut self) -> io::Result<()> { let previous_buffer = &self.buffers[1 - self.current]; let current_buffer = &self.buffers[self.current]; - let updates = diff_buffers(previous_buffer, current_buffer); - if let Some(DrawCommand::Put { x, y, .. }) = updates - .iter() - .rev() - .find(|cmd| matches!(cmd, DrawCommand::Put { .. })) - { - self.last_known_cursor_pos = Position { x: *x, y: *y }; + + if self.use_custom_flush { + let updates = diff_buffers(previous_buffer, current_buffer); + if let Some(DrawCommand::Put { x, y, .. }) = updates + .iter() + .rev() + .find(|cmd| matches!(cmd, DrawCommand::Put { .. })) + { + self.last_known_cursor_pos = Position { x: *x, y: *y }; + } + draw(&mut self.backend, updates.into_iter()) + } else { + let updates = previous_buffer.diff(current_buffer); + if let Some((x, y, _)) = updates.last() { + self.last_known_cursor_pos = Position { x: *x, y: *y }; + } + self.backend.draw(updates.into_iter()) } - draw(&mut self.backend, updates.into_iter()) } /// Updates the Terminal so that internal buffers match the requested area. @@ -408,11 +420,13 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { let x = row .iter() - .rposition(|cell| cell.symbol() != " " || cell.bg != bg) + .rposition(|cell| { + cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() + }) .unwrap_or(0); last_nonblank_column[y as usize] = x as u16; - let (x_abs, y_abs) = a.pos_of(row_start + x + 1); if x < (a.area.width as usize).saturating_sub(1) { + let (x_abs, y_abs) = a.pos_of(row_start + x + 1); updates.push(DrawCommand::ClearToEnd { x: x_abs, y: y_abs, diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 676b0559..a4b7c87f 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -67,7 +67,7 @@ impl From for Box { rows.push(Box::new(path)); rows.push(Box::new(RtLine::from(""))); rows.push(Box::new(InsetRenderable::new( - Box::new(row.change), + row.change, Insets::tlbr(0, 2, 0, 0), ))); } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 51824ddc..a3cc8cac 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -11,6 +11,7 @@ use crate::render::line_utils::push_owned_lines; use crate::shimmer::shimmer_spans; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; +use crate::wrapping::word_wrap_lines; use codex_ansi_escape::ansi_escape_line; use codex_common::elapsed::format_duration; use codex_protocol::parse_command::ParsedCommand; @@ -138,17 +139,25 @@ impl HistoryCell for ExecCell { } } - fn transcript_lines(&self) -> Vec> { + fn desired_transcript_height(&self, width: u16) -> u16 { + self.transcript_lines(width).len() as u16 + } + + fn transcript_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = vec![]; - for call in self.iter_calls() { - let cmd_display = strip_bash_lc_and_escape(&call.command); - for (i, part) in cmd_display.lines().enumerate() { - if i == 0 { - lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); - } else { - lines.push(vec![" ".into(), part.to_string().into()].into()); - } + 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 = word_wrap_lines( + &highlighted_script, + RtOptions::new(width as usize) + .initial_indent("$ ".magenta().into()) + .subsequent_indent(" ".into()), + ); + lines.extend(cmd_display); if let Some(output) = call.output.as_ref() { lines.extend(output.formatted_output.lines().map(ansi_escape_line)); @@ -167,7 +176,6 @@ impl HistoryCell for ExecCell { result.push_span(format!(" • {duration}").dim()); lines.push(result); } - lines.push("".into()); } lines } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 61640c33..7c4d18e1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -11,7 +11,6 @@ use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::style::user_message_style; -use crate::terminal_palette::default_bg; use crate::text_formatting::format_and_truncate_tool_result; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::wrapping::RtOptions; @@ -56,10 +55,6 @@ use unicode_width::UnicodeWidthStr; pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { fn display_lines(&self, width: u16) -> Vec>; - fn transcript_lines(&self) -> Vec> { - self.display_lines(u16::MAX) - } - fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.display_lines(width))) .wrap(Wrap { trim: false }) @@ -68,6 +63,29 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { .unwrap_or(0) } + fn transcript_lines(&self, width: u16) -> Vec> { + self.display_lines(width) + } + + fn desired_transcript_height(&self, width: u16) -> u16 { + let lines = self.transcript_lines(width); + // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. + if let [line] = &lines[..] + && line + .spans + .iter() + .all(|s| s.content.chars().all(char::is_whitespace)) + { + return 1; + } + + Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(0) + } + fn is_stream_continuation(&self) -> bool { false } @@ -92,12 +110,10 @@ impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); - // Use ratatui-aware word wrapping and prefixing to avoid lifetime issues. - let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); // account for the ▌ prefix and trailing space + let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); - let style = user_message_style(default_bg()); + let style = user_message_style(); - // Use our ratatui wrapping helpers for correct styling and lifetimes. let wrapped = word_wrap_lines( &self .message @@ -113,13 +129,6 @@ impl HistoryCell for UserHistoryCell { lines.push(Line::from("").style(style)); lines } - - fn transcript_lines(&self) -> Vec> { - let mut lines: Vec> = Vec::new(); - lines.push("user".cyan().bold().into()); - lines.extend(self.message.lines().map(|l| l.to_string().into())); - lines - } } #[derive(Debug)] @@ -127,6 +136,7 @@ pub(crate) struct ReasoningSummaryCell { _header: String, content: String, citation_context: MarkdownCitationContext, + transcript_only: bool, } impl ReasoningSummaryCell { @@ -134,17 +144,17 @@ impl ReasoningSummaryCell { header: String, content: String, citation_context: MarkdownCitationContext, + transcript_only: bool, ) -> Self { Self { _header: header, content, citation_context, + transcript_only, } } -} -impl HistoryCell for ReasoningSummaryCell { - fn display_lines(&self, width: u16) -> Vec> { + fn lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); append_markdown( &self.content, @@ -152,7 +162,7 @@ impl HistoryCell for ReasoningSummaryCell { &mut lines, self.citation_context.clone(), ); - let summary_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); + let summary_style = Style::default().dim().italic(); let summary_lines = lines .into_iter() .map(|mut line| { @@ -172,19 +182,31 @@ impl HistoryCell for ReasoningSummaryCell { .subsequent_indent(" ".into()), ) } +} - fn transcript_lines(&self) -> Vec> { - let mut out: Vec> = Vec::new(); - out.push("thinking".magenta().bold().into()); - let mut lines = Vec::new(); - append_markdown( - &self.content, - None, - &mut lines, - self.citation_context.clone(), - ); - out.extend(lines); - out +impl HistoryCell for ReasoningSummaryCell { + fn display_lines(&self, width: u16) -> Vec> { + if self.transcript_only { + Vec::new() + } else { + self.lines(width) + } + } + + fn desired_height(&self, width: u16) -> u16 { + if self.transcript_only { + 0 + } else { + self.lines(width).len() as u16 + } + } + + fn transcript_lines(&self, width: u16) -> Vec> { + self.lines(width) + } + + fn desired_transcript_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 } } @@ -217,15 +239,6 @@ impl HistoryCell for AgentMessageCell { ) } - fn transcript_lines(&self) -> Vec> { - let mut out: Vec> = Vec::new(); - if self.is_first_line { - out.push("codex".magenta().bold().into()); - } - out.extend(self.lines.clone()); - out - } - fn is_stream_continuation(&self) -> bool { !self.is_first_line } @@ -248,21 +261,6 @@ impl HistoryCell for PlainHistoryCell { } } -#[derive(Debug)] -pub(crate) struct TranscriptOnlyHistoryCell { - lines: Vec>, -} - -impl HistoryCell for TranscriptOnlyHistoryCell { - fn display_lines(&self, _width: u16) -> Vec> { - Vec::new() - } - - fn transcript_lines(&self) -> Vec> { - self.lines.clone() - } -} - /// Cyan history cell line showing the current review status. pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { PlainHistoryCell { @@ -1050,16 +1048,6 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor PlainHistoryCell { lines } } -pub(crate) fn new_reasoning_block( - full_reasoning_buffer: String, - config: &Config, -) -> TranscriptOnlyHistoryCell { - let mut lines: Vec> = Vec::new(); - lines.push(Line::from("thinking".magenta().italic())); - append_markdown(&full_reasoning_buffer, None, &mut lines, config); - TranscriptOnlyHistoryCell { lines } -} - pub(crate) fn new_reasoning_summary_block( full_reasoning_buffer: String, config: &Config, @@ -1085,12 +1073,18 @@ pub(crate) fn new_reasoning_summary_block( header_buffer, summary_buffer, config.into(), + false, )); } } } } - Box::new(new_reasoning_block(full_reasoning_buffer, config)) + Box::new(ReasoningSummaryCell::new( + "".to_string(), + full_reasoning_buffer, + config.into(), + true, + )) } #[derive(Debug)] @@ -1121,10 +1115,6 @@ impl HistoryCell for FinalMessageSeparator { vec![Line::from_iter(["─".repeat(width as usize).dim()])] } } - - fn transcript_lines(&self) -> Vec> { - vec![] - } } fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { @@ -1188,7 +1178,14 @@ mod tests { } fn render_transcript(cell: &dyn HistoryCell) -> Vec { - render_lines(&cell.transcript_lines()) + render_lines(&cell.transcript_lines(u16::MAX)) + } + + #[test] + fn empty_agent_message_cell_transcript() { + let cell = AgentMessageCell::new(vec![Line::default()], false); + assert_eq!(cell.transcript_lines(80), vec![Line::from(" ")]); + assert_eq!(cell.desired_transcript_height(80), 1); } #[test] @@ -1883,10 +1880,7 @@ mod tests { assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]); let rendered_transcript = render_transcript(cell.as_ref()); - assert_eq!( - rendered_transcript, - vec!["thinking", "Detailed reasoning goes here."] - ); + assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]); } #[test] @@ -1898,7 +1892,7 @@ mod tests { new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config); let rendered = render_transcript(cell.as_ref()); - assert_eq!(rendered, vec!["thinking", "Detailed reasoning goes here."]); + assert_eq!(rendered, vec!["• Detailed reasoning goes here."]); } #[test] @@ -1912,10 +1906,7 @@ mod tests { ); let rendered = render_transcript(cell.as_ref()); - assert_eq!( - rendered, - vec!["thinking", "**High level reasoning without closing"] - ); + assert_eq!(rendered, vec!["• **High level reasoning without closing"]); } #[test] @@ -1929,10 +1920,7 @@ mod tests { ); let rendered = render_transcript(cell.as_ref()); - assert_eq!( - rendered, - vec!["thinking", "High level reasoning without closing"] - ); + assert_eq!(rendered, vec!["• High level reasoning without closing"]); let cell = new_reasoning_summary_block( "**High level reasoning without closing**\n\n ".to_string(), @@ -1940,10 +1928,7 @@ mod tests { ); let rendered = render_transcript(cell.as_ref()); - assert_eq!( - rendered, - vec!["thinking", "High level reasoning without closing"] - ); + assert_eq!(rendered, vec!["• High level reasoning without closing"]); } #[test] @@ -1960,9 +1945,6 @@ mod tests { assert_eq!(rendered_display, vec!["• We should fix the bug next."]); let rendered_transcript = render_transcript(cell.as_ref()); - assert_eq!( - rendered_transcript, - vec!["thinking", "We should fix the bug next."] - ); + assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]); } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 7997625a..46c6c19a 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -3,9 +3,13 @@ use std::sync::Arc; use std::time::Duration; use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; +use crate::render::Insets; +use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; +use crate::style::user_message_style; use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; @@ -13,6 +17,7 @@ use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; +use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -21,7 +26,6 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; -use ratatui::widgets::Wrap; pub(crate) enum Overlay { Transcript(TranscriptOverlay), @@ -317,29 +321,30 @@ impl PagerView { } } -struct CachedParagraph { - paragraph: Paragraph<'static>, +/// A renderable that caches its desired height. +struct CachedRenderable { + renderable: Box, height: std::cell::Cell>, last_width: std::cell::Cell>, } -impl CachedParagraph { - fn new(paragraph: Paragraph<'static>) -> Self { +impl CachedRenderable { + fn new(renderable: Box) -> Self { Self { - paragraph, + renderable, height: std::cell::Cell::new(None), last_width: std::cell::Cell::new(None), } } } -impl Renderable for CachedParagraph { +impl Renderable for CachedRenderable { fn render(&self, area: Rect, buf: &mut Buffer) { - self.paragraph.render_ref(area, buf); + self.renderable.render(area, buf); } fn desired_height(&self, width: u16) -> u16 { if self.last_width.get() != Some(width) { - let height = self.paragraph.line_count(width) as u16; + let height = self.renderable.desired_height(width); self.height.set(Some(height)); self.last_width.set(Some(width)); } @@ -347,6 +352,23 @@ impl Renderable for CachedParagraph { } } +struct CellRenderable { + cell: Arc, + style: Style, +} + +impl Renderable for CellRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + let p = + Paragraph::new(Text::from(self.cell.transcript_lines(area.width))).style(self.style); + p.render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.cell.desired_transcript_height(width) + } +} + pub(crate) struct TranscriptOverlay { view: PagerView, cells: Vec>, @@ -358,7 +380,7 @@ impl TranscriptOverlay { pub(crate) fn new(transcript_cells: Vec>) -> Self { Self { view: PagerView::new( - Self::render_cells_to_texts(&transcript_cells, None), + Self::render_cells(&transcript_cells, None), "T R A N S C R I P T".to_string(), usize::MAX, ), @@ -368,46 +390,46 @@ impl TranscriptOverlay { } } - fn render_cells_to_texts( + fn render_cells( cells: &[Arc], highlight_cell: Option, ) -> Vec> { - let mut texts: Vec> = Vec::new(); - let mut first = true; - for (idx, cell) in cells.iter().enumerate() { - let mut lines: Vec> = Vec::new(); - if !cell.is_stream_continuation() && !first { - lines.push(Line::from("")); - } - let cell_lines = if Some(idx) == highlight_cell { - cell.transcript_lines() - .into_iter() - .map(Stylize::reversed) - .collect() - } else { - cell.transcript_lines() - }; - lines.extend(cell_lines); - texts.push(Box::new(CachedParagraph::new( - Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), - ))); - first = false; - } - texts + cells + .iter() + .enumerate() + .flat_map(|(i, c)| { + let mut v: Vec> = Vec::new(); + let mut cell_renderable = if c.as_any().is::() { + Box::new(CachedRenderable::new(Box::new(CellRenderable { + cell: c.clone(), + style: if highlight_cell == Some(i) { + user_message_style().reversed() + } else { + user_message_style() + }, + }))) as Box + } else { + Box::new(CachedRenderable::new(Box::new(CellRenderable { + cell: c.clone(), + style: Style::default(), + }))) as Box + }; + if !c.is_stream_continuation() && i > 0 { + cell_renderable = Box::new(InsetRenderable::new( + cell_renderable, + Insets::tlbr(1, 0, 0, 0), + )); + } + v.push(cell_renderable); + v + }) + .collect() } pub(crate) fn insert_cell(&mut self, cell: Arc) { let follow_bottom = self.view.is_scrolled_to_bottom(); - // Append as a new Text chunk (with a separating blank if needed) - let mut lines: Vec> = Vec::new(); - if !cell.is_stream_continuation() && !self.cells.is_empty() { - lines.push(Line::from("")); - } - lines.extend(cell.transcript_lines()); - self.view.renderables.push(Box::new(CachedParagraph::new( - Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), - ))); self.cells.push(cell); + self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); if follow_bottom { self.view.scroll_offset = usize::MAX; } @@ -415,7 +437,7 @@ impl TranscriptOverlay { pub(crate) fn set_highlight_cell(&mut self, cell: Option) { self.highlight_cell = cell; - self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell); + self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); if let Some(idx) = self.highlight_cell { self.view.scroll_chunk_into_view(idx); } @@ -475,8 +497,8 @@ pub(crate) struct StaticOverlay { impl StaticOverlay { pub(crate) fn with_title(lines: Vec>, title: String) -> Self { Self::with_renderables( - vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from( - lines, + vec![Box::new(CachedRenderable::new(Box::new(Paragraph::new( + Text::from(lines), ))))], title, ) @@ -585,7 +607,7 @@ mod tests { self.lines.clone() } - fn transcript_lines(&self) -> Vec> { + fn transcript_lines(&self, _width: u16) -> Vec> { self.lines.clone() } } diff --git a/codex-rs/tui/src/render/mod.rs b/codex-rs/tui/src/render/mod.rs index fe92eea9..bf4fb553 100644 --- a/codex-rs/tui/src/render/mod.rs +++ b/codex-rs/tui/src/render/mod.rs @@ -38,11 +38,13 @@ pub trait RectExt { impl RectExt for Rect { fn inset(&self, insets: Insets) -> Rect { + let horizontal = insets.left.saturating_add(insets.right); + let vertical = insets.top.saturating_add(insets.bottom); Rect { - x: self.x + insets.left, - y: self.y + insets.top, - width: self.width - insets.left - insets.right, - height: self.height - insets.top - insets.bottom, + x: self.x.saturating_add(insets.left), + y: self.y.saturating_add(insets.top), + width: self.width.saturating_sub(horizontal), + height: self.height.saturating_sub(vertical), } } } diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index c5542633..62c27206 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::text::Line; @@ -12,6 +14,12 @@ pub trait Renderable { fn desired_height(&self, width: u16) -> u16; } +impl From for Box { + fn from(value: R) -> Self { + Box::new(value) + } +} + impl Renderable for () { fn render(&self, _area: Rect, _buf: &mut Buffer) {} fn desired_height(&self, _width: u16) -> u16 { @@ -71,6 +79,15 @@ impl Renderable for Option { } } +impl Renderable for Arc { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.as_ref().render(area, buf); + } + fn desired_height(&self, width: u16) -> u16 { + self.as_ref().desired_height(width) + } +} + pub struct ColumnRenderable { children: Vec>, } @@ -122,7 +139,10 @@ impl Renderable for InsetRenderable { } impl InsetRenderable { - pub fn new(child: Box, insets: Insets) -> Self { - Self { child, insets } + pub fn new(child: impl Into>, insets: Insets) -> Self { + Self { + child: child.into(), + insets, + } } } diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 75946edb..b2858e8f 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -141,7 +141,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { "ts": now_ts(), "dir": "to_tui", "kind": "insert_history_cell", - "lines": cell.transcript_lines().len(), + "lines": cell.transcript_lines(u16::MAX).len(), }); LOGGER.write_json_line(value); } diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 372f296a..319777ec 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -90,6 +90,7 @@ mod tests { use super::*; use codex_core::config::Config; use codex_core::config::ConfigOverrides; + use pretty_assertions::assert_eq; async fn test_config() -> Config { let overrides = ConfigOverrides { @@ -195,7 +196,7 @@ mod tests { for d in deltas.iter() { ctrl.push(d); while let (Some(cell), idle) = ctrl.on_commit_tick() { - lines.extend(cell.transcript_lines()); + lines.extend(cell.transcript_lines(u16::MAX)); if idle { break; } @@ -203,21 +204,14 @@ mod tests { } // Finalize and flush remaining lines now. if let Some(cell) = ctrl.finalize() { - lines.extend(cell.transcript_lines()); + lines.extend(cell.transcript_lines(u16::MAX)); } - let mut flat = lines; - // Drop leading blank and header line if present. - if !flat.is_empty() && lines_to_plain_strings(&[flat[0].clone()])[0].is_empty() { - flat.remove(0); - } - if !flat.is_empty() { - let s0 = lines_to_plain_strings(&[flat[0].clone()])[0].clone(); - if s0 == "codex" { - flat.remove(0); - } - } - let streamed = lines_to_plain_strings(&flat); + let streamed: Vec<_> = lines_to_plain_strings(&lines) + .into_iter() + // skip • and 2-space indentation + .map(|s| s.chars().skip(2).collect::()) + .collect(); // Full render of the same source let source: String = deltas.iter().copied().collect(); diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index 90a8e7a8..01861e1e 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -1,12 +1,17 @@ use crate::color::blend; use crate::color::is_light; use crate::color::perceptual_distance; +use crate::terminal_palette::default_bg; use crate::terminal_palette::terminal_palette; use ratatui::style::Color; use ratatui::style::Style; +pub fn user_message_style() -> Style { + user_message_style_for(default_bg()) +} + /// Returns the style for a user-authored message using the provided terminal background. -pub fn user_message_style(terminal_bg: Option<(u8, u8, u8)>) -> Style { +pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { match terminal_bg { Some(bg) => Style::default().bg(user_message_bg(bg)), None => Style::default(),