67 KiB
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 --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
Optionkey while - /// clicking and dragging.)
- ///
- /// Setting this option to
truedisables mouse capture, so scrolling with - /// the mouse is not possible, though the keyboard shortcuts e.g.
band - ///
spacestill 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()); -
} @@ -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());self.emit_last_history_entry(); } self.conversation_history.scroll_to_bottom(); -
// 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
- /// one‑shot 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 soft‑wrapping 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 non‑fatal 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-execflag. 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 colour‑eyre 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)?; -
}) } 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;println!("{}", codex_core::protocol::FinalOutput::from(usage)); Ok(())
-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
- // multi‑line 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 ratatui’s [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 single‑line 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
@@ -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
@@ -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.mdis 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
@@ -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
fnorstruct?
- Created: 2025-07-24 15:58:00 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2228929499
@@ -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
\ninanswer_bufferand add the number introduced bydeltaand 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
@@ -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
@@ -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
@@ -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
+ /// one‑shot 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
@@ -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
@@ -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 soft‑wrapping when used in
So even if we explicitly use the
wrap()method ofParagraph, there is nothing we can do?
- Created: 2025-07-24 17:42:35 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229155356
@@ -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 soft‑wrapping 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 non‑fatal 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 in‑progress 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
@@ -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 soft‑wrapping 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 non‑fatal 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 in‑progress 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
selfinstead 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
@@ -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
FinalOutputthat hasTokenUsage(and maybefinal_message: Option<String>) as a field.Then I would have it
impl Display for FinalOutputwith 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
@@ -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_capturein the docs (config.md) andConfigcode.