tui: bring the transcript closer to display mode (#4848)

before
<img width="1161" height="836" alt="Screenshot 2025-10-06 at 3 06 52 PM"
src="https://github.com/user-attachments/assets/7622fd6b-9d37-402f-8651-61c2c55dcbc6"
/>

after
<img width="1161" height="858" alt="Screenshot 2025-10-06 at 3 07 02 PM"
src="https://github.com/user-attachments/assets/1498f327-1d1a-4630-951f-7ca371ab0139"
/>
This commit is contained in:
Jeremy Rose
2025-10-07 16:18:48 -07:00
committed by GitHub
parent 60f9e85c16
commit 0e5d72cc57
13 changed files with 233 additions and 187 deletions

View File

@@ -134,8 +134,9 @@ impl App {
/// Useful when switching sessions to ensure prior history remains visible. /// Useful when switching sessions to ensure prior history remains visible.
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
if !self.transcript_cells.is_empty() { if !self.transcript_cells.is_empty() {
let width = tui.terminal.last_known_screen_size.width;
for cell in &self.transcript_cells { for cell in &self.transcript_cells {
tui.insert_history_lines(cell.transcript_lines()); tui.insert_history_lines(cell.display_lines(width));
} }
} }
} }

View File

@@ -38,7 +38,6 @@ use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
use crate::slash_command::SlashCommand; use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands; use crate::slash_command::built_in_slash_commands;
use crate::style::user_message_style; use crate::style::user_message_style;
use crate::terminal_palette;
use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; 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; let mut block_rect = composer_rect;
block_rect.y = composer_rect.y.saturating_sub(1); block_rect.y = composer_rect.y.saturating_sub(1);
block_rect.height = composer_rect.height.saturating_add(1); block_rect.height = composer_rect.height.saturating_add(1);

View File

