diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index df1b0235a..7c1b07501 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -852,6 +852,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 e397b0ca6..7916a7dc7 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) -> 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 3d38ded1a..c45d81180 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 83fe613c8..cba5dcfbb 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 3111b4229..cf6e8b519 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; @@ -358,6 +359,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 b2f2b9b65..9d73e3b38 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 377b5d6f0..ee14e7bb3 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 3aaa78976..a1f304fe4 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>), } 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 ca33047b1..ba5b07b93 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 6abf5399f..677d6db95 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 b49bce404..bdfb6a23e 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 2a91655cc..ebec534f2 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 de46ac270..f8c06ec5e 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 081a406f2..674470731 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -23,9 +23,6 @@ use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Direction; -use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; @@ -52,6 +49,9 @@ pub(crate) struct ChatWidget<'a> { initial_user_message: Option, token_usage: TokenUsage, reasoning_buffer: String, + // Buffer for streaming assistant answer text; we do not surface partial + // We wait for the final AgentMessage event and then emit the full text + // at once into scrollback so the history contains a single message. answer_buffer: String, } @@ -187,6 +187,13 @@ impl ChatWidget<'_> { } } + /// Emits the last entry's plain lines from conversation_history, if any. + fn emit_last_history_entry(&mut self) { + if let Some(lines) = self.conversation_history.last_entry_plain_lines() { + self.app_event_tx.send(AppEvent::InsertHistory(lines)); + } + } + fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; let mut items: Vec = Vec::new(); @@ -220,7 +227,8 @@ impl ChatWidget<'_> { // Only show text portion in conversation history for now. if !text.is_empty() { - self.conversation_history.add_user_message(text); + self.conversation_history.add_user_message(text.clone()); + self.emit_last_history_entry(); } self.conversation_history.scroll_to_bottom(); } @@ -232,6 +240,10 @@ impl ChatWidget<'_> { // Record session information at the top of the conversation. self.conversation_history .add_session_info(&self.config, event.clone()); + // Immediately surface the session banner / settings summary in + // scrollback so the user can review configuration (model, + // sandbox, approvals, etc.) before interacting. + self.emit_last_history_entry(); // Forward history metadata to the bottom pane so the chat // composer can navigate through past messages. @@ -247,50 +259,50 @@ impl ChatWidget<'_> { self.request_redraw(); } EventMsg::AgentMessage(AgentMessageEvent { message }) => { - // if the answer buffer is empty, this means we haven't received any - // delta. Thus, we need to print the message as a new answer. - if self.answer_buffer.is_empty() { - self.conversation_history - .add_agent_message(&self.config, message); + // Final assistant answer. Prefer the fully provided message + // from the event; if it is empty fall back to any accumulated + // delta buffer (some providers may only stream deltas and send + // an empty final message). + let full = if message.is_empty() { + std::mem::take(&mut self.answer_buffer) } else { + self.answer_buffer.clear(); + message + }; + if !full.is_empty() { self.conversation_history - .replace_prev_agent_message(&self.config, message); + .add_agent_message(&self.config, full); + self.emit_last_history_entry(); } - self.answer_buffer.clear(); self.request_redraw(); } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - if self.answer_buffer.is_empty() { - self.conversation_history - .add_agent_message(&self.config, "".to_string()); - } - self.answer_buffer.push_str(&delta.clone()); - self.conversation_history - .replace_prev_agent_message(&self.config, self.answer_buffer.clone()); - self.request_redraw(); + // Buffer only – do not emit partial lines. This avoids cases + // where long responses appear truncated if the terminal + // wrapped early. The full message is emitted on + // AgentMessage. + self.answer_buffer.push_str(&delta); } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { - if self.reasoning_buffer.is_empty() { - self.conversation_history - .add_agent_reasoning(&self.config, "".to_string()); - } - self.reasoning_buffer.push_str(&delta.clone()); - self.conversation_history - .replace_prev_agent_reasoning(&self.config, self.reasoning_buffer.clone()); - self.request_redraw(); + // Buffer only – disable incremental reasoning streaming so we + // avoid truncated intermediate lines. Full text emitted on + // AgentReasoning. + self.reasoning_buffer.push_str(&delta); } EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { - // if the reasoning buffer is empty, this means we haven't received any - // delta. Thus, we need to print the message as a new reasoning. - if self.reasoning_buffer.is_empty() { - self.conversation_history - .add_agent_reasoning(&self.config, "".to_string()); + // Emit full reasoning text once. Some providers might send + // final event with empty text if only deltas were used. + let full = if text.is_empty() { + std::mem::take(&mut self.reasoning_buffer) } else { - // else, we rerender one last time. + self.reasoning_buffer.clear(); + text + }; + if !full.is_empty() { self.conversation_history - .replace_prev_agent_reasoning(&self.config, text); + .add_agent_reasoning(&self.config, full); + self.emit_last_history_entry(); } - self.reasoning_buffer.clear(); self.request_redraw(); } EventMsg::TaskStarted => { @@ -310,7 +322,8 @@ impl ChatWidget<'_> { .set_token_usage(self.token_usage.clone(), self.config.model_context_window); } EventMsg::Error(ErrorEvent { message }) => { - self.conversation_history.add_error(message); + self.conversation_history.add_error(message.clone()); + self.emit_last_history_entry(); self.bottom_pane.set_task_running(false); } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { @@ -346,6 +359,7 @@ impl ChatWidget<'_> { self.conversation_history .add_patch_event(PatchEventType::ApprovalRequest, changes); + self.emit_last_history_entry(); self.conversation_history.scroll_to_bottom(); @@ -364,7 +378,8 @@ impl ChatWidget<'_> { cwd: _, }) => { self.conversation_history - .reset_or_add_active_exec_command(call_id, command); + .add_active_exec_command(call_id, command); + self.emit_last_history_entry(); self.request_redraw(); } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { @@ -376,6 +391,7 @@ impl ChatWidget<'_> { // summary so the user can follow along. self.conversation_history .add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes); + self.emit_last_history_entry(); if !auto_approved { self.conversation_history.scroll_to_bottom(); } @@ -399,6 +415,7 @@ impl ChatWidget<'_> { }) => { self.conversation_history .add_active_mcp_tool_call(call_id, server, tool, arguments); + self.emit_last_history_entry(); self.request_redraw(); } EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => { @@ -425,6 +442,7 @@ impl ChatWidget<'_> { event => { self.conversation_history .add_background_event(format!("{event:?}")); + self.emit_last_history_entry(); self.request_redraw(); } } @@ -441,7 +459,9 @@ impl ChatWidget<'_> { } pub(crate) fn add_diff_output(&mut self, diff_output: String) { - self.conversation_history.add_diff_output(diff_output); + self.conversation_history + .add_diff_output(diff_output.clone()); + self.emit_last_history_entry(); self.request_redraw(); } @@ -492,19 +512,18 @@ impl ChatWidget<'_> { tracing::error!("failed to submit op: {e}"); } } + + pub(crate) fn token_usage(&self) -> &TokenUsage { + &self.token_usage + } } impl WidgetRef for &ChatWidget<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let bottom_height = self.bottom_pane.calculate_required_height(&area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(bottom_height)]) - .split(area); - - self.conversation_history.render(chunks[0], buf); - (&self.bottom_pane).render(chunks[1], buf); + // In the hybrid inline viewport mode we only draw the interactive + // bottom pane; history entries are injected directly into scrollback + // via `Terminal::insert_before`. + (&self.bottom_pane).render(area, buf); } } diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index ceaf115f3..d8035eff6 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>> { + 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 13bec71b4..ab657163a 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> { + 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 000000000..247e024cb --- /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>, + 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>) { + 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>) { + 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>) { + 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>) { + let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize; + let mut physical: Vec> = 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::>::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 05a55edc7..905f0aaf0 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 { let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -147,24 +150,8 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> std::io:: // `--allow-no-git-exec` flag. let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config); - try_run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx); - Ok(()) -} - -#[expect( - clippy::print_stderr, - reason = "Resort to stderr in exceptional situations." -)] -fn try_run_ratatui_app( - cli: Cli, - config: Config, - show_login_screen: bool, - show_git_warning: bool, - log_rx: tokio::sync::mpsc::UnboundedReceiver, -) { - if let Err(report) = run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) { - eprintln!("Error: {report:?}"); - } + run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) + .map_err(|err| std::io::Error::other(err.to_string())) } fn run_ratatui_app( @@ -173,16 +160,15 @@ fn run_ratatui_app( show_login_screen: bool, show_git_warning: bool, mut log_rx: tokio::sync::mpsc::UnboundedReceiver, -) -> color_eyre::Result<()> { +) -> color_eyre::Result { 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 7fcc94450..fdb3cdaf8 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 cff1296f6..000000000 --- a/codex-rs/tui/src/mouse_capture.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crossterm::event::DisableMouseCapture; -use crossterm::event::EnableMouseCapture; -use ratatui::crossterm::execute; -use std::io::Result; -use std::io::stdout; - -pub(crate) struct MouseCapture { - mouse_capture_is_active: bool, -} - -impl MouseCapture { - pub(crate) fn new_with_capture(mouse_capture_is_active: bool) -> Result { - if mouse_capture_is_active { - enable_capture()?; - } - - Ok(Self { - mouse_capture_is_active, - }) - } -} - -impl MouseCapture { - /// Idempotent method to set the mouse capture state. - pub fn set_active(&mut self, is_active: bool) -> Result<()> { - match (self.mouse_capture_is_active, is_active) { - (true, true) => {} - (false, false) => {} - (true, false) => { - disable_capture()?; - self.mouse_capture_is_active = false; - } - (false, true) => { - enable_capture()?; - self.mouse_capture_is_active = true; - } - } - Ok(()) - } - - pub(crate) fn toggle(&mut self) -> Result<()> { - self.set_active(!self.mouse_capture_is_active) - } - - pub(crate) fn disable(&mut self) -> Result<()> { - if self.mouse_capture_is_active { - disable_capture()?; - self.mouse_capture_is_active = false; - } - Ok(()) - } -} - -impl Drop for MouseCapture { - fn drop(&mut self) { - if self.disable().is_err() { - // The user is likely shutting down, so ignore any errors so the - // shutdown process can complete. - } - } -} - -fn enable_capture() -> Result<()> { - execute!(stdout(), EnableMouseCapture) -} - -fn disable_capture() -> Result<()> { - execute!(stdout(), DisableMouseCapture) -} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bb72ce561..603eb721c 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 dda61d0bd..973ef0981 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 99ff03436..66ae1cfb9 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>; -/// 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 6604daace..431f85a26 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()