diff --git a/codex-rs/tui/src/cell_widget.rs b/codex-rs/tui/src/cell_widget.rs new file mode 100644 index 00000000..8acdc055 --- /dev/null +++ b/codex-rs/tui/src/cell_widget.rs @@ -0,0 +1,20 @@ +use ratatui::prelude::*; + +/// Trait implemented by every type that can live inside the conversation +/// history list. It provides two primitives that the parent scroll-view +/// needs: how *tall* the widget is at a given width and how to render an +/// arbitrary contiguous *window* of that widget. +/// +/// The `first_visible_line` argument to [`render_window`] allows partial +/// rendering when the top of the widget is scrolled off-screen. The caller +/// guarantees that `first_visible_line + area.height as usize` never exceeds +/// the total height previously returned by [`height`]. +pub(crate) trait CellWidget { + /// Total height measured in wrapped terminal lines when drawn with the + /// given *content* width (no scrollbar column included). + fn height(&self, width: u16) -> usize; + + /// Render a *window* that starts `first_visible_line` lines below the top + /// of the widget. The window’s size is given by `area`. + fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer); +} diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index 83d5ebc4..d69f4db8 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -1,3 +1,4 @@ +use crate::cell_widget::CellWidget; use crate::history_cell::CommandOutput; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; @@ -236,11 +237,7 @@ impl ConversationHistoryWidget { fn add_to_history(&mut self, cell: HistoryCell) { let width = self.cached_width.get(); - let count = if width > 0 { - wrapped_line_count_for_cell(&cell, width) - } else { - 0 - }; + let count = if width > 0 { cell.height(width) } else { 0 }; self.entries.push(Entry { cell, @@ -284,9 +281,7 @@ impl ConversationHistoryWidget { // Update cached line count. if width > 0 { - entry - .line_count - .set(wrapped_line_count_for_cell(cell, width)); + entry.line_count.set(cell.height(width)); } break; } @@ -328,9 +323,7 @@ impl ConversationHistoryWidget { entry.cell = completed; if width > 0 { - entry - .line_count - .set(wrapped_line_count_for_cell(&entry.cell, width)); + entry.line_count.set(entry.cell.height(width)); } break; @@ -378,7 +371,7 @@ impl WidgetRef for ConversationHistoryWidget { let mut num_lines: usize = 0; for entry in &self.entries { - let count = wrapped_line_count_for_cell(&entry.cell, effective_width); + let count = entry.cell.height(effective_width); num_lines += count; entry.line_count.set(count); } @@ -397,79 +390,69 @@ impl WidgetRef for ConversationHistoryWidget { self.scroll_position.min(max_scroll) }; - // ------------------------------------------------------------------ - // Build a *window* into the history so we only clone the `Line`s that - // may actually be visible in this frame. We still hand the slice off - // to a `Paragraph` with an additional scroll offset to avoid slicing - // inside a wrapped line (we don’t have per-subline granularity). - // ------------------------------------------------------------------ - - // Find the first entry that intersects the current scroll position. - let mut cumulative = 0usize; - let mut first_idx = 0usize; - for (idx, entry) in self.entries.iter().enumerate() { - let next = cumulative + entry.line_count.get(); - if next > scroll_pos { - first_idx = idx; - break; - } - cumulative = next; - } - - let offset_into_first = scroll_pos - cumulative; - - // Collect enough raw lines from `first_idx` onward to cover the - // viewport. We may fetch *slightly* more than necessary (whole cells) - // but never the entire history. - let mut collected_wrapped = 0usize; - let mut visible_lines: Vec> = Vec::new(); - - for entry in &self.entries[first_idx..] { - visible_lines.extend(entry.cell.lines().iter().cloned()); - collected_wrapped += entry.line_count.get(); - if collected_wrapped >= offset_into_first + viewport_height { - break; - } - } - - // Build the Paragraph with wrapping enabled so long lines are not - // clipped. Apply vertical scroll so that `offset_into_first` wrapped - // lines are hidden at the top. // ------------------------------------------------------------------ // Render order: - // 1. Clear the whole widget area so we do not leave behind any glyphs - // from the previous frame. + // 1. Clear full widget area (avoid artifacts from prior frame). // 2. Draw the surrounding Block (border and title). - // 3. Draw the Paragraph inside the Block, **leaving the right-most - // column free** for the scrollbar. - // 4. Finally draw the scrollbar (if needed). + // 3. Render *each* visible HistoryCell into its own sub-Rect while + // respecting partial visibility at the top and bottom. + // 4. Draw the scrollbar track / thumb in the reserved column. // ------------------------------------------------------------------ - // Clear the widget area to avoid visual artifacts from previous frames. + // Clear entire widget area first. Clear.render(area, buf); - // Draw the outer border and title first so the Paragraph does not - // overwrite it. + // Draw border + title. block.render(area, buf); - // Area available for text after accounting for the scrollbar. - let text_area = Rect { - x: inner.x, - y: inner.y, - width: effective_width, - height: inner.height, - }; + // ------------------------------------------------------------------ + // Calculate which cells are visible for the current scroll position + // and paint them one by one. + // ------------------------------------------------------------------ - let paragraph = Paragraph::new(visible_lines) - .wrap(wrap_cfg()) - .scroll((offset_into_first as u16, 0)); + let mut y_cursor = inner.y; // first line inside viewport + let mut remaining_height = inner.height as usize; + let mut lines_to_skip = scroll_pos; // number of wrapped lines to skip (above viewport) - paragraph.render(text_area, buf); + for entry in &self.entries { + let cell_height = entry.line_count.get(); - // Always render a scrollbar *track* so that the reserved column is - // visually filled, even when the content fits within the viewport. - // We only draw the *thumb* when the content actually overflows. + // Completely above viewport? Skip whole cell. + if lines_to_skip >= cell_height { + lines_to_skip -= cell_height; + continue; + } + // Determine how much of this cell is visible. + let visible_height = (cell_height - lines_to_skip).min(remaining_height); + + if visible_height == 0 { + break; // no space left + } + + let cell_rect = Rect { + x: inner.x, + y: y_cursor, + width: effective_width, + height: visible_height as u16, + }; + + entry.cell.render_window(lines_to_skip, cell_rect, buf); + + // Advance cursor inside viewport. + y_cursor += visible_height as u16; + remaining_height -= visible_height; + + // After the first (possibly partially skipped) cell, we no longer + // need to skip lines at the top. + lines_to_skip = 0; + + if remaining_height == 0 { + break; // viewport filled + } + } + + // Always render a scrollbar *track* so the reserved column is filled. let overflow = num_lines.saturating_sub(viewport_height); let mut scroll_state = ScrollbarState::default() @@ -521,15 +504,6 @@ impl WidgetRef for ConversationHistoryWidget { /// Common [`Wrap`] configuration used for both measurement and rendering so /// they stay in sync. #[inline] -const fn wrap_cfg() -> ratatui::widgets::Wrap { +pub(crate) const fn wrap_cfg() -> ratatui::widgets::Wrap { ratatui::widgets::Wrap { trim: false } } - -/// Returns the wrapped line count for `cell` at the given `width` using the -/// same wrapping rules that `ConversationHistoryWidget` uses during -/// rendering. -fn wrapped_line_count_for_cell(cell: &HistoryCell, width: u16) -> usize { - Paragraph::new(cell.lines().clone()) - .wrap(wrap_cfg()) - .line_count(width) -} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index fab94327..c2938f4b 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -9,6 +9,9 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line as RtLine; use ratatui::text::Span as RtSpan; + +use crate::cell_widget::CellWidget; +use crate::text_block::TextBlock; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -34,16 +37,16 @@ pub(crate) enum PatchEventType { /// scrollable list. pub(crate) enum HistoryCell { /// Welcome message. - WelcomeMessage { lines: Vec> }, + WelcomeMessage { view: TextBlock }, /// Message from the user. - UserPrompt { lines: Vec> }, + UserPrompt { view: TextBlock }, /// Message from the agent. - AgentMessage { lines: Vec> }, + AgentMessage { view: TextBlock }, /// Reasoning event from the agent. - AgentReasoning { lines: Vec> }, + AgentReasoning { view: TextBlock }, /// An exec tool call that has not finished yet. ActiveExecCommand { @@ -51,11 +54,11 @@ pub(crate) enum HistoryCell { /// The shell command, escaped and formatted. command: String, start: Instant, - lines: Vec>, + view: TextBlock, }, /// Completed exec tool call. - CompletedExecCommand { lines: Vec> }, + CompletedExecCommand { view: TextBlock }, /// An MCP tool call that has not finished yet. ActiveMcpToolCall { @@ -67,29 +70,25 @@ pub(crate) enum HistoryCell { /// exact same text without re-formatting. invocation: String, start: Instant, - lines: Vec>, + view: TextBlock, }, /// Completed MCP tool call. - CompletedMcpToolCall { lines: Vec> }, + CompletedMcpToolCall { view: TextBlock }, - /// Background event - BackgroundEvent { lines: Vec> }, + /// Background event. + BackgroundEvent { view: TextBlock }, /// Error event from the backend. - ErrorEvent { lines: Vec> }, + ErrorEvent { view: TextBlock }, - /// Info describing the newly‑initialized session. - SessionInfo { lines: Vec> }, + /// Info describing the newly-initialized session. + SessionInfo { view: TextBlock }, /// A pending code patch that is awaiting user approval. Mirrors the /// behaviour of `ActiveExecCommand` so the user sees *what* patch the /// model wants to apply before being prompted to approve or deny it. - PendingPatch { - /// Identifier so that a future `PatchApplyEnd` can update the entry - /// with the final status (not yet implemented). - lines: Vec>, - }, + PendingPatch { view: TextBlock }, } const TOOL_CALL_MAX_LINES: usize = 5; @@ -132,9 +131,13 @@ impl HistoryCell { lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()])); } lines.push(Line::from("")); - HistoryCell::WelcomeMessage { lines } + HistoryCell::WelcomeMessage { + view: TextBlock::new(lines), + } } else if config.model == model { - HistoryCell::SessionInfo { lines: vec![] } + HistoryCell::SessionInfo { + view: TextBlock::new(Vec::new()), + } } else { let lines = vec![ Line::from("model changed:".magenta().bold()), @@ -142,7 +145,9 @@ impl HistoryCell { Line::from(format!("used: {}", model)), Line::from(""), ]; - HistoryCell::SessionInfo { lines } + HistoryCell::SessionInfo { + view: TextBlock::new(lines), + } } } @@ -152,7 +157,9 @@ impl HistoryCell { lines.extend(message.lines().map(|l| Line::from(l.to_string()))); lines.push(Line::from("")); - HistoryCell::UserPrompt { lines } + HistoryCell::UserPrompt { + view: TextBlock::new(lines), + } } pub(crate) fn new_agent_message(config: &Config, message: String) -> Self { @@ -161,7 +168,9 @@ impl HistoryCell { append_markdown(&message, &mut lines, config); lines.push(Line::from("")); - HistoryCell::AgentMessage { lines } + HistoryCell::AgentMessage { + view: TextBlock::new(lines), + } } pub(crate) fn new_agent_reasoning(config: &Config, text: String) -> Self { @@ -170,7 +179,9 @@ impl HistoryCell { append_markdown(&text, &mut lines, config); lines.push(Line::from("")); - HistoryCell::AgentReasoning { lines } + HistoryCell::AgentReasoning { + view: TextBlock::new(lines), + } } pub(crate) fn new_active_exec_command(call_id: String, command: Vec) -> Self { @@ -187,7 +198,7 @@ impl HistoryCell { call_id, command: command_escaped, start, - lines, + view: TextBlock::new(lines), } } @@ -226,7 +237,9 @@ impl HistoryCell { } lines.push(Line::from("")); - HistoryCell::CompletedExecCommand { lines } + HistoryCell::CompletedExecCommand { + view: TextBlock::new(lines), + } } pub(crate) fn new_active_mcp_tool_call( @@ -267,7 +280,7 @@ impl HistoryCell { fq_tool_name, invocation, start, - lines, + view: TextBlock::new(lines), } } @@ -304,7 +317,9 @@ impl HistoryCell { lines.push(Line::from("")); - HistoryCell::CompletedMcpToolCall { lines } + HistoryCell::CompletedMcpToolCall { + view: TextBlock::new(lines), + } } pub(crate) fn new_background_event(message: String) -> Self { @@ -312,7 +327,9 @@ impl HistoryCell { lines.push(Line::from("event".dim())); lines.extend(message.lines().map(|l| Line::from(l.to_string()).dim())); lines.push(Line::from("")); - HistoryCell::BackgroundEvent { lines } + HistoryCell::BackgroundEvent { + view: TextBlock::new(lines), + } } pub(crate) fn new_error_event(message: String) -> Self { @@ -320,7 +337,9 @@ impl HistoryCell { vec!["ERROR: ".red().bold(), message.into()].into(), "".into(), ]; - HistoryCell::ErrorEvent { lines } + HistoryCell::ErrorEvent { + view: TextBlock::new(lines), + } } /// Create a new `PendingPatch` cell that lists the file‑level summary of @@ -339,7 +358,9 @@ impl HistoryCell { auto_approved: false, } => { let lines = vec![Line::from("patch applied".magenta().bold())]; - return Self::PendingPatch { lines }; + return Self::PendingPatch { + view: TextBlock::new(lines), + }; } }; @@ -380,23 +401,52 @@ impl HistoryCell { lines.push(Line::from("")); - HistoryCell::PendingPatch { lines } + HistoryCell::PendingPatch { + view: TextBlock::new(lines), + } + } +} + +// --------------------------------------------------------------------------- +// `CellWidget` implementation – most variants delegate to their internal +// `TextBlock`. Variants that need custom painting can add their own logic in +// the match arms. +// --------------------------------------------------------------------------- + +impl CellWidget for HistoryCell { + fn height(&self, width: u16) -> usize { + match self { + HistoryCell::WelcomeMessage { view } + | HistoryCell::UserPrompt { view } + | HistoryCell::AgentMessage { view } + | HistoryCell::AgentReasoning { view } + | HistoryCell::BackgroundEvent { view } + | HistoryCell::ErrorEvent { view } + | HistoryCell::SessionInfo { view } + | HistoryCell::CompletedExecCommand { view } + | HistoryCell::CompletedMcpToolCall { view } + | HistoryCell::PendingPatch { view } + | HistoryCell::ActiveExecCommand { view, .. } + | HistoryCell::ActiveMcpToolCall { view, .. } => view.height(width), + } } - pub(crate) fn lines(&self) -> &Vec> { + fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer) { match self { - HistoryCell::WelcomeMessage { lines, .. } - | HistoryCell::UserPrompt { lines, .. } - | HistoryCell::AgentMessage { lines, .. } - | HistoryCell::AgentReasoning { lines, .. } - | HistoryCell::BackgroundEvent { lines, .. } - | HistoryCell::ErrorEvent { lines, .. } - | HistoryCell::SessionInfo { lines, .. } - | HistoryCell::ActiveExecCommand { lines, .. } - | HistoryCell::CompletedExecCommand { lines, .. } - | HistoryCell::ActiveMcpToolCall { lines, .. } - | HistoryCell::CompletedMcpToolCall { lines, .. } - | HistoryCell::PendingPatch { lines, .. } => lines, + HistoryCell::WelcomeMessage { view } + | HistoryCell::UserPrompt { view } + | HistoryCell::AgentMessage { view } + | HistoryCell::AgentReasoning { view } + | HistoryCell::BackgroundEvent { view } + | HistoryCell::ErrorEvent { view } + | HistoryCell::SessionInfo { view } + | HistoryCell::CompletedExecCommand { view } + | HistoryCell::CompletedMcpToolCall { view } + | HistoryCell::PendingPatch { view } + | HistoryCell::ActiveExecCommand { view, .. } + | HistoryCell::ActiveMcpToolCall { view, .. } => { + view.render_window(first_visible_line, area, buf) + } } } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1ddd79cf..df85673e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -19,6 +19,7 @@ mod app; mod app_event; mod app_event_sender; mod bottom_pane; +mod cell_widget; mod chatwidget; mod citation_regex; mod cli; @@ -32,6 +33,7 @@ mod mouse_capture; mod scroll_event_helper; mod slash_command; mod status_indicator_widget; +mod text_block; mod tui; mod user_approval_widget; diff --git a/codex-rs/tui/src/text_block.rs b/codex-rs/tui/src/text_block.rs new file mode 100644 index 00000000..2c68d90f --- /dev/null +++ b/codex-rs/tui/src/text_block.rs @@ -0,0 +1,32 @@ +use crate::cell_widget::CellWidget; +use ratatui::prelude::*; + +/// A simple widget that just displays a list of `Line`s via a `Paragraph`. +/// This is the default rendering backend for most `HistoryCell` variants. +#[derive(Clone)] +pub(crate) struct TextBlock { + pub(crate) lines: Vec>, +} + +impl TextBlock { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + +impl CellWidget for TextBlock { + fn height(&self, width: u16) -> usize { + // Use the same wrapping configuration as ConversationHistoryWidget so + // measurement stays in sync with rendering. + ratatui::widgets::Paragraph::new(self.lines.clone()) + .wrap(crate::conversation_history_widget::wrap_cfg()) + .line_count(width) + } + + fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer) { + ratatui::widgets::Paragraph::new(self.lines.clone()) + .wrap(crate::conversation_history_widget::wrap_cfg()) + .scroll((first_visible_line as u16, 0)) + .render(area, buf); + } +}