@@ -20,7 +20,6 @@ use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable; use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use crate::style::user_message_style; use crate::style::user_message_style;
use crate::terminal_palette;
use super::CancellationEvent; use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView; use super::bottom_pane_view::BottomPaneView;
@@ -350,7 +349,7 @@ impl Renderable for ListSelectionView {
.areas(area); .areas(area);
Block::default() Block::default()
.style(user_message_style(terminal_palette::default_bg())) .style(user_message_style())
.render(content_area, buf); .render(content_area, buf);
let header_height = self let header_height = self

View File

@@ -120,6 +120,8 @@ where
/// Last known position of the cursor. Used to find the new area when the viewport is inlined /// Last known position of the cursor. Used to find the new area when the viewport is inlined
/// and the terminal resized. /// and the terminal resized.
pub last_known_cursor_pos: Position, pub last_known_cursor_pos: Position,
use_custom_flush: bool,
} }
impl<B> Drop for Terminal<B> impl<B> Drop for Terminal<B>
@@ -158,6 +160,7 @@ where
viewport_area: Rect::new(0, cursor_pos.y, 0, 0), viewport_area: Rect::new(0, cursor_pos.y, 0, 0),
last_known_screen_size: screen_size, last_known_screen_size: screen_size,
last_known_cursor_pos: cursor_pos, last_known_cursor_pos: cursor_pos,
use_custom_flush: true,
}) })
} }
@@ -190,15 +193,24 @@ where
pub fn flush(&mut self) -> io::Result<()> { pub fn flush(&mut self) -> io::Result<()> {
let previous_buffer = &self.buffers[1 - self.current]; let previous_buffer = &self.buffers[1 - self.current];
let current_buffer = &self.buffers[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 if self.use_custom_flush {
.iter() let updates = diff_buffers(previous_buffer, current_buffer);
.rev() if let Some(DrawCommand::Put { x, y, .. }) = updates
.find(|cmd| matches!(cmd, DrawCommand::Put { .. })) .iter()
{ .rev()
self.last_known_cursor_pos = Position { x: *x, y: *y }; .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. /// 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<DrawCommand<'a>> {
let x = row let x = row
.iter() .iter()
.rposition(|cell| cell.symbol() != " " || cell.bg != bg) .rposition(|cell| {
cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty()
})
.unwrap_or(0); .unwrap_or(0);
last_nonblank_column[y as usize] = x as u16; 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) { 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 { updates.push(DrawCommand::ClearToEnd {
x: x_abs, x: x_abs,
y: y_abs, y: y_abs,

View File

@@ -67,7 +67,7 @@ impl From<DiffSummary> for Box<dyn Renderable> {
rows.push(Box::new(path)); rows.push(Box::new(path));
rows.push(Box::new(RtLine::from(""))); rows.push(Box::new(RtLine::from("")));
rows.push(Box::new(InsetRenderable::new( rows.push(Box::new(InsetRenderable::new(
Box::new(row.change), row.change,
Insets::tlbr(0, 2, 0, 0), Insets::tlbr(0, 2, 0, 0),
))); )));
} }

View File

@@ -11,6 +11,7 @@ use crate::render::line_utils::push_owned_lines;
use crate::shimmer::shimmer_spans; use crate::shimmer::shimmer_spans;
use crate::wrapping::RtOptions; use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use codex_ansi_escape::ansi_escape_line; use codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration; use codex_common::elapsed::format_duration;
use codex_protocol::parse_command::ParsedCommand; use codex_protocol::parse_command::ParsedCommand;
@@ -138,17 +139,25 @@ impl HistoryCell for ExecCell {
} }
} }
fn transcript_lines(&self) -> Vec<Line<'static>> { fn desired_transcript_height(&self, width: u16) -> u16 {
self.transcript_lines(width).len() as u16
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![]; let mut lines: Vec<Line<'static>> = vec![];
for call in self.iter_calls() { for (i, call) in self.iter_calls().enumerate() {
let cmd_display = strip_bash_lc_and_escape(&call.command); if i > 0 {
for (i, part) in cmd_display.lines().enumerate() { lines.push("".into());
if i == 0 {
lines.push(vec!["$ ".magenta(), part.to_string().into()].into());
} else {
lines.push(vec![" ".into(), part.to_string().into()].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() { if let Some(output) = call.output.as_ref() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line)); lines.extend(output.formatted_output.lines().map(ansi_escape_line));
@@ -167,7 +176,6 @@ impl HistoryCell for ExecCell {
result.push_span(format!("{duration}").dim()); result.push_span(format!("{duration}").dim());
lines.push(result); lines.push(result);
} }
lines.push("".into());
} }
lines lines
} }

View File

@@ -11,7 +11,6 @@ use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static; use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines; use crate::render::line_utils::prefix_lines;
use crate::style::user_message_style; use crate::style::user_message_style;
use crate::terminal_palette::default_bg;
use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::format_and_truncate_tool_result;
use crate::ui_consts::LIVE_PREFIX_COLS; use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::wrapping::RtOptions; use crate::wrapping::RtOptions;
@@ -56,10 +55,6 @@ use unicode_width::UnicodeWidthStr;
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
fn display_lines(&self, width: u16) -> Vec<Line<'static>>; fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.display_lines(u16::MAX)
}
fn desired_height(&self, width: u16) -> u16 { fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines(width))) Paragraph::new(Text::from(self.display_lines(width)))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
@@ -68,6 +63,29 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
.unwrap_or(0) .unwrap_or(0)
} }
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
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 { fn is_stream_continuation(&self) -> bool {
false false
} }
@@ -92,12 +110,10 @@ impl HistoryCell for UserHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> { fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
// Use ratatui-aware word wrapping and prefixing to avoid lifetime issues. let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS);
let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); // account for the ▌ prefix and trailing space
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( let wrapped = word_wrap_lines(
&self &self
.message .message
@@ -113,13 +129,6 @@ impl HistoryCell for UserHistoryCell {
lines.push(Line::from("").style(style)); lines.push(Line::from("").style(style));
lines lines
} }
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push("user".cyan().bold().into());
lines.extend(self.message.lines().map(|l| l.to_string().into()));
lines
}
} }
#[derive(Debug)] #[derive(Debug)]
@@ -127,6 +136,7 @@ pub(crate) struct ReasoningSummaryCell {
_header: String, _header: String,
content: String, content: String,
citation_context: MarkdownCitationContext, citation_context: MarkdownCitationContext,
transcript_only: bool,
} }
impl ReasoningSummaryCell { impl ReasoningSummaryCell {
@@ -134,17 +144,17 @@ impl ReasoningSummaryCell {
header: String, header: String,
content: String, content: String,
citation_context: MarkdownCitationContext, citation_context: MarkdownCitationContext,
transcript_only: bool,
) -> Self { ) -> Self {
Self { Self {
_header: header, _header: header,
content, content,
citation_context, citation_context,
transcript_only,
} }
} }
}
impl HistoryCell for ReasoningSummaryCell { fn lines(&self, width: u16) -> Vec<Line<'static>> {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown( append_markdown(
&self.content, &self.content,
@@ -152,7 +162,7 @@ impl HistoryCell for ReasoningSummaryCell {
&mut lines, &mut lines,
self.citation_context.clone(), 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 let summary_lines = lines
.into_iter() .into_iter()
.map(|mut line| { .map(|mut line| {
@@ -172,19 +182,31 @@ impl HistoryCell for ReasoningSummaryCell {
.subsequent_indent(" ".into()), .subsequent_indent(" ".into()),
) )
} }
}
fn transcript_lines(&self) -> Vec<Line<'static>> { impl HistoryCell for ReasoningSummaryCell {
let mut out: Vec<Line<'static>> = Vec::new(); fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
out.push("thinking".magenta().bold().into()); if self.transcript_only {
let mut lines = Vec::new(); Vec::new()
append_markdown( } else {
&self.content, self.lines(width)
None, }
&mut lines, }
self.citation_context.clone(),
); fn desired_height(&self, width: u16) -> u16 {
out.extend(lines); if self.transcript_only {
out 0
} else {
self.lines(width).len() as u16
}
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
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<Line<'static>> {
let mut out: Vec<Line<'static>> = 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 { fn is_stream_continuation(&self) -> bool {
!self.is_first_line !self.is_first_line
} }
@@ -248,21 +261,6 @@ impl HistoryCell for PlainHistoryCell {
} }
} }
#[derive(Debug)]
pub(crate) struct TranscriptOnlyHistoryCell {
lines: Vec<Line<'static>>,
}
impl HistoryCell for TranscriptOnlyHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
Vec::new()
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
}
}
/// Cyan history cell line showing the current review status. /// Cyan history cell line showing the current review status.
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
PlainHistoryCell { PlainHistoryCell {
@@ -1050,16 +1048,6 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,
) -> TranscriptOnlyHistoryCell {
let mut lines: Vec<Line<'static>> = 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( pub(crate) fn new_reasoning_summary_block(
full_reasoning_buffer: String, full_reasoning_buffer: String,
config: &Config, config: &Config,
@@ -1085,12 +1073,18 @@ pub(crate) fn new_reasoning_summary_block(
header_buffer, header_buffer,
summary_buffer, summary_buffer,
config.into(), 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)] #[derive(Debug)]
@@ -1121,10 +1115,6 @@ impl HistoryCell for FinalMessageSeparator {
vec![Line::from_iter(["".repeat(width as usize).dim()])] vec![Line::from_iter(["".repeat(width as usize).dim()])]
} }
} }
fn transcript_lines(&self) -> Vec<Line<'static>> {
vec![]
}
} }
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
@@ -1188,7 +1178,14 @@ mod tests {
} }
fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> { fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
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] #[test]
@@ -1883,10 +1880,7 @@ mod tests {
assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]); assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
let rendered_transcript = render_transcript(cell.as_ref()); let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!( assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]);
rendered_transcript,
vec!["thinking", "Detailed reasoning goes here."]
);
} }
#[test] #[test]
@@ -1898,7 +1892,7 @@ mod tests {
new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config); new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config);
let rendered = render_transcript(cell.as_ref()); 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] #[test]
@@ -1912,10 +1906,7 @@ mod tests {
); );
let rendered = render_transcript(cell.as_ref()); let rendered = render_transcript(cell.as_ref());
assert_eq!( assert_eq!(rendered, vec!["• **High level reasoning without closing"]);
rendered,
vec!["thinking", "**High level reasoning without closing"]
);
} }
#[test] #[test]
@@ -1929,10 +1920,7 @@ mod tests {
); );
let rendered = render_transcript(cell.as_ref()); let rendered = render_transcript(cell.as_ref());
assert_eq!( assert_eq!(rendered, vec!["• High level reasoning without closing"]);
rendered,
vec!["thinking", "High level reasoning without closing"]
);
let cell = new_reasoning_summary_block( let cell = new_reasoning_summary_block(
"**High level reasoning without closing**\n\n ".to_string(), "**High level reasoning without closing**\n\n ".to_string(),
@@ -1940,10 +1928,7 @@ mod tests {
); );
let rendered = render_transcript(cell.as_ref()); let rendered = render_transcript(cell.as_ref());
assert_eq!( assert_eq!(rendered, vec!["• High level reasoning without closing"]);
rendered,
vec!["thinking", "High level reasoning without closing"]
);
} }
#[test] #[test]
@@ -1960,9 +1945,6 @@ mod tests {
assert_eq!(rendered_display, vec!["• We should fix the bug next."]); assert_eq!(rendered_display, vec!["• We should fix the bug next."]);
let rendered_transcript = render_transcript(cell.as_ref()); let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!( assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]);
rendered_transcript,
vec!["thinking", "We should fix the bug next."]
);
} }
} }

