mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
1742 lines
67 KiB
Markdown
1742 lines
67 KiB
Markdown
# PR #1672: Easily Selectable History
|
||
|
||
- URL: https://github.com/openai/codex/pull/1672
|
||
- Author: easong-openai
|
||
- Created: 2025-07-24 10:51:09 UTC
|
||
- Updated: 2025-07-25 08:56:48 UTC
|
||
- Changes: +394/-353, Files changed: 24, Commits: 7
|
||
|
||
## Description
|
||
|
||
This update replaces the previous ratatui history widget with an append-only log so that the terminal can handle text selection and scrolling. It also disables streaming responses, which we'll do our best to bring back in a later PR. It also adds a small summary of token use after the TUI exits.
|
||
|
||
## Full Diff
|
||
|
||
```diff
|
||
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
|
||
index d179a142f4..9aa1d2789a 100644
|
||
--- a/codex-rs/Cargo.lock
|
||
+++ b/codex-rs/Cargo.lock
|
||
@@ -850,6 +850,7 @@ dependencies = [
|
||
"tui-markdown",
|
||
"tui-textarea",
|
||
"unicode-segmentation",
|
||
+ "unicode-width 0.1.14",
|
||
"uuid",
|
||
]
|
||
|
||
diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs
|
||
index e397b0ca6a..7916a7dc79 100644
|
||
--- a/codex-rs/cli/src/main.rs
|
||
+++ b/codex-rs/cli/src/main.rs
|
||
@@ -105,7 +105,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||
None => {
|
||
let mut tui_cli = cli.interactive;
|
||
prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
|
||
- codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
|
||
+ let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?;
|
||
+ println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||
}
|
||
Some(Subcommand::Exec(mut exec_cli)) => {
|
||
prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);
|
||
diff --git a/codex-rs/config.md b/codex-rs/config.md
|
||
index 3d38ded1a5..c45d81180d 100644
|
||
--- a/codex-rs/config.md
|
||
+++ b/codex-rs/config.md
|
||
@@ -498,14 +498,5 @@ Options that are specific to the TUI.
|
||
|
||
```toml
|
||
[tui]
|
||
-# This will make it so that Codex does not try to process mouse events, which
|
||
-# means your Terminal's native drag-to-text to text selection and copy/paste
|
||
-# should work. The tradeoff is that Codex will not receive any mouse events, so
|
||
-# it will not be possible to use the mouse to scroll conversation history.
|
||
-#
|
||
-# Note that most terminals support holding down a modifier key when using the
|
||
-# mouse to support text selection. For example, even if Codex mouse capture is
|
||
-# enabled (i.e., this is set to `false`), you can still hold down alt while
|
||
-# dragging the mouse to select text.
|
||
-disable_mouse_capture = true # defaults to `false`
|
||
+# More to come here
|
||
```
|
||
diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs
|
||
index 83fe613c86..cba5dcfbb2 100644
|
||
--- a/codex-rs/core/src/config_types.rs
|
||
+++ b/codex-rs/core/src/config_types.rs
|
||
@@ -76,20 +76,7 @@ pub enum HistoryPersistence {
|
||
|
||
/// Collection of settings that are specific to the TUI.
|
||
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
|
||
-pub struct Tui {
|
||
- /// By default, mouse capture is enabled in the TUI so that it is possible
|
||
- /// to scroll the conversation history with a mouse. This comes at the cost
|
||
- /// of not being able to use the mouse to select text in the TUI.
|
||
- /// (Most terminals support a modifier key to allow this. For example,
|
||
- /// text selection works in iTerm if you hold down the `Option` key while
|
||
- /// clicking and dragging.)
|
||
- ///
|
||
- /// Setting this option to `true` disables mouse capture, so scrolling with
|
||
- /// the mouse is not possible, though the keyboard shortcuts e.g. `b` and
|
||
- /// `space` still work. This allows the user to select text in the TUI
|
||
- /// using the mouse without needing to hold down a modifier key.
|
||
- pub disable_mouse_capture: bool,
|
||
-}
|
||
+pub struct Tui {}
|
||
|
||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)]
|
||
#[serde(rename_all = "kebab-case")]
|
||
diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs
|
||
index 0c375e455d..ad6686d175 100644
|
||
--- a/codex-rs/core/src/protocol.rs
|
||
+++ b/codex-rs/core/src/protocol.rs
|
||
@@ -4,9 +4,10 @@
|
||
//! between user and agent.
|
||
|
||
use std::collections::HashMap;
|
||
+use std::fmt;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
-use std::str::FromStr;
|
||
+use std::str::FromStr; // Added for FinalOutput Display implementation
|
||
|
||
use mcp_types::CallToolResult;
|
||
use serde::Deserialize;
|
||
@@ -355,6 +356,36 @@ pub struct TokenUsage {
|
||
pub total_tokens: u64,
|
||
}
|
||
|
||
+#[derive(Debug, Clone, Deserialize, Serialize)]
|
||
+pub struct FinalOutput {
|
||
+ pub token_usage: TokenUsage,
|
||
+}
|
||
+
|
||
+impl From<TokenUsage> for FinalOutput {
|
||
+ fn from(token_usage: TokenUsage) -> Self {
|
||
+ Self { token_usage }
|
||
+ }
|
||
+}
|
||
+
|
||
+impl fmt::Display for FinalOutput {
|
||
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
+ let u = &self.token_usage;
|
||
+ write!(
|
||
+ f,
|
||
+ "Token usage: total={} input={}{} output={}{}",
|
||
+ u.total_tokens,
|
||
+ u.input_tokens,
|
||
+ u.cached_input_tokens
|
||
+ .map(|c| format!(" (cached {c})"))
|
||
+ .unwrap_or_default(),
|
||
+ u.output_tokens,
|
||
+ u.reasoning_output_tokens
|
||
+ .map(|r| format!(" (reasoning {r})"))
|
||
+ .unwrap_or_default()
|
||
+ )
|
||
+ }
|
||
+}
|
||
+
|
||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||
pub struct AgentMessageEvent {
|
||
pub message: String,
|
||
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
|
||
index b2f2b9b653..9d73e3b386 100644
|
||
--- a/codex-rs/tui/Cargo.toml
|
||
+++ b/codex-rs/tui/Cargo.toml
|
||
@@ -58,6 +58,7 @@ tui-input = "0.14.0"
|
||
tui-markdown = "0.3.3"
|
||
tui-textarea = "0.7.0"
|
||
unicode-segmentation = "1.12.0"
|
||
+unicode-width = "0.1"
|
||
uuid = "1"
|
||
|
||
[dev-dependencies]
|
||
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
|
||
index 377b5d6f0b..ee14e7bb37 100644
|
||
--- a/codex-rs/tui/src/app.rs
|
||
+++ b/codex-rs/tui/src/app.rs
|
||
@@ -6,7 +6,6 @@ use crate::get_git_diff::get_git_diff;
|
||
use crate::git_warning_screen::GitWarningOutcome;
|
||
use crate::git_warning_screen::GitWarningScreen;
|
||
use crate::login_screen::LoginScreen;
|
||
-use crate::mouse_capture::MouseCapture;
|
||
use crate::scroll_event_helper::ScrollEventHelper;
|
||
use crate::slash_command::SlashCommand;
|
||
use crate::tui;
|
||
@@ -197,17 +196,17 @@ impl App<'_> {
|
||
});
|
||
}
|
||
|
||
- pub(crate) fn run(
|
||
- &mut self,
|
||
- terminal: &mut tui::Tui,
|
||
- mouse_capture: &mut MouseCapture,
|
||
- ) -> Result<()> {
|
||
+ pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||
// Insert an event to trigger the first render.
|
||
let app_event_tx = self.app_event_tx.clone();
|
||
app_event_tx.send(AppEvent::RequestRedraw);
|
||
|
||
while let Ok(event) = self.app_event_rx.recv() {
|
||
match event {
|
||
+ AppEvent::InsertHistory(lines) => {
|
||
+ crate::insert_history::insert_history_lines(terminal, lines);
|
||
+ self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
+ }
|
||
AppEvent::RequestRedraw => {
|
||
self.schedule_redraw();
|
||
}
|
||
@@ -287,11 +286,6 @@ impl App<'_> {
|
||
self.app_state = AppState::Chat { widget: new_widget };
|
||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||
}
|
||
- SlashCommand::ToggleMouseMode => {
|
||
- if let Err(e) = mouse_capture.toggle() {
|
||
- tracing::error!("Failed to toggle mouse mode: {e}");
|
||
- }
|
||
- }
|
||
SlashCommand::Quit => {
|
||
break;
|
||
}
|
||
@@ -332,6 +326,15 @@ impl App<'_> {
|
||
Ok(())
|
||
}
|
||
|
||
+ pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
||
+ match &self.app_state {
|
||
+ AppState::Chat { widget } => widget.token_usage().clone(),
|
||
+ AppState::Login { .. } | AppState::GitWarning { .. } => {
|
||
+ codex_core::protocol::TokenUsage::default()
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+
|
||
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||
// TODO: add a throttle to avoid redrawing too often
|
||
|
||
diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs
|
||
index 3aaa789760..a1f304fe42 100644
|
||
--- a/codex-rs/tui/src/app_event.rs
|
||
+++ b/codex-rs/tui/src/app_event.rs
|
||
@@ -1,6 +1,7 @@
|
||
use codex_core::protocol::Event;
|
||
use codex_file_search::FileMatch;
|
||
use crossterm::event::KeyEvent;
|
||
+use ratatui::text::Line;
|
||
|
||
use crate::slash_command::SlashCommand;
|
||
|
||
@@ -49,4 +50,6 @@ pub(crate) enum AppEvent {
|
||
query: String,
|
||
matches: Vec<FileMatch>,
|
||
},
|
||
+
|
||
+ InsertHistory(Vec<Line<'static>>),
|
||
}
|
||
diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
|
||
index ca33047b1f..ba5b07b93c 100644
|
||
--- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
|
||
+++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs
|
||
@@ -50,10 +50,6 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
|
||
self.current.is_complete() && self.queue.is_empty()
|
||
}
|
||
|
||
- fn calculate_required_height(&self, area: &Rect) -> u16 {
|
||
- self.current.get_height(area)
|
||
- }
|
||
-
|
||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||
(&self.current).render_ref(area, buf);
|
||
}
|
||
diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
|
||
index 6abf5399f5..677d6db95b 100644
|
||
--- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
|
||
+++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
|
||
@@ -22,9 +22,6 @@ pub(crate) trait BottomPaneView<'a> {
|
||
false
|
||
}
|
||
|
||
- /// Height required to render the view.
|
||
- fn calculate_required_height(&self, area: &Rect) -> u16;
|
||
-
|
||
/// Render the view: this will be displayed in place of the composer.
|
||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||
|
||
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||
index b49bce4046..bdfb6a23e2 100644
|
||
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
|
||
@@ -22,11 +22,6 @@ use crate::app_event::AppEvent;
|
||
use crate::app_event_sender::AppEventSender;
|
||
use codex_file_search::FileMatch;
|
||
|
||
-/// Minimum number of visible text rows inside the textarea.
|
||
-const MIN_TEXTAREA_ROWS: usize = 1;
|
||
-/// Rows consumed by the border.
|
||
-const BORDER_LINES: u16 = 2;
|
||
-
|
||
const BASE_PLACEHOLDER_TEXT: &str = "send a message";
|
||
/// If the pasted content exceeds this number of characters, replace it with a
|
||
/// placeholder in the UI.
|
||
@@ -609,17 +604,6 @@ impl ChatComposer<'_> {
|
||
self.dismissed_file_popup_token = None;
|
||
}
|
||
|
||
- pub fn calculate_required_height(&self, area: &Rect) -> u16 {
|
||
- let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS);
|
||
- let num_popup_rows = match &self.active_popup {
|
||
- ActivePopup::Command(popup) => popup.calculate_required_height(area),
|
||
- ActivePopup::File(popup) => popup.calculate_required_height(area),
|
||
- ActivePopup::None => 0,
|
||
- };
|
||
-
|
||
- rows as u16 + BORDER_LINES + num_popup_rows
|
||
- }
|
||
-
|
||
fn update_border(&mut self, has_focus: bool) {
|
||
struct BlockState {
|
||
right_title: Line<'static>,
|
||
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
|
||
index 2a91655cc5..ebec534f21 100644
|
||
--- a/codex-rs/tui/src/bottom_pane/mod.rs
|
||
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
|
||
@@ -65,10 +65,8 @@ impl BottomPane<'_> {
|
||
if !view.is_complete() {
|
||
self.active_view = Some(view);
|
||
} else if self.is_task_running {
|
||
- let height = self.composer.calculate_required_height(&Rect::default());
|
||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||
self.app_event_tx.clone(),
|
||
- height,
|
||
)));
|
||
}
|
||
self.request_redraw();
|
||
@@ -138,10 +136,8 @@ impl BottomPane<'_> {
|
||
match (running, self.active_view.is_some()) {
|
||
(true, false) => {
|
||
// Show status indicator overlay.
|
||
- let height = self.composer.calculate_required_height(&Rect::default());
|
||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||
self.app_event_tx.clone(),
|
||
- height,
|
||
)));
|
||
self.request_redraw();
|
||
}
|
||
@@ -203,14 +199,6 @@ impl BottomPane<'_> {
|
||
}
|
||
|
||
/// Height (terminal rows) required by the current bottom pane.
|
||
- pub fn calculate_required_height(&self, area: &Rect) -> u16 {
|
||
- if let Some(view) = &self.active_view {
|
||
- view.calculate_required_height(area)
|
||
- } else {
|
||
- self.composer.calculate_required_height(area)
|
||
- }
|
||
- }
|
||
-
|
||
pub(crate) fn request_redraw(&self) {
|
||
self.app_event_tx.send(AppEvent::RequestRedraw)
|
||
}
|
||
diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
|
||
index de46ac2709..f8c06ec5e5 100644
|
||
--- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
|
||
+++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs
|
||
@@ -1,5 +1,4 @@
|
||
use ratatui::buffer::Buffer;
|
||
-use ratatui::layout::Rect;
|
||
use ratatui::widgets::WidgetRef;
|
||
|
||
use crate::app_event_sender::AppEventSender;
|
||
@@ -13,9 +12,9 @@ pub(crate) struct StatusIndicatorView {
|
||
}
|
||
|
||
impl StatusIndicatorView {
|
||
- pub fn new(app_event_tx: AppEventSender, height: u16) -> Self {
|
||
+ pub fn new(app_event_tx: AppEventSender) -> Self {
|
||
Self {
|
||
- view: StatusIndicatorWidget::new(app_event_tx, height),
|
||
+ view: StatusIndicatorWidget::new(app_event_tx),
|
||
}
|
||
}
|
||
|
||
@@ -34,11 +33,7 @@ impl BottomPaneView<'_> for StatusIndicatorView {
|
||
true
|
||
}
|
||
|
||
- fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
||
- self.view.get_height()
|
||
- }
|
||
-
|
||
- fn render(&self, area: Rect, buf: &mut Buffer) {
|
||
+ fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) {
|
||
self.view.render_ref(area, buf);
|
||
}
|
||
}
|
||
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
|
||
index 081a406f29..6744707319 100644
|
||
--- a/codex-rs/tui/src/chatwidget.rs
|
||
+++ b/codex-rs/tui/src/chatwidget.rs
|
||
@@ -23,9 +23,6 @@ use codex_core::protocol::TaskCompleteEvent;
|
||
use codex_core::protocol::TokenUsage;
|
||
use crossterm::event::KeyEvent;
|
||
use ratatui::buffer::Buffer;
|
||
-use ratatui::layout::Constraint;
|
||
-use ratatui::layout::Direction;
|
||
-use ratatui::layout::Layout;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::widgets::Widget;
|
||
use ratatui::widgets::WidgetRef;
|
||
@@ -52,6 +49,9 @@ pub(crate) struct ChatWidget<'a> {
|
||
initial_user_message: Option<UserMessage>,
|
||
token_usage: TokenUsage,
|
||
reasoning_buffer: String,
|
||
+ // Buffer for streaming assistant answer text; we do not surface partial
|
||
+ // We wait for the final AgentMessage event and then emit the full text
|
||
+ // at once into scrollback so the history contains a single message.
|
||
answer_buffer: String,
|
||
}
|
||
|
||
@@ -187,6 +187,13 @@ impl ChatWidget<'_> {
|
||
}
|
||
}
|
||
|
||
+ /// Emits the last entry's plain lines from conversation_history, if any.
|
||
+ fn emit_last_history_entry(&mut self) {
|
||
+ if let Some(lines) = self.conversation_history.last_entry_plain_lines() {
|
||
+ self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||
+ }
|
||
+ }
|
||
+
|
||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||
let UserMessage { text, image_paths } = user_message;
|
||
let mut items: Vec<InputItem> = Vec::new();
|
||
@@ -220,7 +227,8 @@ impl ChatWidget<'_> {
|
||
|
||
// Only show text portion in conversation history for now.
|
||
if !text.is_empty() {
|
||
- self.conversation_history.add_user_message(text);
|
||
+ self.conversation_history.add_user_message(text.clone());
|
||
+ self.emit_last_history_entry();
|
||
}
|
||
self.conversation_history.scroll_to_bottom();
|
||
}
|
||
@@ -232,6 +240,10 @@ impl ChatWidget<'_> {
|
||
// Record session information at the top of the conversation.
|
||
self.conversation_history
|
||
.add_session_info(&self.config, event.clone());
|
||
+ // Immediately surface the session banner / settings summary in
|
||
+ // scrollback so the user can review configuration (model,
|
||
+ // sandbox, approvals, etc.) before interacting.
|
||
+ self.emit_last_history_entry();
|
||
|
||
// Forward history metadata to the bottom pane so the chat
|
||
// composer can navigate through past messages.
|
||
@@ -247,50 +259,50 @@ impl ChatWidget<'_> {
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||
- // if the answer buffer is empty, this means we haven't received any
|
||
- // delta. Thus, we need to print the message as a new answer.
|
||
- if self.answer_buffer.is_empty() {
|
||
- self.conversation_history
|
||
- .add_agent_message(&self.config, message);
|
||
+ // Final assistant answer. Prefer the fully provided message
|
||
+ // from the event; if it is empty fall back to any accumulated
|
||
+ // delta buffer (some providers may only stream deltas and send
|
||
+ // an empty final message).
|
||
+ let full = if message.is_empty() {
|
||
+ std::mem::take(&mut self.answer_buffer)
|
||
} else {
|
||
+ self.answer_buffer.clear();
|
||
+ message
|
||
+ };
|
||
+ if !full.is_empty() {
|
||
self.conversation_history
|
||
- .replace_prev_agent_message(&self.config, message);
|
||
+ .add_agent_message(&self.config, full);
|
||
+ self.emit_last_history_entry();
|
||
}
|
||
- self.answer_buffer.clear();
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||
- if self.answer_buffer.is_empty() {
|
||
- self.conversation_history
|
||
- .add_agent_message(&self.config, "".to_string());
|
||
- }
|
||
- self.answer_buffer.push_str(&delta.clone());
|
||
- self.conversation_history
|
||
- .replace_prev_agent_message(&self.config, self.answer_buffer.clone());
|
||
- self.request_redraw();
|
||
+ // Buffer only – do not emit partial lines. This avoids cases
|
||
+ // where long responses appear truncated if the terminal
|
||
+ // wrapped early. The full message is emitted on
|
||
+ // AgentMessage.
|
||
+ self.answer_buffer.push_str(&delta);
|
||
}
|
||
EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => {
|
||
- if self.reasoning_buffer.is_empty() {
|
||
- self.conversation_history
|
||
- .add_agent_reasoning(&self.config, "".to_string());
|
||
- }
|
||
- self.reasoning_buffer.push_str(&delta.clone());
|
||
- self.conversation_history
|
||
- .replace_prev_agent_reasoning(&self.config, self.reasoning_buffer.clone());
|
||
- self.request_redraw();
|
||
+ // Buffer only – disable incremental reasoning streaming so we
|
||
+ // avoid truncated intermediate lines. Full text emitted on
|
||
+ // AgentReasoning.
|
||
+ self.reasoning_buffer.push_str(&delta);
|
||
}
|
||
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
|
||
- // if the reasoning buffer is empty, this means we haven't received any
|
||
- // delta. Thus, we need to print the message as a new reasoning.
|
||
- if self.reasoning_buffer.is_empty() {
|
||
- self.conversation_history
|
||
- .add_agent_reasoning(&self.config, "".to_string());
|
||
+ // Emit full reasoning text once. Some providers might send
|
||
+ // final event with empty text if only deltas were used.
|
||
+ let full = if text.is_empty() {
|
||
+ std::mem::take(&mut self.reasoning_buffer)
|
||
} else {
|
||
- // else, we rerender one last time.
|
||
+ self.reasoning_buffer.clear();
|
||
+ text
|
||
+ };
|
||
+ if !full.is_empty() {
|
||
self.conversation_history
|
||
- .replace_prev_agent_reasoning(&self.config, text);
|
||
+ .add_agent_reasoning(&self.config, full);
|
||
+ self.emit_last_history_entry();
|
||
}
|
||
- self.reasoning_buffer.clear();
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::TaskStarted => {
|
||
@@ -310,7 +322,8 @@ impl ChatWidget<'_> {
|
||
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
|
||
}
|
||
EventMsg::Error(ErrorEvent { message }) => {
|
||
- self.conversation_history.add_error(message);
|
||
+ self.conversation_history.add_error(message.clone());
|
||
+ self.emit_last_history_entry();
|
||
self.bottom_pane.set_task_running(false);
|
||
}
|
||
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||
@@ -346,6 +359,7 @@ impl ChatWidget<'_> {
|
||
|
||
self.conversation_history
|
||
.add_patch_event(PatchEventType::ApprovalRequest, changes);
|
||
+ self.emit_last_history_entry();
|
||
|
||
self.conversation_history.scroll_to_bottom();
|
||
|
||
@@ -364,7 +378,8 @@ impl ChatWidget<'_> {
|
||
cwd: _,
|
||
}) => {
|
||
self.conversation_history
|
||
- .reset_or_add_active_exec_command(call_id, command);
|
||
+ .add_active_exec_command(call_id, command);
|
||
+ self.emit_last_history_entry();
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||
@@ -376,6 +391,7 @@ impl ChatWidget<'_> {
|
||
// summary so the user can follow along.
|
||
self.conversation_history
|
||
.add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes);
|
||
+ self.emit_last_history_entry();
|
||
if !auto_approved {
|
||
self.conversation_history.scroll_to_bottom();
|
||
}
|
||
@@ -399,6 +415,7 @@ impl ChatWidget<'_> {
|
||
}) => {
|
||
self.conversation_history
|
||
.add_active_mcp_tool_call(call_id, server, tool, arguments);
|
||
+ self.emit_last_history_entry();
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => {
|
||
@@ -425,6 +442,7 @@ impl ChatWidget<'_> {
|
||
event => {
|
||
self.conversation_history
|
||
.add_background_event(format!("{event:?}"));
|
||
+ self.emit_last_history_entry();
|
||
self.request_redraw();
|
||
}
|
||
}
|
||
@@ -441,7 +459,9 @@ impl ChatWidget<'_> {
|
||
}
|
||
|
||
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||
- self.conversation_history.add_diff_output(diff_output);
|
||
+ self.conversation_history
|
||
+ .add_diff_output(diff_output.clone());
|
||
+ self.emit_last_history_entry();
|
||
self.request_redraw();
|
||
}
|
||
|
||
@@ -492,19 +512,18 @@ impl ChatWidget<'_> {
|
||
tracing::error!("failed to submit op: {e}");
|
||
}
|
||
}
|
||
+
|
||
+ pub(crate) fn token_usage(&self) -> &TokenUsage {
|
||
+ &self.token_usage
|
||
+ }
|
||
}
|
||
|
||
impl WidgetRef for &ChatWidget<'_> {
|
||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||
- let bottom_height = self.bottom_pane.calculate_required_height(&area);
|
||
-
|
||
- let chunks = Layout::default()
|
||
- .direction(Direction::Vertical)
|
||
- .constraints([Constraint::Min(0), Constraint::Length(bottom_height)])
|
||
- .split(area);
|
||
-
|
||
- self.conversation_history.render(chunks[0], buf);
|
||
- (&self.bottom_pane).render(chunks[1], buf);
|
||
+ // In the hybrid inline viewport mode we only draw the interactive
|
||
+ // bottom pane; history entries are injected directly into scrollback
|
||
+ // via `Terminal::insert_before`.
|
||
+ (&self.bottom_pane).render(area, buf);
|
||
}
|
||
}
|
||
|
||
diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs
|
||
index ceaf115f33..d8035eff64 100644
|
||
--- a/codex-rs/tui/src/conversation_history_widget.rs
|
||
+++ b/codex-rs/tui/src/conversation_history_widget.rs
|
||
@@ -202,14 +202,6 @@ impl ConversationHistoryWidget {
|
||
self.add_to_history(HistoryCell::new_agent_reasoning(config, text));
|
||
}
|
||
|
||
- pub fn replace_prev_agent_reasoning(&mut self, config: &Config, text: String) {
|
||
- self.replace_last_agent_reasoning(config, text);
|
||
- }
|
||
-
|
||
- pub fn replace_prev_agent_message(&mut self, config: &Config, text: String) {
|
||
- self.replace_last_agent_message(config, text);
|
||
- }
|
||
-
|
||
pub fn add_background_event(&mut self, message: String) {
|
||
self.add_to_history(HistoryCell::new_background_event(message));
|
||
}
|
||
@@ -235,30 +227,6 @@ impl ConversationHistoryWidget {
|
||
self.add_to_history(HistoryCell::new_active_exec_command(call_id, command));
|
||
}
|
||
|
||
- /// If an ActiveExecCommand with the same call_id already exists, replace
|
||
- /// it with a fresh one (resetting start time and view). Otherwise, add a new entry.
|
||
- pub fn reset_or_add_active_exec_command(&mut self, call_id: String, command: Vec<String>) {
|
||
- // Find the most recent matching ActiveExecCommand.
|
||
- let maybe_idx = self.entries.iter().rposition(|entry| {
|
||
- if let HistoryCell::ActiveExecCommand { call_id: id, .. } = &entry.cell {
|
||
- id == &call_id
|
||
- } else {
|
||
- false
|
||
- }
|
||
- });
|
||
-
|
||
- if let Some(idx) = maybe_idx {
|
||
- let width = self.cached_width.get();
|
||
- self.entries[idx].cell = HistoryCell::new_active_exec_command(call_id.clone(), command);
|
||
- if width > 0 {
|
||
- let height = self.entries[idx].cell.height(width);
|
||
- self.entries[idx].line_count.set(height);
|
||
- }
|
||
- } else {
|
||
- self.add_active_exec_command(call_id, command);
|
||
- }
|
||
- }
|
||
-
|
||
pub fn add_active_mcp_tool_call(
|
||
&mut self,
|
||
call_id: String,
|
||
@@ -281,40 +249,10 @@ impl ConversationHistoryWidget {
|
||
});
|
||
}
|
||
|
||
- pub fn replace_last_agent_reasoning(&mut self, config: &Config, text: String) {
|
||
- if let Some(idx) = self
|
||
- .entries
|
||
- .iter()
|
||
- .rposition(|entry| matches!(entry.cell, HistoryCell::AgentReasoning { .. }))
|
||
- {
|
||
- let width = self.cached_width.get();
|
||
- let entry = &mut self.entries[idx];
|
||
- entry.cell = HistoryCell::new_agent_reasoning(config, text);
|
||
- let height = if width > 0 {
|
||
- entry.cell.height(width)
|
||
- } else {
|
||
- 0
|
||
- };
|
||
- entry.line_count.set(height);
|
||
- }
|
||
- }
|
||
-
|
||
- pub fn replace_last_agent_message(&mut self, config: &Config, text: String) {
|
||
- if let Some(idx) = self
|
||
- .entries
|
||
- .iter()
|
||
- .rposition(|entry| matches!(entry.cell, HistoryCell::AgentMessage { .. }))
|
||
- {
|
||
- let width = self.cached_width.get();
|
||
- let entry = &mut self.entries[idx];
|
||
- entry.cell = HistoryCell::new_agent_message(config, text);
|
||
- let height = if width > 0 {
|
||
- entry.cell.height(width)
|
||
- } else {
|
||
- 0
|
||
- };
|
||
- entry.line_count.set(height);
|
||
- }
|
||
+ /// Return the lines for the most recently appended entry (if any) so the
|
||
+ /// parent widget can surface them via the new scrollback insertion path.
|
||
+ pub(crate) fn last_entry_plain_lines(&self) -> Option<Vec<Line<'static>>> {
|
||
+ self.entries.last().map(|e| e.cell.plain_lines())
|
||
}
|
||
|
||
pub fn record_completed_exec_command(
|
||
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
|
||
index b481313405..df58e163f3 100644
|
||
--- a/codex-rs/tui/src/history_cell.rs
|
||
+++ b/codex-rs/tui/src/history_cell.rs
|
||
@@ -123,6 +123,30 @@ pub(crate) enum HistoryCell {
|
||
const TOOL_CALL_MAX_LINES: usize = 5;
|
||
|
||
impl HistoryCell {
|
||
+ /// Return a cloned, plain representation of the cell's lines suitable for
|
||
+ /// 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<PathBuf>) -> std::io::Result<()> {
|
||
+pub fn run_main(
|
||
+ cli: Cli,
|
||
+ codex_linux_sandbox_exe: Option<PathBuf>,
|
||
+) -> std::io::Result<codex_core::protocol::TokenUsage> {
|
||
let (sandbox_mode, approval_policy) = if cli.full_auto {
|
||
(
|
||
Some(SandboxMode::WorkspaceWrite),
|
||
@@ -147,24 +150,8 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::
|
||
// `--allow-no-git-exec` flag.
|
||
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
|
||
|
||
- try_run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx);
|
||
- Ok(())
|
||
-}
|
||
-
|
||
-#[expect(
|
||
- clippy::print_stderr,
|
||
- reason = "Resort to stderr in exceptional situations."
|
||
-)]
|
||
-fn try_run_ratatui_app(
|
||
- cli: Cli,
|
||
- config: Config,
|
||
- show_login_screen: bool,
|
||
- show_git_warning: bool,
|
||
- log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||
-) {
|
||
- if let Err(report) = run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) {
|
||
- eprintln!("Error: {report:?}");
|
||
- }
|
||
+ run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
|
||
+ .map_err(|err| std::io::Error::other(err.to_string()))
|
||
}
|
||
|
||
fn run_ratatui_app(
|
||
@@ -173,16 +160,15 @@ fn run_ratatui_app(
|
||
show_login_screen: bool,
|
||
show_git_warning: bool,
|
||
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||
-) -> color_eyre::Result<()> {
|
||
+) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
|
||
color_eyre::install()?;
|
||
|
||
- // Forward panic reports through the tracing stack so that they appear in
|
||
- // the status indicator instead of breaking the alternate screen – the
|
||
- // normal 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)?;
|
||
+ println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||
Ok(())
|
||
})
|
||
}
|
||
diff --git a/codex-rs/tui/src/mouse_capture.rs b/codex-rs/tui/src/mouse_capture.rs
|
||
deleted file mode 100644
|
||
index cff1296f6d..0000000000
|
||
--- a/codex-rs/tui/src/mouse_capture.rs
|
||
+++ /dev/null
|
||
@@ -1,69 +0,0 @@
|
||
-use crossterm::event::DisableMouseCapture;
|
||
-use crossterm::event::EnableMouseCapture;
|
||
-use ratatui::crossterm::execute;
|
||
-use std::io::Result;
|
||
-use std::io::stdout;
|
||
-
|
||
-pub(crate) struct MouseCapture {
|
||
- mouse_capture_is_active: bool,
|
||
-}
|
||
-
|
||
-impl MouseCapture {
|
||
- pub(crate) fn new_with_capture(mouse_capture_is_active: bool) -> Result<Self> {
|
||
- if mouse_capture_is_active {
|
||
- enable_capture()?;
|
||
- }
|
||
-
|
||
- Ok(Self {
|
||
- mouse_capture_is_active,
|
||
- })
|
||
- }
|
||
-}
|
||
-
|
||
-impl MouseCapture {
|
||
- /// Idempotent method to set the mouse capture state.
|
||
- pub fn set_active(&mut self, is_active: bool) -> Result<()> {
|
||
- match (self.mouse_capture_is_active, is_active) {
|
||
- (true, true) => {}
|
||
- (false, false) => {}
|
||
- (true, false) => {
|
||
- disable_capture()?;
|
||
- self.mouse_capture_is_active = false;
|
||
- }
|
||
- (false, true) => {
|
||
- enable_capture()?;
|
||
- self.mouse_capture_is_active = true;
|
||
- }
|
||
- }
|
||
- Ok(())
|
||
- }
|
||
-
|
||
- pub(crate) fn toggle(&mut self) -> Result<()> {
|
||
- self.set_active(!self.mouse_capture_is_active)
|
||
- }
|
||
-
|
||
- pub(crate) fn disable(&mut self) -> Result<()> {
|
||
- if self.mouse_capture_is_active {
|
||
- disable_capture()?;
|
||
- self.mouse_capture_is_active = false;
|
||
- }
|
||
- Ok(())
|
||
- }
|
||
-}
|
||
-
|
||
-impl Drop for MouseCapture {
|
||
- fn drop(&mut self) {
|
||
- if self.disable().is_err() {
|
||
- // The user is likely shutting down, so ignore any errors so the
|
||
- // shutdown process can complete.
|
||
- }
|
||
- }
|
||
-}
|
||
-
|
||
-fn enable_capture() -> Result<()> {
|
||
- execute!(stdout(), EnableMouseCapture)
|
||
-}
|
||
-
|
||
-fn disable_capture() -> Result<()> {
|
||
- execute!(stdout(), DisableMouseCapture)
|
||
-}
|
||
diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs
|
||
index bb72ce561c..603eb721cd 100644
|
||
--- a/codex-rs/tui/src/slash_command.rs
|
||
+++ b/codex-rs/tui/src/slash_command.rs
|
||
@@ -15,7 +15,6 @@ pub enum SlashCommand {
|
||
New,
|
||
Diff,
|
||
Quit,
|
||
- ToggleMouseMode,
|
||
}
|
||
|
||
impl SlashCommand {
|
||
@@ -23,9 +22,6 @@ impl SlashCommand {
|
||
pub fn description(self) -> &'static str {
|
||
match self {
|
||
SlashCommand::New => "Start a new chat.",
|
||
- SlashCommand::ToggleMouseMode => {
|
||
- "Toggle mouse mode (enable for scrolling, disable for text selection)"
|
||
- }
|
||
SlashCommand::Quit => "Exit the application.",
|
||
SlashCommand::Diff => {
|
||
"Show git diff of the working directory (including untracked files)"
|
||
diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs
|
||
index dda61d0bd0..973ef09818 100644
|
||
--- a/codex-rs/tui/src/status_indicator_widget.rs
|
||
+++ b/codex-rs/tui/src/status_indicator_widget.rs
|
||
@@ -34,11 +34,6 @@ pub(crate) struct StatusIndicatorWidget {
|
||
/// time).
|
||
text: String,
|
||
|
||
- /// Height in terminal rows – matches the height of the textarea at the
|
||
- /// moment the task started so the UI does not jump when we toggle between
|
||
- /// input mode and loading mode.
|
||
- height: u16,
|
||
-
|
||
frame_idx: Arc<AtomicUsize>,
|
||
running: Arc<AtomicBool>,
|
||
// Keep one sender alive to prevent the channel from closing while the
|
||
@@ -50,7 +45,7 @@ pub(crate) struct StatusIndicatorWidget {
|
||
|
||
impl StatusIndicatorWidget {
|
||
/// Create a new status indicator and start the animation timer.
|
||
- pub(crate) fn new(app_event_tx: AppEventSender, height: u16) -> Self {
|
||
+ pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
|
||
let frame_idx = Arc::new(AtomicUsize::new(0));
|
||
let running = Arc::new(AtomicBool::new(true));
|
||
|
||
@@ -72,18 +67,12 @@ impl StatusIndicatorWidget {
|
||
|
||
Self {
|
||
text: String::from("waiting for logs…"),
|
||
- height: height.max(3),
|
||
frame_idx,
|
||
running,
|
||
_app_event_tx: app_event_tx,
|
||
}
|
||
}
|
||
|
||
- /// Preferred height in terminal rows.
|
||
- pub(crate) fn get_height(&self) -> u16 {
|
||
- self.height
|
||
- }
|
||
-
|
||
/// Update the line that is displayed in the widget.
|
||
pub(crate) fn update_text(&mut self, text: String) {
|
||
self.text = text.replace(['\n', '\r'], " ");
|
||
diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs
|
||
index 99ff034361..66ae1cfb96 100644
|
||
--- a/codex-rs/tui/src/tui.rs
|
||
+++ b/codex-rs/tui/src/tui.rs
|
||
@@ -4,31 +4,39 @@ use std::io::stdout;
|
||
|
||
use codex_core::config::Config;
|
||
use crossterm::event::DisableBracketedPaste;
|
||
-use crossterm::event::DisableMouseCapture;
|
||
use crossterm::event::EnableBracketedPaste;
|
||
use ratatui::Terminal;
|
||
+use ratatui::TerminalOptions;
|
||
+use ratatui::Viewport;
|
||
use ratatui::backend::CrosstermBackend;
|
||
use ratatui::crossterm::execute;
|
||
-use ratatui::crossterm::terminal::EnterAlternateScreen;
|
||
-use ratatui::crossterm::terminal::LeaveAlternateScreen;
|
||
use ratatui::crossterm::terminal::disable_raw_mode;
|
||
use ratatui::crossterm::terminal::enable_raw_mode;
|
||
|
||
-use crate::mouse_capture::MouseCapture;
|
||
-
|
||
/// A type alias for the terminal type used in this application
|
||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||
|
||
-/// Initialize the terminal
|
||
-pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> {
|
||
- execute!(stdout(), EnterAlternateScreen)?;
|
||
+/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||
+pub fn init(_config: &Config) -> Result<Tui> {
|
||
execute!(stdout(), EnableBracketedPaste)?;
|
||
- let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?;
|
||
|
||
enable_raw_mode()?;
|
||
set_panic_hook();
|
||
- let tui = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||
- Ok((tui, mouse_capture))
|
||
+
|
||
+ // Reserve a fixed number of lines for the interactive viewport (composer,
|
||
+ // status, popups). History is injected above using `insert_before`. This
|
||
+ // is an initial step of the refactor – later the height can become
|
||
+ // dynamic. For now a conservative default keeps enough room for the
|
||
+ // 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
|
||
|
||
```diff
|
||
@@ -355,6 +356,36 @@ pub struct TokenUsage {
|
||
pub total_tokens: u64,
|
||
}
|
||
|
||
+#[derive(Debug, Clone, Deserialize, Serialize)]
|
||
```
|
||
|
||
> protocol.rs should only have things that are part of the protocol between the business logic and the UI layer. This seems more appropriate for codex-rs/common?
|
||
|
||
### codex-rs/tui/src/app_event.rs
|
||
|
||
- Created: 2025-07-24 15:50:16 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2228908991
|
||
|
||
```diff
|
||
@@ -49,4 +50,9 @@ pub(crate) enum AppEvent {
|
||
query: String,
|
||
matches: Vec<FileMatch>,
|
||
},
|
||
+
|
||
+ /// Append immutable history lines above the inline viewport. Part of the
|
||
+ /// incremental migration to the hybrid scrollback model described in
|
||
+ /// `fix-history-plan.md`.
|
||
```
|
||
|
||
> I think the reference to `fix-history-plan.md` is outdated now?
|
||
|
||
### codex-rs/tui/src/chatwidget.rs
|
||
|
||
- Created: 2025-07-24 15:53:59 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2228918474
|
||
|
||
```diff
|
||
@@ -53,6 +50,10 @@ pub(crate) struct ChatWidget<'a> {
|
||
token_usage: TokenUsage,
|
||
reasoning_buffer: String,
|
||
answer_buffer: String,
|
||
+ // Buffer for streaming assistant answer text; we do not surface partial
|
||
```
|
||
|
||
> I feel like this comment should be "attached" to `fn` or `struct`?
|
||
|
||
- Created: 2025-07-24 15:58:00 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2228929499
|
||
|
||
```diff
|
||
@@ -247,50 +257,54 @@ impl ChatWidget<'_> {
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||
- // if the answer buffer is empty, this means we haven't received any
|
||
- // delta. Thus, we need to print the message as a new answer.
|
||
- if self.answer_buffer.is_empty() {
|
||
- self.conversation_history
|
||
- .add_agent_message(&self.config, message);
|
||
+ // Final assistant answer. Prefer the fully provided message
|
||
+ // from the event; if it is empty fall back to any accumulated
|
||
+ // delta buffer (some providers may only stream deltas and send
|
||
+ // an empty final message).
|
||
+ let full = if message.is_empty() {
|
||
+ std::mem::take(&mut self.answer_buffer)
|
||
} else {
|
||
+ self.answer_buffer.clear();
|
||
+ message
|
||
+ };
|
||
+ if !full.is_empty() {
|
||
self.conversation_history
|
||
- .replace_prev_agent_message(&self.config, message);
|
||
+ .add_agent_message(&self.config, full);
|
||
+ if let Some(lines) = self.conversation_history.last_entry_plain_lines() {
|
||
+ self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||
+ }
|
||
}
|
||
- self.answer_buffer.clear();
|
||
self.request_redraw();
|
||
}
|
||
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
|
||
- if self.answer_buffer.is_empty() {
|
||
- self.conversation_history
|
||
- .add_agent_message(&self.config, "".to_string());
|
||
- }
|
||
- self.answer_buffer.push_str(&delta.clone());
|
||
- self.conversation_history
|
||
- .replace_prev_agent_message(&self.config, self.answer_buffer.clone());
|
||
- self.request_redraw();
|
||
+ // Buffer only – do not emit partial lines. This avoids cases
|
||
```
|
||
|
||
> Could we keep track of the number of `\n` in `answer_buffer` and add the number introduced by `delta` and flush everything up to the latest `\n`? Maybe in a follow-up PR?
|
||
|
||
- Created: 2025-07-24 17:07:09 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229087676
|
||
|
||
```diff
|
||
@@ -220,7 +221,10 @@ impl ChatWidget<'_> {
|
||
|
||
// Only show text portion in conversation history for now.
|
||
if !text.is_empty() {
|
||
- self.conversation_history.add_user_message(text);
|
||
+ self.conversation_history.add_user_message(text.clone());
|
||
+ if let Some(lines) = self.conversation_history.last_entry_plain_lines() {
|
||
+ self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||
+ }
|
||
```
|
||
|
||
> This seems to happen enough that it feels like it should be a helper function.
|
||
|
||
- Created: 2025-07-25 04:05:00 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2230089078
|
||
|
||
```diff
|
||
@@ -53,6 +50,10 @@ pub(crate) struct ChatWidget<'a> {
|
||
token_usage: TokenUsage,
|
||
reasoning_buffer: String,
|
||
answer_buffer: String,
|
||
+ // Buffer for streaming assistant answer text; we do not surface partial
|
||
```
|
||
|
||
> Note doc comments in Rust should have `///`.
|
||
|
||
### codex-rs/tui/src/history_cell.rs
|
||
|
||
- Created: 2025-07-24 17:21:58 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229115614
|
||
|
||
```diff
|
||
@@ -123,6 +123,30 @@ pub(crate) enum HistoryCell {
|
||
const TOOL_CALL_MAX_LINES: usize = 5;
|
||
|
||
impl HistoryCell {
|
||
+ /// Return a cloned, plain representation of the cell's lines suitable for
|
||
+ /// 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
|
||
|
||
```diff
|
||
@@ -0,0 +1,183 @@
|
||
+use crate::tui; // for the Tui type alias
|
||
```
|
||
|
||
> Drop comment?
|
||
|
||
- Created: 2025-07-24 17:35:56 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229143383
|
||
|
||
```diff
|
||
@@ -0,0 +1,183 @@
|
||
+use crate::tui; // for the Tui type alias
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+use ratatui::widgets::Paragraph;
|
||
+use ratatui::widgets::Widget;
|
||
+use unicode_width::UnicodeWidthChar;
|
||
+
|
||
+/// Insert a batch of history lines into the terminal scrollback above the
|
||
+/// inline viewport.
|
||
+///
|
||
+/// The incoming `lines` are the logical lines supplied by the
|
||
+/// `ConversationHistory`. They may contain embedded newlines and arbitrary
|
||
+/// runs of whitespace inside individual [`Span`]s. All of that must be
|
||
+/// normalised before writing to the backing terminal buffer because the
|
||
+/// ratatui [`Paragraph`] widget does not perform soft‑wrapping when used in
|
||
```
|
||
|
||
> So even if we explicitly use the `wrap()` method of `Paragraph`, there is nothing we can do?
|
||
|
||
- Created: 2025-07-24 17:42:35 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229155356
|
||
|
||
```diff
|
||
@@ -0,0 +1,183 @@
|
||
+use crate::tui; // for the Tui type alias
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+use ratatui::widgets::Paragraph;
|
||
+use ratatui::widgets::Widget;
|
||
+use unicode_width::UnicodeWidthChar;
|
||
+
|
||
+/// Insert a batch of history lines into the terminal scrollback above the
|
||
+/// inline viewport.
|
||
+///
|
||
+/// The incoming `lines` are the logical lines supplied by the
|
||
+/// `ConversationHistory`. They may contain embedded newlines and arbitrary
|
||
+/// runs of whitespace inside individual [`Span`]s. All of that must be
|
||
+/// normalised before writing to the backing terminal buffer because the
|
||
+/// ratatui [`Paragraph`] widget does not perform 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
|
||
|
||
```diff
|
||
@@ -0,0 +1,183 @@
|
||
+use crate::tui; // for the Tui type alias
|
||
+use ratatui::layout::Rect;
|
||
+use ratatui::text::Line;
|
||
+use ratatui::text::Span;
|
||
+use ratatui::widgets::Paragraph;
|
||
+use ratatui::widgets::Widget;
|
||
+use unicode_width::UnicodeWidthChar;
|
||
+
|
||
+/// Insert a batch of history lines into the terminal scrollback above the
|
||
+/// inline viewport.
|
||
+///
|
||
+/// The incoming `lines` are the logical lines supplied by the
|
||
+/// `ConversationHistory`. They may contain embedded newlines and arbitrary
|
||
+/// runs of whitespace inside individual [`Span`]s. All of that must be
|
||
+/// normalised before writing to the backing terminal buffer because the
|
||
+/// ratatui [`Paragraph`] widget does not perform 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 `self` instead of passed in?
|
||
|
||
### codex-rs/tui/src/lib.rs
|
||
|
||
- Created: 2025-07-24 17:29:49 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229131705
|
||
|
||
```diff
|
||
@@ -47,7 +47,10 @@ mod user_approval_widget;
|
||
|
||
pub use cli::Cli;
|
||
|
||
-pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::Result<()> {
|
||
+pub fn run_main(
|
||
+ cli: Cli,
|
||
+ codex_linux_sandbox_exe: Option<PathBuf>,
|
||
+) -> std::io::Result<codex_core::protocol::TokenUsage> {
|
||
```
|
||
|
||
> I think it might be helpful to introduce a struct like `FinalOutput` that has `TokenUsage` (and maybe `final_message: Option<String>`) as a field.
|
||
>
|
||
> Then I would have it `impl Display for FinalOutput` with the implementation that corresponds to this code:
|
||
>
|
||
> ```
|
||
> println!(
|
||
> "Token usage: total={} input={}{} output={}{}",
|
||
> usage.total_tokens,
|
||
> usage.input_tokens,
|
||
> usage
|
||
> .cached_input_tokens
|
||
> .map(|c| format!(" (cached {c})"))
|
||
> .unwrap_or_default(),
|
||
> usage.output_tokens,
|
||
> usage
|
||
> .reasoning_output_tokens
|
||
> .map(|r| format!(" (reasoning {r})"))
|
||
> .unwrap_or_default()
|
||
> );
|
||
> ```
|
||
>
|
||
> so that you can just do:
|
||
>
|
||
> ```
|
||
> println!("{final_output}");
|
||
> ```
|
||
>
|
||
> rather than copy/paste the two implementations.
|
||
|
||
### codex-rs/tui/src/slash_command.rs
|
||
|
||
- Created: 2025-07-24 17:31:23 UTC | Link: https://github.com/openai/codex/pull/1672#discussion_r2229135058
|
||
|
||
```diff
|
||
@@ -15,17 +15,13 @@ pub enum SlashCommand {
|
||
New,
|
||
Diff,
|
||
Quit,
|
||
- ToggleMouseMode,
|
||
}
|
||
|
||
impl SlashCommand {
|
||
/// User-visible description shown in the popup.
|
||
pub fn description(self) -> &'static str {
|
||
match self {
|
||
SlashCommand::New => "Start a new chat.",
|
||
- SlashCommand::ToggleMouseMode => {
|
||
```
|
||
|
||
> Nice! We can also remove `disable_mouse_capture` in the docs (`config.md`) and `Config` code. |