Files
codex/prs/bolinfest/PR-1672.md
2025-09-02 15:17:45 -07:00

67 KiB
Raw Blame History

PR #1672: Easily Selectable History

Description

This update replaces the previous ratatui history widget with an append-only log so that the terminal can handle text selection and scrolling. It also disables streaming responses, which we'll do our best to bring back in a later PR. It also adds a small summary of token use after the TUI exits.

Full Diff

diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index d179a142f4..9aa1d2789a 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -850,6 +850,7 @@ dependencies = [
  "tui-markdown",
  "tui-textarea",
  "unicode-segmentation",
+ "unicode-width 0.1.14",
  "uuid",
 ]
 
diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs
index e397b0ca6a..7916a7dc79 100644
--- a/codex-rs/cli/src/main.rs
+++ b/codex-rs/cli/src/main.rs
@@ -105,7 +105,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
         None => {
             let mut tui_cli = cli.interactive;
             prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
-            codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
+            let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
+            println!("{}", codex_core::protocol::FinalOutput::from(usage));
         }
         Some(Subcommand::Exec(mut exec_cli)) => {
             prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);
diff --git a/codex-rs/config.md b/codex-rs/config.md
index 3d38ded1a5..c45d81180d 100644
--- a/codex-rs/config.md
+++ b/codex-rs/config.md
@@ -498,14 +498,5 @@ Options that are specific to the TUI.
 
 ```toml
 [tui]
-# This will make it so that Codex does not try to process mouse events, which
-# means your Terminal's native drag-to-text to text selection and copy/paste
-# should work. The tradeoff is that Codex will not receive any mouse events, so
-# it will not be possible to use the mouse to scroll conversation history.
-#
-# Note that most terminals support holding down a modifier key when using the
-# mouse to support text selection. For example, even if Codex mouse capture is
-# enabled (i.e., this is set to `false`), you can still hold down alt while
-# dragging the mouse to select text.
-disable_mouse_capture = true  # defaults to `false`
+# More to come here

diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 83fe613c86..cba5dcfbb2 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -76,20 +76,7 @@ pub enum HistoryPersistence {

/// Collection of settings that are specific to the TUI. #[derive(Deserialize, Debug, Clone, PartialEq, Default)] -pub struct Tui {

  • /// By default, mouse capture is enabled in the TUI so that it is possible
  • /// to scroll the conversation history with a mouse. This comes at the cost
  • /// of not being able to use the mouse to select text in the TUI.
  • /// (Most terminals support a modifier key to allow this. For example,
  • /// text selection works in iTerm if you hold down the Option key while
  • /// clicking and dragging.)
  • ///
  • /// Setting this option to true disables mouse capture, so scrolling with
  • /// the mouse is not possible, though the keyboard shortcuts e.g. b and
  • /// space still work. This allows the user to select text in the TUI
  • /// using the mouse without needing to hold down a modifier key.
  • pub disable_mouse_capture: bool, -} +pub struct Tui {}

#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)] #[serde(rename_all = "kebab-case")] diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 0c375e455d..ad6686d175 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -4,9 +4,10 @@ //! between user and agent.

use std::collections::HashMap; +use std::fmt; use std::path::Path; use std::path::PathBuf; -use std::str::FromStr; +use std::str::FromStr; // Added for FinalOutput Display implementation

use mcp_types::CallToolResult; use serde::Deserialize; @@ -355,6 +356,36 @@ pub struct TokenUsage { pub total_tokens: u64, }

+#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FinalOutput {

  • pub token_usage: TokenUsage, +}

+impl From for FinalOutput {

  • fn from(token_usage: TokenUsage) -> Self {
  •    Self { token_usage }
    
  • } +}

+impl fmt::Display for FinalOutput {

  • fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  •    let u = &self.token_usage;
    
  •    write!(
    
  •        f,
    
  •        "Token usage: total={} input={}{} output={}{}",
    
  •        u.total_tokens,
    
  •        u.input_tokens,
    
  •        u.cached_input_tokens
    
  •            .map(|c| format!(" (cached {c})"))
    
  •            .unwrap_or_default(),
    
  •        u.output_tokens,
    
  •        u.reasoning_output_tokens
    
  •            .map(|r| format!(" (reasoning {r})"))
    
  •            .unwrap_or_default()
    
  •    )
    
  • } +}

#[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentMessageEvent { pub message: String, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b2f2b9b653..9d73e3b386 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -58,6 +58,7 @@ tui-input = "0.14.0" tui-markdown = "0.3.3" tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" +unicode-width = "0.1" uuid = "1"

[dev-dependencies] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 377b5d6f0b..ee14e7bb37 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -6,7 +6,6 @@ use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; use crate::login_screen::LoginScreen; -use crate::mouse_capture::MouseCapture; use crate::scroll_event_helper::ScrollEventHelper; use crate::slash_command::SlashCommand; use crate::tui; @@ -197,17 +196,17 @@ impl App<'_> { }); }

  • pub(crate) fn run(
  •    &mut self,
    
  •    terminal: &mut tui::Tui,
    
  •    mouse_capture: &mut MouseCapture,
    
  • ) -> Result<()> {
  • pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { // Insert an event to trigger the first render. let app_event_tx = self.app_event_tx.clone(); app_event_tx.send(AppEvent::RequestRedraw);

    while let Ok(event) = self.app_event_rx.recv() {
        match event {
    
  •            AppEvent::InsertHistory(lines) => {
    
  •                crate::insert_history::insert_history_lines(terminal, lines);
    
  •                self.app_event_tx.send(AppEvent::RequestRedraw);
    
  •            }
               AppEvent::RequestRedraw => {
                   self.schedule_redraw();
               }
    

@@ -287,11 +286,6 @@ impl App<'_> { self.app_state = AppState::Chat { widget: new_widget }; self.app_event_tx.send(AppEvent::RequestRedraw); }

  •                SlashCommand::ToggleMouseMode => {
    
  •                    if let Err(e) = mouse_capture.toggle() {
    
  •                        tracing::error!("Failed to toggle mouse mode: {e}");
    
  •                    }
    
  •                }
                   SlashCommand::Quit => {
                       break;
                   }
    

@@ -332,6 +326,15 @@ impl App<'_> { Ok(()) }

  • pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
  •    match &self.app_state {
    
  •        AppState::Chat { widget } => widget.token_usage().clone(),
    
  •        AppState::Login { .. } | AppState::GitWarning { .. } => {
    
  •            codex_core::protocol::TokenUsage::default()
    
  •        }
    
  •    }
    
  • }
  • fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> { // TODO: add a throttle to avoid redrawing too often

diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 3aaa789760..a1f304fe42 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,6 +1,7 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; use crossterm::event::KeyEvent; +use ratatui::text::Line;

use crate::slash_command::SlashCommand;

@@ -49,4 +50,6 @@ pub(crate) enum AppEvent { query: String, matches: Vec, }, +

  • InsertHistory(Vec<Line<'static>>), } diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index ca33047b1f..ba5b07b93c 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -50,10 +50,6 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { self.current.is_complete() && self.queue.is_empty() }
  • fn calculate_required_height(&self, area: &Rect) -> u16 {

  •    self.current.get_height(area)
    
  • }

  • fn render(&self, area: Rect, buf: &mut Buffer) { (&self.current).render_ref(area, buf); } diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 6abf5399f5..677d6db95b 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -22,9 +22,6 @@ pub(crate) trait BottomPaneView<'a> { false }

  • /// Height required to render the view.

  • fn calculate_required_height(&self, area: &Rect) -> u16;

  • /// Render the view: this will be displayed in place of the composer. fn render(&self, area: Rect, buf: &mut Buffer);

diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b49bce4046..bdfb6a23e2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -22,11 +22,6 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use codex_file_search::FileMatch;

-/// Minimum number of visible text rows inside the textarea. -const MIN_TEXTAREA_ROWS: usize = 1; -/// Rows consumed by the border. -const BORDER_LINES: u16 = 2;

const BASE_PLACEHOLDER_TEXT: &str = "send a message"; /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. @@ -609,17 +604,6 @@ impl ChatComposer<'_> { self.dismissed_file_popup_token = None; }

  • pub fn calculate_required_height(&self, area: &Rect) -> u16 {
  •    let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS);
    
  •    let num_popup_rows = match &self.active_popup {
    
  •        ActivePopup::Command(popup) => popup.calculate_required_height(area),
    
  •        ActivePopup::File(popup) => popup.calculate_required_height(area),
    
  •        ActivePopup::None => 0,
    
  •    };
    
  •    rows as u16 + BORDER_LINES + num_popup_rows
    
  • }
  • fn update_border(&mut self, has_focus: bool) { struct BlockState { right_title: Line<'static>, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2a91655cc5..ebec534f21 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -65,10 +65,8 @@ impl BottomPane<'_> { if !view.is_complete() { self.active_view = Some(view); } else if self.is_task_running {
  •            let height = self.composer.calculate_required_height(&Rect::default());
               self.active_view = Some(Box::new(StatusIndicatorView::new(
                   self.app_event_tx.clone(),
    
  •                height,
               )));
           }
           self.request_redraw();
    

@@ -138,10 +136,8 @@ impl BottomPane<'_> { match (running, self.active_view.is_some()) { (true, false) => { // Show status indicator overlay.

  •            let height = self.composer.calculate_required_height(&Rect::default());
               self.active_view = Some(Box::new(StatusIndicatorView::new(
                   self.app_event_tx.clone(),
    
  •                height,
               )));
               self.request_redraw();
           }
    

@@ -203,14 +199,6 @@ impl BottomPane<'_> { }

 /// Height (terminal rows) required by the current bottom pane.
  • pub fn calculate_required_height(&self, area: &Rect) -> u16 {
  •    if let Some(view) = &self.active_view {
    
  •        view.calculate_required_height(area)
    
  •    } else {
    
  •        self.composer.calculate_required_height(area)
    
  •    }
    
  • }
  • pub(crate) fn request_redraw(&self) { self.app_event_tx.send(AppEvent::RequestRedraw) } diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs index de46ac2709..f8c06ec5e5 100644 --- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -1,5 +1,4 @@ use ratatui::buffer::Buffer; -use ratatui::layout::Rect; use ratatui::widgets::WidgetRef;

use crate::app_event_sender::AppEventSender; @@ -13,9 +12,9 @@ pub(crate) struct StatusIndicatorView { }

impl StatusIndicatorView {

  • pub fn new(app_event_tx: AppEventSender, height: u16) -> Self {
  • pub fn new(app_event_tx: AppEventSender) -> Self { Self {
  •        view: StatusIndicatorWidget::new(app_event_tx, height),
    
  •        view: StatusIndicatorWidget::new(app_event_tx),
       }
    
    }

@@ -34,11 +33,7 @@ impl BottomPaneView<'_> for StatusIndicatorView { true }

  • fn calculate_required_height(&self, _area: &Rect) -> u16 {
  •    self.view.get_height()
    
  • }
  • fn render(&self, area: Rect, buf: &mut Buffer) {
  • fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) { self.view.render_ref(area, buf); } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 081a406f29..6744707319 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -23,9 +23,6 @@ use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Direction; -use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; @@ -52,6 +49,9 @@ pub(crate) struct ChatWidget<'a> { initial_user_message: Option, token_usage: TokenUsage, reasoning_buffer: String,
  • // Buffer for streaming assistant answer text; we do not surface partial
  • // We wait for the final AgentMessage event and then emit the full text
  • // at once into scrollback so the history contains a single message. answer_buffer: String, }

@@ -187,6 +187,13 @@ impl ChatWidget<'_> { } }

  • /// Emits the last entry's plain lines from conversation_history, if any.

  • fn emit_last_history_entry(&mut self) {

  •    if let Some(lines) = self.conversation_history.last_entry_plain_lines() {
    
  •        self.app_event_tx.send(AppEvent::InsertHistory(lines));
    
  •    }
    
  • }

  • fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; let mut items: Vec = Vec::new(); @@ -220,7 +227,8 @@ impl ChatWidget<'_> {

       // Only show text portion in conversation history for now.
       if !text.is_empty() {
    
  •        self.conversation_history.add_user_message(text);
    
  •        self.conversation_history.add_user_message(text.clone());
    
  •        self.emit_last_history_entry();
       }
       self.conversation_history.scroll_to_bottom();
    
    } @@ -232,6 +240,10 @@ impl ChatWidget<'_> { // Record session information at the top of the conversation. self.conversation_history .add_session_info(&self.config, event.clone());
  •            // Immediately surface the session banner / settings summary in
    
  •            // scrollback so the user can review configuration (model,
    
  •            // sandbox, approvals, etc.) before interacting.
    
  •            self.emit_last_history_entry();
    
               // Forward history metadata to the bottom pane so the chat
               // composer can navigate through past messages.
    

@@ -247,50 +259,50 @@ impl ChatWidget<'_> { self.request_redraw(); } EventMsg::AgentMessage(AgentMessageEvent { message }) => {

  •            // if the answer buffer is empty, this means we haven't received any
    
  •            // delta. Thus, we need to print the message as a new answer.
    
  •            if self.answer_buffer.is_empty() {
    
  •                self.conversation_history
    
  •                    .add_agent_message(&self.config, message);
    
  •            // Final assistant answer. Prefer the fully provided message
    
  •            // from the event; if it is empty fall back to any accumulated
    
  •            // delta buffer (some providers may only stream deltas and send
    
  •            // an empty final message).
    
  •            let full = if message.is_empty() {
    
  •                std::mem::take(&mut self.answer_buffer)
               } else {
    
  •                self.answer_buffer.clear();
    
  •                message
    
  •            };
    
  •            if !full.is_empty() {
                   self.conversation_history
    
  •                    .replace_prev_agent_message(&self.config, message);
    
  •                    .add_agent_message(&self.config, full);
    
  •                self.emit_last_history_entry();
               }
    
  •            self.answer_buffer.clear();
               self.request_redraw();
           }
           EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
    
  •            if self.answer_buffer.is_empty() {
    
  •                self.conversation_history
    
  •                    .add_agent_message(&self.config, "".to_string());
    
  •            }
    
  •            self.answer_buffer.push_str(&delta.clone());
    
  •            self.conversation_history
    
  •                .replace_prev_agent_message(&self.config, self.answer_buffer.clone());
    
  •            self.request_redraw();
    
  •            // Buffer only  do not emit partial lines. This avoids cases
    
  •            // where long responses appear truncated if the terminal
    
  •            // wrapped early. The full message is emitted on
    
  •            // AgentMessage.
    
  •            self.answer_buffer.push_str(&delta);
           }
           EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
    
  •            if self.reasoning_buffer.is_empty() {
    
  •                self.conversation_history
    
  •                    .add_agent_reasoning(&self.config, "".to_string());
    
  •            }
    
  •            self.reasoning_buffer.push_str(&delta.clone());
    
  •            self.conversation_history
    
  •                .replace_prev_agent_reasoning(&self.config, self.reasoning_buffer.clone());
    
  •            self.request_redraw();
    
  •            // Buffer only  disable incremental reasoning streaming so we
    
  •            // avoid truncated intermediate lines. Full text emitted on
    
  •            // AgentReasoning.
    
  •            self.reasoning_buffer.push_str(&delta);
           }
           EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
    
  •            // if the reasoning buffer is empty, this means we haven't received any
    
  •            // delta. Thus, we need to print the message as a new reasoning.
    
  •            if self.reasoning_buffer.is_empty() {
    
  •                self.conversation_history
    
  •                    .add_agent_reasoning(&self.config, "".to_string());
    
  •            // Emit full reasoning text once. Some providers might send
    
  •            // final event with empty text if only deltas were used.
    
  •            let full = if text.is_empty() {
    
  •                std::mem::take(&mut self.reasoning_buffer)
               } else {
    
  •                // else, we rerender one last time.
    
  •                self.reasoning_buffer.clear();
    
  •                text
    
  •            };
    
  •            if !full.is_empty() {
                   self.conversation_history
    
  •                    .replace_prev_agent_reasoning(&self.config, text);
    
  •                    .add_agent_reasoning(&self.config, full);
    
  •                self.emit_last_history_entry();
               }
    
  •            self.reasoning_buffer.clear();
               self.request_redraw();
           }
           EventMsg::TaskStarted => {
    

@@ -310,7 +322,8 @@ impl ChatWidget<'_> { .set_token_usage(self.token_usage.clone(), self.config.model_context_window); } EventMsg::Error(ErrorEvent { message }) => {

  •            self.conversation_history.add_error(message);
    
  •            self.conversation_history.add_error(message.clone());
    
  •            self.emit_last_history_entry();
               self.bottom_pane.set_task_running(false);
           }
           EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
    

@@ -346,6 +359,7 @@ impl ChatWidget<'_> {

             self.conversation_history
                 .add_patch_event(PatchEventType::ApprovalRequest, changes);
  •            self.emit_last_history_entry();
    
               self.conversation_history.scroll_to_bottom();
    

@@ -364,7 +378,8 @@ impl ChatWidget<'_> { cwd: _, }) => { self.conversation_history

  •                .reset_or_add_active_exec_command(call_id, command);
    
  •                .add_active_exec_command(call_id, command);
    
  •            self.emit_last_history_entry();
               self.request_redraw();
           }
           EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
    

@@ -376,6 +391,7 @@ impl ChatWidget<'_> { // summary so the user can follow along. self.conversation_history .add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes);

  •            self.emit_last_history_entry();
               if !auto_approved {
                   self.conversation_history.scroll_to_bottom();
               }
    

@@ -399,6 +415,7 @@ impl ChatWidget<'_> { }) => { self.conversation_history .add_active_mcp_tool_call(call_id, server, tool, arguments);

  •            self.emit_last_history_entry();
               self.request_redraw();
           }
           EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => {
    

@@ -425,6 +442,7 @@ impl ChatWidget<'_> { event => { self.conversation_history .add_background_event(format!("{event:?}"));

  •            self.emit_last_history_entry();
               self.request_redraw();
           }
       }
    

@@ -441,7 +459,9 @@ impl ChatWidget<'_> { }

 pub(crate) fn add_diff_output(&mut self, diff_output: String) {
  •    self.conversation_history.add_diff_output(diff_output);
    
  •    self.conversation_history
    
  •        .add_diff_output(diff_output.clone());
    
  •    self.emit_last_history_entry();
       self.request_redraw();
    
    }

@@ -492,19 +512,18 @@ impl ChatWidget<'_> { tracing::error!("failed to submit op: {e}"); } } +

  • pub(crate) fn token_usage(&self) -> &TokenUsage {
  •    &self.token_usage
    
  • } }

impl WidgetRef for &ChatWidget<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) {

  •    let bottom_height = self.bottom_pane.calculate_required_height(&area);
    
  •    let chunks = Layout::default()
    
  •        .direction(Direction::Vertical)
    
  •        .constraints([Constraint::Min(0), Constraint::Length(bottom_height)])
    
  •        .split(area);
    
  •    self.conversation_history.render(chunks[0], buf);
    
  •    (&self.bottom_pane).render(chunks[1], buf);
    
  •    // In the hybrid inline viewport mode we only draw the interactive
    
  •    // bottom pane; history entries are injected directly into scrollback
    
  •    // via `Terminal::insert_before`.
    
  •    (&self.bottom_pane).render(area, buf);
    
    } }

diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index ceaf115f33..d8035eff64 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -202,14 +202,6 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_agent_reasoning(config, text)); }

  • pub fn replace_prev_agent_reasoning(&mut self, config: &Config, text: String) {

  •    self.replace_last_agent_reasoning(config, text);
    
  • }

  • pub fn replace_prev_agent_message(&mut self, config: &Config, text: String) {

  •    self.replace_last_agent_message(config, text);
    
  • }

  • pub fn add_background_event(&mut self, message: String) { self.add_to_history(HistoryCell::new_background_event(message)); } @@ -235,30 +227,6 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_active_exec_command(call_id, command)); }

  • /// If an ActiveExecCommand with the same call_id already exists, replace

  • /// it with a fresh one (resetting start time and view). Otherwise, add a new entry.

  • pub fn reset_or_add_active_exec_command(&mut self, call_id: String, command: Vec) {

  •    // Find the most recent matching ActiveExecCommand.
    
  •    let maybe_idx = self.entries.iter().rposition(|entry| {
    
  •        if let HistoryCell::ActiveExecCommand { call_id: id, .. } = &entry.cell {
    
  •            id == &call_id
    
  •        } else {
    
  •            false
    
  •        }
    
  •    });
    
  •    if let Some(idx) = maybe_idx {
    
  •        let width = self.cached_width.get();
    
  •        self.entries[idx].cell = HistoryCell::new_active_exec_command(call_id.clone(), command);
    
  •        if width > 0 {
    
  •            let height = self.entries[idx].cell.height(width);
    
  •            self.entries[idx].line_count.set(height);
    
  •        }
    
  •    } else {
    
  •        self.add_active_exec_command(call_id, command);
    
  •    }
    
  • }

  • pub fn add_active_mcp_tool_call( &mut self, call_id: String, @@ -281,40 +249,10 @@ impl ConversationHistoryWidget { }); }

  • pub fn replace_last_agent_reasoning(&mut self, config: &Config, text: String) {

  •    if let Some(idx) = self
    
  •        .entries
    
  •        .iter()
    
  •        .rposition(|entry| matches!(entry.cell, HistoryCell::AgentReasoning { .. }))
    
  •    {
    
  •        let width = self.cached_width.get();
    
  •        let entry = &mut self.entries[idx];
    
  •        entry.cell = HistoryCell::new_agent_reasoning(config, text);
    
  •        let height = if width > 0 {
    
  •            entry.cell.height(width)
    
  •        } else {
    
  •            0
    
  •        };
    
  •        entry.line_count.set(height);
    
  •    }
    
  • }

  • pub fn replace_last_agent_message(&mut self, config: &Config, text: String) {

  •    if let Some(idx) = self
    
  •        .entries
    
  •        .iter()
    
  •        .rposition(|entry| matches!(entry.cell, HistoryCell::AgentMessage { .. }))
    
  •    {
    
  •        let width = self.cached_width.get();
    
  •        let entry = &mut self.entries[idx];
    
  •        entry.cell = HistoryCell::new_agent_message(config, text);
    
  •        let height = if width > 0 {
    
  •            entry.cell.height(width)
    
  •        } else {
    
  •            0
    
  •        };
    
  •        entry.line_count.set(height);
    
  •    }
    
  • /// Return the lines for the most recently appended entry (if any) so the

  • /// parent widget can surface them via the new scrollback insertion path.

  • pub(crate) fn last_entry_plain_lines(&self) -> Option<Vec<Line<'static>>> {

  •    self.entries.last().map(|e| e.cell.plain_lines())
    

    }

    pub fn record_completed_exec_command( diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b481313405..df58e163f3 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -123,6 +123,30 @@ pub(crate) enum HistoryCell { const TOOL_CALL_MAX_LINES: usize = 5;

impl HistoryCell {

  • /// Return a cloned, plain representation of the cell's lines suitable for
  • /// oneshot insertion into the terminal scrollback. Image cells are
  • /// represented with a simple placeholder for now.
  • pub(crate) fn plain_lines(&self) -> Vec<Line<'static>> {
  •    match self {
    
  •        HistoryCell::WelcomeMessage { view }
    
  •        | HistoryCell::UserPrompt { view }
    
  •        | HistoryCell::AgentMessage { view }
    
  •        | HistoryCell::AgentReasoning { view }
    
  •        | HistoryCell::BackgroundEvent { view }
    
  •        | HistoryCell::GitDiffOutput { view }
    
  •        | HistoryCell::ErrorEvent { view }
    
  •        | HistoryCell::SessionInfo { view }
    
  •        | HistoryCell::CompletedExecCommand { view }
    
  •        | HistoryCell::CompletedMcpToolCall { view }
    
  •        | HistoryCell::PendingPatch { view }
    
  •        | HistoryCell::ActiveExecCommand { view, .. }
    
  •        | HistoryCell::ActiveMcpToolCall { view, .. } => view.lines.clone(),
    
  •        HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
    
  •            Line::from("tool result (image output omitted)"),
    
  •            Line::from(""),
    
  •        ],
    
  •    }
    
  • } pub(crate) fn new_session_info( config: &Config, event: SessionConfiguredEvent, diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs new file mode 100644 index 0000000000..247e024cb0 --- /dev/null +++ b/codex-rs/tui/src/insert_history.rs @@ -0,0 +1,178 @@ +use crate::tui; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthChar;

+/// Insert a batch of history lines into the terminal scrollback above the +/// inline viewport. +/// +/// The incoming lines are the logical lines supplied by the +/// ConversationHistory. They may contain embedded newlines and arbitrary +/// runs of whitespace inside individual [Span]s. All of that must be +/// normalised before writing to the backing terminal buffer because the +/// ratatui [Paragraph] widget does not perform softwrapping when used in +/// conjunction with [Terminal::insert_before]. +/// +/// This function performs a minimal wrapping / normalisation pass: +/// +/// * A terminal width is determined via Terminal::size() (falling back to +/// 80 columns if the size probe fails). +/// * Each logical line is broken into words and whitespace. Consecutive +/// whitespace is collapsed to a single space; leading whitespace is +/// discarded. +/// * Words that do not fit on the current line cause a soft wrap. Extremely +/// long words (longer than the terminal width) are split character by +/// character so they still populate the display instead of overflowing the +/// line. +/// * Explicit \n characters inside a span force a hard line break. +/// * Empty lines (including a trailing newline at the end of the batch) are +/// preserved so vertical spacing remains faithful to the logical history. +/// +/// Finally the physical lines are rendered directly into the terminal's +/// scrollback region using [Terminal::insert_before]. Any backend error is +/// ignored: failing to insert history is nonfatal and a subsequent redraw +/// will eventually repaint a consistent view. +fn display_width(s: &str) -> usize {

  • s.chars()
  •    .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
    
  •    .sum()
    

+} + +struct LineBuilder {

  • term_width: usize,
  • spans: Vec<Span<'static>>,
  • width: usize, +}

+impl LineBuilder {

  • fn new(term_width: usize) -> Self {
  •    Self {
    
  •        term_width,
    
  •        spans: Vec::new(),
    
  •        width: 0,
    
  •    }
    
  • }
  • fn flush_line(&mut self, out: &mut Vec<Line<'static>>) {
  •    out.push(Line::from(std::mem::take(&mut self.spans)));
    
  •    self.width = 0;
    
  • }
  • fn push_segment(&mut self, text: String, style: Style) {
  •    self.width += display_width(&text);
    
  •    self.spans.push(Span::styled(text, style));
    
  • }
  • fn push_word(&mut self, word: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
  •    if word.is_empty() {
    
  •        return;
    
  •    }
    
  •    let w_len = display_width(word);
    
  •    if self.width > 0 && self.width + w_len > self.term_width {
    
  •        self.flush_line(out);
    
  •    }
    
  •    if w_len > self.term_width && self.width == 0 {
    
  •        // Split an overlong word across multiple lines.
    
  •        let mut cur = String::new();
    
  •        let mut cur_w = 0;
    
  •        for ch in word.chars() {
    
  •            let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
    
  •            if cur_w + ch_w > self.term_width && cur_w > 0 {
    
  •                self.push_segment(cur.clone(), style);
    
  •                self.flush_line(out);
    
  •                cur.clear();
    
  •                cur_w = 0;
    
  •            }
    
  •            cur.push(ch);
    
  •            cur_w += ch_w;
    
  •        }
    
  •        if !cur.is_empty() {
    
  •            self.push_segment(cur, style);
    
  •        }
    
  •    } else {
    
  •        self.push_segment(word.clone(), style);
    
  •    }
    
  •    word.clear();
    
  • }
  • fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
  •    if ws.is_empty() {
    
  •        return;
    
  •    }
    
  •    let space_w = display_width(ws);
    
  •    if self.width > 0 && self.width + space_w > self.term_width {
    
  •        self.flush_line(out);
    
  •    }
    
  •    if self.width > 0 {
    
  •        self.push_segment(" ".to_string(), style);
    
  •    }
    
  •    ws.clear();
    
  • } +}

+pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {

  • let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize;
  • let mut physical: Vec<Line<'static>> = Vec::new();
  • for logical in lines.into_iter() {
  •    if logical.spans.is_empty() {
    
  •        physical.push(logical);
    
  •        continue;
    
  •    }
    
  •    let mut builder = LineBuilder::new(term_width);
    
  •    let mut buf_space = String::new();
    
  •    for span in logical.spans.into_iter() {
    
  •        let style = span.style;
    
  •        let mut buf_word = String::new();
    
  •        for ch in span.content.chars() {
    
  •            if ch == '\n' {
    
  •                builder.push_word(&mut buf_word, style, &mut physical);
    
  •                buf_space.clear();
    
  •                builder.flush_line(&mut physical);
    
  •                continue;
    
  •            }
    
  •            if ch.is_whitespace() {
    
  •                builder.push_word(&mut buf_word, style, &mut physical);
    
  •                buf_space.push(ch);
    
  •            } else {
    
  •                builder.consume_whitespace(&mut buf_space, style, &mut physical);
    
  •                buf_word.push(ch);
    
  •            }
    
  •            if builder.width >= term_width {
    
  •                builder.flush_line(&mut physical);
    
  •            }
    
  •        }
    
  •        builder.push_word(&mut buf_word, style, &mut physical);
    
  •        // whitespace intentionally left to allow collapsing across spans
    
  •    }
    
  •    if !builder.spans.is_empty() {
    
  •        physical.push(Line::from(std::mem::take(&mut builder.spans)));
    
  •    } else {
    
  •        // Preserve explicit blank line (e.g. due to a trailing newline).
    
  •        physical.push(Line::from(Vec::<Span<'static>>::new()));
    
  •    }
    
  • }
  • let total = physical.len() as u16;
  • terminal
  •    .insert_before(total, |buf| {
    
  •        let width = buf.area.width;
    
  •        for (i, line) in physical.into_iter().enumerate() {
    
  •            let area = Rect {
    
  •                x: 0,
    
  •                y: i as u16,
    
  •                width,
    
  •                height: 1,
    
  •            };
    
  •            Paragraph::new(line).render(area, buf);
    
  •        }
    
  •    })
    
  •    .ok();
    

+} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 05a55edc7b..905f0aaf0b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -33,10 +33,10 @@ mod file_search; mod get_git_diff; mod git_warning_screen; mod history_cell; +mod insert_history; mod log_layer; mod login_screen; mod markdown; -mod mouse_capture; mod scroll_event_helper; mod slash_command; mod status_indicator_widget; @@ -47,7 +47,10 @@ mod user_approval_widget;

pub use cli::Cli;

-pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> std::io::Result<()> { +pub fn run_main(

  • cli: Cli,
  • codex_linux_sandbox_exe: Option, +) -> std::io::Result<codex_core::protocol::TokenUsage> { let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -147,24 +150,8 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> std::io:: // --allow-no-git-exec flag. let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
  • try_run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx);
  • Ok(()) -}

-#[expect(

  • clippy::print_stderr,
  • reason = "Resort to stderr in exceptional situations." -)] -fn try_run_ratatui_app(
  • cli: Cli,
  • config: Config,
  • show_login_screen: bool,
  • show_git_warning: bool,
  • log_rx: tokio::sync::mpsc::UnboundedReceiver, -) {
  • if let Err(report) = run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) {
  •    eprintln!("Error: {report:?}");
    
  • }
  • run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
  •    .map_err(|err| std::io::Error::other(err.to_string()))
    

}

fn run_ratatui_app( @@ -173,16 +160,15 @@ fn run_ratatui_app( show_login_screen: bool, show_git_warning: bool, mut log_rx: tokio::sync::mpsc::UnboundedReceiver, -) -> color_eyre::Result<()> { +) -> color_eyre::Result<codex_core::protocol::TokenUsage> { color_eyre::install()?;

  • // Forward panic reports through the tracing stack so that they appear in
  • // the status indicator instead of breaking the alternate screen the
  • // normal coloureyre hook writes to stderr which would corrupt the UI.
  • // Forward panic reports through tracing so they appear in the UI status
  • // line instead of interleaving raw panic output with the interface. std::panic::set_hook(Box::new(|info| { tracing::error!("panic: {info}"); }));
  • let (mut terminal, mut mouse_capture) = tui::init(&config)?;
  • let mut terminal = tui::init(&config)?; terminal.clear()?;

    let Cli { prompt, images, .. } = cli; @@ -204,10 +190,12 @@ fn run_ratatui_app( }); }

  • let app_result = app.run(&mut terminal, &mut mouse_capture);
  • let app_result = app.run(&mut terminal);

  • let usage = app.token_usage();

    restore();

  • app_result
  • // ignore error when collecting usage report underlying error instead
  • app_result.map(|_| usage) }

#[expect( diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 7fcc944504..fdb3cdaf82 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -20,7 +20,8 @@ fn main() -> anyhow::Result<()> { .config_overrides .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides);

  •    run_main(inner, codex_linux_sandbox_exe)?;
    
  •    let usage = run_main(inner, codex_linux_sandbox_exe)?;
    
  •    println!("{}", codex_core::protocol::FinalOutput::from(usage));
       Ok(())
    
    }) } diff --git a/codex-rs/tui/src/mouse_capture.rs b/codex-rs/tui/src/mouse_capture.rs deleted file mode 100644 index cff1296f6d..0000000000 --- a/codex-rs/tui/src/mouse_capture.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crossterm::event::DisableMouseCapture; -use crossterm::event::EnableMouseCapture; -use ratatui::crossterm::execute; -use std::io::Result; -use std::io::stdout;

-pub(crate) struct MouseCapture {

  • mouse_capture_is_active: bool, -}

-impl MouseCapture {

  • pub(crate) fn new_with_capture(mouse_capture_is_active: bool) -> Result {
  •    if mouse_capture_is_active {
    
  •        enable_capture()?;
    
  •    }
    
  •    Ok(Self {
    
  •        mouse_capture_is_active,
    
  •    })
    
  • } -}

-impl MouseCapture {

  • /// Idempotent method to set the mouse capture state.
  • pub fn set_active(&mut self, is_active: bool) -> Result<()> {
  •    match (self.mouse_capture_is_active, is_active) {
    
  •        (true, true) => {}
    
  •        (false, false) => {}
    
  •        (true, false) => {
    
  •            disable_capture()?;
    
  •            self.mouse_capture_is_active = false;
    
  •        }
    
  •        (false, true) => {
    
  •            enable_capture()?;
    
  •            self.mouse_capture_is_active = true;
    
  •        }
    
  •    }
    
  •    Ok(())
    
  • }
  • pub(crate) fn toggle(&mut self) -> Result<()> {
  •    self.set_active(!self.mouse_capture_is_active)
    
  • }
  • pub(crate) fn disable(&mut self) -> Result<()> {
  •    if self.mouse_capture_is_active {
    
  •        disable_capture()?;
    
  •        self.mouse_capture_is_active = false;
    
  •    }
    
  •    Ok(())
    
  • } -}

-impl Drop for MouseCapture {

  • fn drop(&mut self) {
  •    if self.disable().is_err() {
    
  •        // The user is likely shutting down, so ignore any errors so the
    
  •        // shutdown process can complete.
    
  •    }
    
  • } -}

-fn enable_capture() -> Result<()> {

  • execute!(stdout(), EnableMouseCapture) -}

-fn disable_capture() -> Result<()> {

  • execute!(stdout(), DisableMouseCapture) -} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bb72ce561c..603eb721cd 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,7 +15,6 @@ pub enum SlashCommand { New, Diff, Quit,
  • ToggleMouseMode, }

impl SlashCommand { @@ -23,9 +22,6 @@ impl SlashCommand { pub fn description(self) -> &'static str { match self { SlashCommand::New => "Start a new chat.",

  •        SlashCommand::ToggleMouseMode => {
    
  •            "Toggle mouse mode (enable for scrolling, disable for text selection)"
    
  •        }
           SlashCommand::Quit => "Exit the application.",
           SlashCommand::Diff => {
               "Show git diff of the working directory (including untracked files)"
    

diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index dda61d0bd0..973ef09818 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -34,11 +34,6 @@ pub(crate) struct StatusIndicatorWidget { /// time). text: String,

  • /// Height in terminal rows matches the height of the textarea at the
  • /// moment the task started so the UI does not jump when we toggle between
  • /// input mode and loading mode.
  • height: u16,
  • frame_idx: Arc, running: Arc, // Keep one sender alive to prevent the channel from closing while the @@ -50,7 +45,7 @@ pub(crate) struct StatusIndicatorWidget {

impl StatusIndicatorWidget { /// Create a new status indicator and start the animation timer.

  • pub(crate) fn new(app_event_tx: AppEventSender, height: u16) -> Self {
  • pub(crate) fn new(app_event_tx: AppEventSender) -> Self { let frame_idx = Arc::new(AtomicUsize::new(0)); let running = Arc::new(AtomicBool::new(true));

@@ -72,18 +67,12 @@ impl StatusIndicatorWidget {

     Self {
         text: String::from("waiting for logs…"),
  •        height: height.max(3),
           frame_idx,
           running,
           _app_event_tx: app_event_tx,
       }
    

    }

  • /// Preferred height in terminal rows.

  • pub(crate) fn get_height(&self) -> u16 {

  •    self.height
    
  • }

  • /// Update the line that is displayed in the widget. pub(crate) fn update_text(&mut self, text: String) { self.text = text.replace(['\n', '\r'], " "); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 99ff034361..66ae1cfb96 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -4,31 +4,39 @@ use std::io::stdout;

use codex_core::config::Config; use crossterm::event::DisableBracketedPaste; -use crossterm::event::DisableMouseCapture; use crossterm::event::EnableBracketedPaste; use ratatui::Terminal; +use ratatui::TerminalOptions; +use ratatui::Viewport; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; -use ratatui::crossterm::terminal::EnterAlternateScreen; -use ratatui::crossterm::terminal::LeaveAlternateScreen; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode;

-use crate::mouse_capture::MouseCapture;

/// A type alias for the terminal type used in this application pub type Tui = Terminal<CrosstermBackend>;

-/// Initialize the terminal -pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> {

  • execute!(stdout(), EnterAlternateScreen)?; +/// Initialize the terminal (inline viewport; history stays in normal scrollback) +pub fn init(_config: &Config) -> Result { execute!(stdout(), EnableBracketedPaste)?;

  • let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?;

    enable_raw_mode()?; set_panic_hook();

  • let tui = Terminal::new(CrosstermBackend::new(stdout()))?;

  • Ok((tui, mouse_capture))

  • // Reserve a fixed number of lines for the interactive viewport (composer,
  • // status, popups). History is injected above using insert_before. This
  • // is an initial step of the refactor later the height can become
  • // dynamic. For now a conservative default keeps enough room for the
  • // multiline composer while not occupying the whole screen.
  • const BOTTOM_VIEWPORT_HEIGHT: u16 = 8;
  • let backend = CrosstermBackend::new(stdout());
  • let tui = Terminal::with_options(
  •    backend,
    
  •    TerminalOptions {
    
  •        viewport: Viewport::Inline(BOTTOM_VIEWPORT_HEIGHT),
    
  •    },
    
  • )?;
  • Ok(tui) }

fn set_panic_hook() { @@ -41,14 +49,7 @@ fn set_panic_hook() {

/// Restore the terminal to its original state pub fn restore() -> Result<()> {

  • // We are shutting down, and we cannot reference the MouseCapture, so we
  • // categorically disable mouse capture just to be safe.
  • if execute!(stdout(), DisableMouseCapture).is_err() {
  •    // It is possible that `DisableMouseCapture` is written more than once
    
  •    // on shutdown, so ignore the error in this case.
    
  • } execute!(stdout(), DisableBracketedPaste)?;
  • execute!(stdout(), LeaveAlternateScreen)?; disable_raw_mode()?; Ok(()) } diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index 6604daace8..431f85a268 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -116,10 +116,6 @@ pub(crate) struct UserApprovalWidget<'a> { done: bool, }

-// Number of lines automatically added by ratatuis [Block] when -// borders are enabled (one at the top, one at the bottom). -const BORDER_LINES: u16 = 2;

impl UserApprovalWidget<'> { pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { let input = Input::default(); @@ -190,28 +186,6 @@ impl UserApprovalWidget<'> { } }

  • pub(crate) fn get_height(&self, area: &Rect) -> u16 {
  •    let confirmation_prompt_height =
    
  •        self.get_confirmation_prompt_height(area.width - BORDER_LINES);
    
  •    match self.mode {
    
  •        Mode::Select => {
    
  •            let num_option_lines = SELECT_OPTIONS.len() as u16;
    
  •            confirmation_prompt_height + num_option_lines + BORDER_LINES
    
  •        }
    
  •        Mode::Input => {
    
  •            //   1. "Give the model feedback ..." prompt
    
  •            //   2. A singleline input field (we allocate exactly one row;
    
  •            //      the `tui-input` widget will scroll horizontally if the
    
  •            //      text exceeds the width).
    
  •            const INPUT_PROMPT_LINES: u16 = 1;
    
  •            const INPUT_FIELD_LINES: u16 = 1;
    
  •            confirmation_prompt_height + INPUT_PROMPT_LINES + INPUT_FIELD_LINES + BORDER_LINES
    
  •        }
    
  •    }
    
  • }
  • fn get_confirmation_prompt_height(&self, width: u16) -> u16 { // Should cache this for last value of width. self.confirmation_prompt.line_count(width) as u16 @@ -333,7 +307,32 @@ impl WidgetRef for &UserApprovalWidget<'_> { .borders(Borders::ALL) .border_type(BorderType::Rounded); let inner = outer.inner(area);
  •    let prompt_height = self.get_confirmation_prompt_height(inner.width);
    
  •    // Determine how many rows we can allocate for the static confirmation
    
  •    // prompt while *always* keeping enough space for the interactive
    
  •    // response area (select list or input field). When the full prompt
    
  •    // would exceed the available height we truncate it so the response
    
  •    // options never get pushed out of view. This keeps the approval modal
    
  •    // usable even when the overall bottom viewport is small.
    
  •    // Full height of the prompt (may be larger than the available area).
    
  •    let full_prompt_height = self.get_confirmation_prompt_height(inner.width);
    
  •    // Minimum rows that must remain for the interactive section.
    
  •    let min_response_rows = match self.mode {
    
  •        Mode::Select => SELECT_OPTIONS.len() as u16,
    
  •        // In input mode we need exactly two rows: one for the guidance
    
  •        // prompt and one for the single-line input field.
    
  •        Mode::Input => 2,
    
  •    };
    
  •    // Clamp prompt height so confirmation + response never exceed the
    
  •    // available space. `saturating_sub` avoids underflow when the area is
    
  •    // too small even for the minimal layout  in this unlikely case we
    
  •    // fall back to zero-height prompt so at least the options are
    
  •    // visible.
    
  •    let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows));
    
  •    let chunks = Layout::default()
           .direction(Direction::Vertical)
           .constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
    

@@ -342,8 +341,7 @@ impl WidgetRef for &UserApprovalWidget<'_> { let response_chunk = chunks[1];

     // Build the inner lines based on the mode. Collect them into a List of
  •    // non-wrapping lines rather than a Paragraph because get_height(Rect)
    
  •    // depends on this behavior for its calculation.
    
  •    // non-wrapping lines rather than a Paragraph for predictable layout.
       let lines = match self.mode {
           Mode::Select => SELECT_OPTIONS
               .iter()
    

## Review Comments

### codex-rs/core/src/protocol.rs

- Created: 2025-07-25 04:02:24 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2230086896

```diff
@@ -4,9 +4,10 @@
 //! between user and agent.
 
 use std::collections::HashMap;