View File

@@ -3,9 +3,13 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint; use crate::key_hint;
use crate::key_hint::KeyBinding; use crate::key_hint::KeyBinding;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent; use crate::tui::TuiEvent;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
@@ -13,6 +17,7 @@ use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::buffer::Cell; use ratatui::buffer::Cell;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span; use ratatui::text::Span;
@@ -21,7 +26,6 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
pub(crate) enum Overlay { pub(crate) enum Overlay {
Transcript(TranscriptOverlay), Transcript(TranscriptOverlay),
@@ -317,29 +321,30 @@ impl PagerView {
} }
} }
struct CachedParagraph { /// A renderable that caches its desired height.
paragraph: Paragraph<'static>, struct CachedRenderable {
renderable: Box<dyn Renderable>,
height: std::cell::Cell<Option<u16>>, height: std::cell::Cell<Option<u16>>,
last_width: std::cell::Cell<Option<u16>>, last_width: std::cell::Cell<Option<u16>>,
} }
impl CachedParagraph { impl CachedRenderable {
fn new(paragraph: Paragraph<'static>) -> Self { fn new(renderable: Box<dyn Renderable>) -> Self {
Self { Self {
paragraph, renderable,
height: std::cell::Cell::new(None), height: std::cell::Cell::new(None),
last_width: 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) { 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 { fn desired_height(&self, width: u16) -> u16 {
if self.last_width.get() != Some(width) { 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.height.set(Some(height));
self.last_width.set(Some(width)); self.last_width.set(Some(width));
} }
@@ -347,6 +352,23 @@ impl Renderable for CachedParagraph {
} }
} }
struct CellRenderable {
cell: Arc<dyn HistoryCell>,
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 { pub(crate) struct TranscriptOverlay {
view: PagerView, view: PagerView,
cells: Vec<Arc<dyn HistoryCell>>, cells: Vec<Arc<dyn HistoryCell>>,
@@ -358,7 +380,7 @@ impl TranscriptOverlay {
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self { pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
Self { Self {
view: PagerView::new( 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(), "T R A N S C R I P T".to_string(),
usize::MAX, usize::MAX,
), ),
@@ -368,46 +390,46 @@ impl TranscriptOverlay {
} }
} }
fn render_cells_to_texts( fn render_cells(
cells: &[Arc<dyn HistoryCell>], cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>, highlight_cell: Option<usize>,
) -> Vec<Box<dyn Renderable>> { ) -> Vec<Box<dyn Renderable>> {
let mut texts: Vec<Box<dyn Renderable>> = Vec::new(); cells
let mut first = true; .iter()
for (idx, cell) in cells.iter().enumerate() { .enumerate()
let mut lines: Vec<Line<'static>> = Vec::new(); .flat_map(|(i, c)| {
if !cell.is_stream_continuation() && !first { let mut v: Vec<Box<dyn Renderable>> = Vec::new();
lines.push(Line::from("")); let mut cell_renderable = if c.as_any().is::<UserHistoryCell>() {
} Box::new(CachedRenderable::new(Box::new(CellRenderable {
let cell_lines = if Some(idx) == highlight_cell { cell: c.clone(),
cell.transcript_lines() style: if highlight_cell == Some(i) {
.into_iter() user_message_style().reversed()
.map(Stylize::reversed) } else {
.collect() user_message_style()
} else { },
cell.transcript_lines() }))) as Box<dyn Renderable>
}; } else {
lines.extend(cell_lines); Box::new(CachedRenderable::new(Box::new(CellRenderable {
texts.push(Box::new(CachedParagraph::new( cell: c.clone(),
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), style: Style::default(),
))); }))) as Box<dyn Renderable>
first = false; };
} if !c.is_stream_continuation() && i > 0 {
texts 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<dyn HistoryCell>) { pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
let follow_bottom = self.view.is_scrolled_to_bottom(); 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<Line<'static>> = 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.cells.push(cell);
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
if follow_bottom { if follow_bottom {
self.view.scroll_offset = usize::MAX; self.view.scroll_offset = usize::MAX;
} }
@@ -415,7 +437,7 @@ impl TranscriptOverlay {
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) { pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell; 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 { if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx); self.view.scroll_chunk_into_view(idx);
} }
@@ -475,8 +497,8 @@ pub(crate) struct StaticOverlay {
impl StaticOverlay { impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self { pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
Self::with_renderables( Self::with_renderables(
vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from( vec![Box::new(CachedRenderable::new(Box::new(Paragraph::new(
lines, Text::from(lines),
))))], ))))],
title, title,
) )
@@ -585,7 +607,7 @@ mod tests {
self.lines.clone() self.lines.clone()
} }
fn transcript_lines(&self) -> Vec<Line<'static>> { fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone() self.lines.clone()
} }
} }

View File

@@ -38,11 +38,13 @@ pub trait RectExt {
impl RectExt for Rect { impl RectExt for Rect {
fn inset(&self, insets: Insets) -> Rect { fn inset(&self, insets: Insets) -> Rect {
let horizontal = insets.left.saturating_add(insets.right);
let vertical = insets.top.saturating_add(insets.bottom);
Rect { Rect {
x: self.x + insets.left, x: self.x.saturating_add(insets.left),
y: self.y + insets.top, y: self.y.saturating_add(insets.top),
width: self.width - insets.left - insets.right, width: self.width.saturating_sub(horizontal),
height: self.height - insets.top - insets.bottom, height: self.height.saturating_sub(vertical),
} }
} }
} }

View File

@@ -1,3 +1,5 @@
use std::sync::Arc;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::text::Line; use ratatui::text::Line;
@@ -12,6 +14,12 @@ pub trait Renderable {
fn desired_height(&self, width: u16) -> u16; fn desired_height(&self, width: u16) -> u16;
} }
impl<R: Renderable + 'static> From<R> for Box<dyn Renderable> {
fn from(value: R) -> Self {
Box::new(value)
}
}
impl Renderable for () { impl Renderable for () {
fn render(&self, _area: Rect, _buf: &mut Buffer) {} fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 { fn desired_height(&self, _width: u16) -> u16 {
@@ -71,6 +79,15 @@ impl<R: Renderable> Renderable for Option<R> {
} }
} }
impl<R: Renderable> Renderable for Arc<R> {
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 { pub struct ColumnRenderable {
children: Vec<Box<dyn Renderable>>, children: Vec<Box<dyn Renderable>>,
} }
@@ -122,7 +139,10 @@ impl Renderable for InsetRenderable {
} }
impl InsetRenderable { impl InsetRenderable {
pub fn new(child: Box<dyn Renderable>, insets: Insets) -> Self { pub fn new(child: impl Into<Box<dyn Renderable>>, insets: Insets) -> Self {
Self { child, insets } Self {
child: child.into(),
insets,
}
} }
} }

View File

@@ -141,7 +141,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"ts": now_ts(), "ts": now_ts(),
"dir": "to_tui", "dir": "to_tui",
"kind": "insert_history_cell", "kind": "insert_history_cell",
"lines": cell.transcript_lines().len(), "lines": cell.transcript_lines(u16::MAX).len(),
}); });
LOGGER.write_json_line(value); LOGGER.write_json_line(value);
} }

