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
after
---
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