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

1742 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR #1672: Easily Selectable History
- URL: https://github.com/openai/codex/pull/1672
- Author: easong-openai
- Created: 2025-07-24 10:51:09 UTC
- Updated: 2025-07-25 08:56:48 UTC
- Changes: +394/-353, Files changed: 24, Commits: 7
## 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
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<TokenUsage> 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<FileMatch>,
},
+
+ 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<UserMessage>,
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<InputItem> = 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<String>) {
- // 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<PathBuf>) -> std::io::Result<()> {
+pub fn run_main(
+ cli: Cli,
+ codex_linux_sandbox_exe: Option<PathBuf>,
+) -> 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<PathBuf>) -> 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<String>,
-) {
- 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<String>,
-) -> 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<Self> {
- 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<AtomicUsize>,
running: Arc<AtomicBool>,
// 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<Stdout>>;
-/// 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<Tui> {
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?
- Created: 2025-07-25 04:03:23 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2230087715
```diff
@@ -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
- Created: 2025-07-24 15:50:16 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2228908991
```diff
@@ -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
- Created: 2025-07-24 15:53:59 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2228918474
```diff
@@ -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`?
- Created: 2025-07-24 15:58:00 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2228929499
```diff
@@ -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?
- Created: 2025-07-24 17:07:09 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229087676
```diff
@@ -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.
- Created: 2025-07-25 04:05:00 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2230089078
```diff
@@ -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
- Created: 2025-07-24 17:21:58 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229115614
```diff
@@ -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
- Created: 2025-07-24 17:22:12 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229116015
```diff
@@ -0,0 +1,183 @@
+use crate::tui; // for the Tui type alias
```
> Drop comment?
- Created: 2025-07-24 17:35:56 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229143383
```diff
@@ -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?
- Created: 2025-07-24 17:42:35 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229155356
```diff
@@ -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.
- Created: 2025-07-24 17:43:51 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229157784
```diff
@@ -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
- Created: 2025-07-24 17:29:49 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229131705
```diff
@@ -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
- Created: 2025-07-24 17:31:23 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229135058
```diff
@@ -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.