View File

@@ -90,6 +90,7 @@ mod tests {
use super::*; use super::*;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::config::ConfigOverrides; use codex_core::config::ConfigOverrides;
use pretty_assertions::assert_eq;
async fn test_config() -> Config { async fn test_config() -> Config {
let overrides = ConfigOverrides { let overrides = ConfigOverrides {
@@ -195,7 +196,7 @@ mod tests {
for d in deltas.iter() { for d in deltas.iter() {
ctrl.push(d); ctrl.push(d);
while let (Some(cell), idle) = ctrl.on_commit_tick() { while let (Some(cell), idle) = ctrl.on_commit_tick() {
lines.extend(cell.transcript_lines()); lines.extend(cell.transcript_lines(u16::MAX));
if idle { if idle {
break; break;
} }
@@ -203,21 +204,14 @@ mod tests {
} }
// Finalize and flush remaining lines now. // Finalize and flush remaining lines now.
if let Some(cell) = ctrl.finalize() { if let Some(cell) = ctrl.finalize() {
lines.extend(cell.transcript_lines()); lines.extend(cell.transcript_lines(u16::MAX));
} }
let mut flat = lines; let streamed: Vec<_> = lines_to_plain_strings(&lines)
// Drop leading blank and header line if present. .into_iter()
if !flat.is_empty() && lines_to_plain_strings(&[flat[0].clone()])[0].is_empty() { // skip • and 2-space indentation
flat.remove(0); .map(|s| s.chars().skip(2).collect::<String>())
} .collect();
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);
// Full render of the same source // Full render of the same source
let source: String = deltas.iter().copied().collect(); let source: String = deltas.iter().copied().collect();

View File

@@ -1,12 +1,17 @@
use crate::color::blend; use crate::color::blend;
use crate::color::is_light; use crate::color::is_light;
use crate::color::perceptual_distance; use crate::color::perceptual_distance;
use crate::terminal_palette::default_bg;
use crate::terminal_palette::terminal_palette; use crate::terminal_palette::terminal_palette;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::style::Style; 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. /// 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 { match terminal_bg {
Some(bg) => Style::default().bg(user_message_bg(bg)), Some(bg) => Style::default().bg(user_message_bg(bg)),
None => Style::default(), None => Style::default(),