+use std::fmt;
 use std::path::Path;
 use std::path::PathBuf;
-use std::str::FromStr;
+use std::str::FromStr; // Added for FinalOutput Display implementation

Remove comment?

@@ -355,6 +356,36 @@ pub struct TokenUsage {
     pub total_tokens: u64,
 }
 
+#[derive(Debug, Clone, Deserialize, Serialize)]

protocol.rs should only have things that are part of the protocol between the business logic and the UI layer. This seems more appropriate for codex-rs/common?

codex-rs/tui/src/app_event.rs

@@ -49,4 +50,9 @@ pub(crate) enum AppEvent {
         query: String,
         matches: Vec<FileMatch>,
     },
+
+    /// Append immutable history lines above the inline viewport. Part of the
+    /// incremental migration to the hybrid scrollback model described in
+    /// `fix-history-plan.md`.

I think the reference to fix-history-plan.md is outdated now?

codex-rs/tui/src/chatwidget.rs

@@ -53,6 +50,10 @@ pub(crate) struct ChatWidget<'a> {
     token_usage: TokenUsage,
     reasoning_buffer: String,
     answer_buffer: String,
+    // Buffer for streaming assistant answer text; we do not surface partial

I feel like this comment should be "attached" to fn or struct?

@@ -247,50 +257,54 @@ impl ChatWidget<'_> {
                 self.request_redraw();
             }
             EventMsg::AgentMessage(AgentMessageEvent { message }) => {
-                // if the answer buffer is empty, this means we haven't received any
-                // delta. Thus, we need to print the message as a new answer.
-                if self.answer_buffer.is_empty() {
-                    self.conversation_history
-                        .add_agent_message(&self.config, message);
+                // Final assistant answer. Prefer the fully provided message
+                // from the event; if it is empty fall back to any accumulated
+                // delta buffer (some providers may only stream deltas and send
+                // an empty final message).
+                let full = if message.is_empty() {
+                    std::mem::take(&mut self.answer_buffer)
                 } else {
+                    self.answer_buffer.clear();
+                    message
+                };
+                if !full.is_empty() {
                     self.conversation_history
-                        .replace_prev_agent_message(&self.config, message);
+                        .add_agent_message(&self.config, full);
+                    if let Some(lines) = self.conversation_history.last_entry_plain_lines() {
+                        self.app_event_tx.send(AppEvent::InsertHistory(lines));
+                    }
                 }
-                self.answer_buffer.clear();
                 self.request_redraw();
             }
             EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
-                if self.answer_buffer.is_empty() {
-                    self.conversation_history
-                        .add_agent_message(&self.config, "".to_string());
-                }
-                self.answer_buffer.push_str(&delta.clone());
-                self.conversation_history
-                    .replace_prev_agent_message(&self.config, self.answer_buffer.clone());
-                self.request_redraw();
+                // Buffer only  do not emit partial lines. This avoids cases

Could we keep track of the number of \n in answer_buffer and add the number introduced by delta and flush everything up to the latest \n? Maybe in a follow-up PR?

@@ -220,7 +221,10 @@ impl ChatWidget<'_> {
 
         // Only show text portion in conversation history for now.
         if !text.is_empty() {
-            self.conversation_history.add_user_message(text);
+            self.conversation_history.add_user_message(text.clone());
+            if let Some(lines) = self.conversation_history.last_entry_plain_lines() {
+                self.app_event_tx.send(AppEvent::InsertHistory(lines));
+            }

This seems to happen enough that it feels like it should be a helper function.

@@ -53,6 +50,10 @@ pub(crate) struct ChatWidget<'a> {
     token_usage: TokenUsage,
     reasoning_buffer: String,
     answer_buffer: String,
+    // Buffer for streaming assistant answer text; we do not surface partial

Note doc comments in Rust should have ///.

codex-rs/tui/src/history_cell.rs

@@ -123,6 +123,30 @@ pub(crate) enum HistoryCell {
 const TOOL_CALL_MAX_LINES: usize = 5;
 
 impl HistoryCell {
+    /// Return a cloned, plain representation of the cell's lines suitable for
+    /// oneshot insertion into the terminal scrollback. Image cells are
+    /// represented with a simple placeholder for now.
+    pub(crate) fn plain_lines(&self) -> Vec<Line<'static>> {
+        match self {
+            HistoryCell::WelcomeMessage { view }
+            | HistoryCell::UserPrompt { view }
+            | HistoryCell::AgentMessage { view }
+            | HistoryCell::AgentReasoning { view }
+            | HistoryCell::BackgroundEvent { view }
+            | HistoryCell::GitDiffOutput { view }
+            | HistoryCell::ErrorEvent { view }
+            | HistoryCell::SessionInfo { view }
+            | HistoryCell::CompletedExecCommand { view }
+            | HistoryCell::CompletedMcpToolCall { view }
+            | HistoryCell::PendingPatch { view }
+            | HistoryCell::ActiveExecCommand { view, .. }
+            | HistoryCell::ActiveMcpToolCall { view, .. } => view.lines.clone(),
+            HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![

It's not the most high priority thing, but can we not support images anymore?

codex-rs/tui/src/insert_history.rs

@@ -0,0 +1,183 @@
+use crate::tui; // for the Tui type alias

Drop comment?

@@ -0,0 +1,183 @@
+use crate::tui; // for the Tui type alias
+use ratatui::layout::Rect;
+use ratatui::text::Line;
+use ratatui::text::Span;
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::Widget;
+use unicode_width::UnicodeWidthChar;
+
+/// Insert a batch of history lines into the terminal scrollback above the
+/// inline viewport.
+///
+/// The incoming `lines` are the logical lines supplied by the
+/// `ConversationHistory`. They may contain embedded newlines and arbitrary
+/// runs of whitespace inside individual [`Span`]s. All of that must be
+/// normalised before writing to the backing terminal buffer because the
+/// ratatui [`Paragraph`] widget does not perform softwrapping when used in

So even if we explicitly use the wrap() method of Paragraph, there is nothing we can do?

@@ -0,0 +1,183 @@
+use crate::tui; // for the Tui type alias
+use ratatui::layout::Rect;
+use ratatui::text::Line;
+use ratatui::text::Span;
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::Widget;
+use unicode_width::UnicodeWidthChar;
+
+/// Insert a batch of history lines into the terminal scrollback above the
+/// inline viewport.
+///
+/// The incoming `lines` are the logical lines supplied by the
+/// `ConversationHistory`. They may contain embedded newlines and arbitrary
+/// runs of whitespace inside individual [`Span`]s. All of that must be
+/// normalised before writing to the backing terminal buffer because the
+/// ratatui [`Paragraph`] widget does not perform softwrapping when used in
+/// conjunction with [`Terminal::insert_before`].
+///
+/// This function performs a minimal wrapping / normalisation pass:
+///
+/// * A terminal width is determined via `Terminal::size()` (falling back to
+///   80 columns if the size probe fails).
+/// * Each logical line is broken into words and whitespace. Consecutive
+///   whitespace is collapsed to a single space; leading whitespace is
+///   discarded.
+/// * Words that do not fit on the current line cause a soft wrap. Extremely
+///   long words (longer than the terminal width) are split character by
+///   character so they still populate the display instead of overflowing the
+///   line.
+/// * Explicit `\n` characters inside a span force a hard line break.
+/// * Empty lines (including a trailing newline at the end of the batch) are
+///   preserved so vertical spacing remains faithful to the logical history.
+///
+/// Finally the physical lines are rendered directly into the terminal's
+/// scrollback region using [`Terminal::insert_before`]. Any backend error is
+/// ignored: failing to insert history is nonfatal and a subsequent redraw
+/// will eventually repaint a consistent view.
+pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
+    let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize;
+    let mut physical: Vec<Line<'static>> = Vec::new();
+
+    for logical in lines.into_iter() {
+        if logical.spans.is_empty() {
+            physical.push(logical);
+            continue;
+        }
+
+        let mut line_spans: Vec<Span<'static>> = Vec::new();
+        let mut line_width: usize = 0;
+
+        // Helper that finalises the current inprogress line.
+        let flush_line =
+            |store: &mut Vec<Line<'static>>, spans: &mut Vec<Span<'static>>, width: &mut usize| {
+                store.push(Line::from(spans.clone()));
+                spans.clear();
+                *width = 0;
+            };
+
+        // Iterate spans tokenising into words and whitespace so wrapping can
+        // happen at word boundaries.
+        for span in logical.spans.into_iter() {
+            let style = span.style;
+            let mut buf_word = String::new();
+            let mut buf_space = String::new();
+            let flush_word = |word: &mut String,

Can we pull out the appropriate arguments so this can be a top-level function instead of a closure? I think that would make things a bit easier to follow.

@@ -0,0 +1,183 @@
+use crate::tui; // for the Tui type alias
+use ratatui::layout::Rect;
+use ratatui::text::Line;
+use ratatui::text::Span;
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::Widget;
+use unicode_width::UnicodeWidthChar;
+
+/// Insert a batch of history lines into the terminal scrollback above the
+/// inline viewport.
+///
+/// The incoming `lines` are the logical lines supplied by the
+/// `ConversationHistory`. They may contain embedded newlines and arbitrary
+/// runs of whitespace inside individual [`Span`]s. All of that must be
+/// normalised before writing to the backing terminal buffer because the
+/// ratatui [`Paragraph`] widget does not perform softwrapping when used in
+/// conjunction with [`Terminal::insert_before`].
+///
+/// This function performs a minimal wrapping / normalisation pass:
+///
+/// * A terminal width is determined via `Terminal::size()` (falling back to
+///   80 columns if the size probe fails).
+/// * Each logical line is broken into words and whitespace. Consecutive
+///   whitespace is collapsed to a single space; leading whitespace is
+///   discarded.
+/// * Words that do not fit on the current line cause a soft wrap. Extremely
+///   long words (longer than the terminal width) are split character by
+///   character so they still populate the display instead of overflowing the
+///   line.
+/// * Explicit `\n` characters inside a span force a hard line break.
+/// * Empty lines (including a trailing newline at the end of the batch) are
+///   preserved so vertical spacing remains faithful to the logical history.
+///
+/// Finally the physical lines are rendered directly into the terminal's
+/// scrollback region using [`Terminal::insert_before`]. Any backend error is
+/// ignored: failing to insert history is nonfatal and a subsequent redraw
+/// will eventually repaint a consistent view.
+pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
+    let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize;
+    let mut physical: Vec<Line<'static>> = Vec::new();
+
+    for logical in lines.into_iter() {
+        if logical.spans.is_empty() {
+            physical.push(logical);
+            continue;
+        }
+
+        let mut line_spans: Vec<Span<'static>> = Vec::new();
+        let mut line_width: usize = 0;
+
+        // Helper that finalises the current inprogress line.
+        let flush_line =
+            |store: &mut Vec<Line<'static>>, spans: &mut Vec<Span<'static>>, width: &mut usize| {
+                store.push(Line::from(spans.clone()));
+                spans.clear();
+                *width = 0;
+            };
+
+        // Iterate spans tokenising into words and whitespace so wrapping can
+        // happen at word boundaries.
+        for span in logical.spans.into_iter() {
+            let style = span.style;
+            let mut buf_word = String::new();
+            let mut buf_space = String::new();
+            let flush_word = |word: &mut String,

Alternatively, maybe it would be easier to create a struct with some methods on it to manage this entire computation? Then things can be read off self instead of passed in?

codex-rs/tui/src/lib.rs

@@ -47,7 +47,10 @@ mod user_approval_widget;
 
 pub use cli::Cli;
 
-pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::Result<()> {
+pub fn run_main(
+    cli: Cli,
+    codex_linux_sandbox_exe: Option<PathBuf>,
+) -> std::io::Result<codex_core::protocol::TokenUsage> {

I think it might be helpful to introduce a struct like FinalOutput that has TokenUsage (and maybe final_message: Option<String>) as a field.

Then I would have it impl Display for FinalOutput with the implementation that corresponds to this code:

            println!(
                "Token usage: total={} input={}{} output={}{}",
                usage.total_tokens,
                usage.input_tokens,
                usage
                    .cached_input_tokens
                    .map(|c| format!(" (cached {c})"))
                    .unwrap_or_default(),
                usage.output_tokens,
                usage
                    .reasoning_output_tokens
                    .map(|r| format!(" (reasoning {r})"))
                    .unwrap_or_default()
            );

so that you can just do:

println!("{final_output}");

rather than copy/paste the two implementations.

codex-rs/tui/src/slash_command.rs

@@ -15,17 +15,13 @@ pub enum SlashCommand {
     New,
     Diff,
     Quit,
-    ToggleMouseMode,
 }
 
 impl SlashCommand {
     /// User-visible description shown in the popup.
     pub fn description(self) -> &'static str {
         match self {
             SlashCommand::New => "Start a new chat.",
-            SlashCommand::ToggleMouseMode => {

Nice! We can also remove disable_mouse_capture in the docs (config.md) and Config code.