Easily Selectable History (#1672)

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.
This commit is contained in:
easong-openai
2025-07-25 01:56:40 -07:00
committed by GitHub
parent 508abbe990
commit 480e82b00d
24 changed files with 394 additions and 353 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -852,6 +852,7 @@ dependencies = [
"tui-markdown",
"tui-textarea",
"unicode-segmentation",
"unicode-width 0.1.14",
"uuid",
]

View File

@@ -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);

View File

@@ -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
```

View File

@@ -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")]

View File

@@ -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<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,

View File

@@ -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]

View File

@@ -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

View File

@@ -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>>),
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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>,

View File

@@ -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)
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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(

View File

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

View File

@@ -0,0 +1,178 @@
use crate::tui;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar;
/// Insert a batch of history lines into the terminal scrollback above the
/// inline viewport.
///
/// The incoming `lines` are the logical lines supplied by the
/// `ConversationHistory`. They may contain embedded newlines and arbitrary
/// runs of whitespace inside individual [`Span`]s. All of that must be
/// normalised before writing to the backing terminal buffer because the
/// ratatui [`Paragraph`] widget does not perform softwrapping when used in
/// conjunction with [`Terminal::insert_before`].
///
/// This function performs a minimal wrapping / normalisation pass:
///
/// * A terminal width is determined via `Terminal::size()` (falling back to
/// 80 columns if the size probe fails).
/// * Each logical line is broken into words and whitespace. Consecutive
/// whitespace is collapsed to a single space; leading whitespace is
/// discarded.
/// * Words that do not fit on the current line cause a soft wrap. Extremely
/// long words (longer than the terminal width) are split character by
/// character so they still populate the display instead of overflowing the
/// line.
/// * Explicit `\n` characters inside a span force a hard line break.
/// * Empty lines (including a trailing newline at the end of the batch) are
/// preserved so vertical spacing remains faithful to the logical history.
///
/// Finally the physical lines are rendered directly into the terminal's
/// scrollback region using [`Terminal::insert_before`]. Any backend error is
/// ignored: failing to insert history is nonfatal and a subsequent redraw
/// will eventually repaint a consistent view.
fn display_width(s: &str) -> usize {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum()
}
struct LineBuilder {
term_width: usize,
spans: Vec<Span<'static>>,
width: usize,
}
impl LineBuilder {
fn new(term_width: usize) -> Self {
Self {
term_width,
spans: Vec::new(),
width: 0,
}
}
fn flush_line(&mut self, out: &mut Vec<Line<'static>>) {
out.push(Line::from(std::mem::take(&mut self.spans)));
self.width = 0;
}
fn push_segment(&mut self, text: String, style: Style) {
self.width += display_width(&text);
self.spans.push(Span::styled(text, style));
}
fn push_word(&mut self, word: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
if word.is_empty() {
return;
}
let w_len = display_width(word);
if self.width > 0 && self.width + w_len > self.term_width {
self.flush_line(out);
}
if w_len > self.term_width && self.width == 0 {
// Split an overlong word across multiple lines.
let mut cur = String::new();
let mut cur_w = 0;
for ch in word.chars() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
if cur_w + ch_w > self.term_width && cur_w > 0 {
self.push_segment(cur.clone(), style);
self.flush_line(out);
cur.clear();
cur_w = 0;
}
cur.push(ch);
cur_w += ch_w;
}
if !cur.is_empty() {
self.push_segment(cur, style);
}
} else {
self.push_segment(word.clone(), style);
}
word.clear();
}
fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
if ws.is_empty() {
return;
}
let space_w = display_width(ws);
if self.width > 0 && self.width + space_w > self.term_width {
self.flush_line(out);
}
if self.width > 0 {
self.push_segment(" ".to_string(), style);
}
ws.clear();
}
}
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize;
let mut physical: Vec<Line<'static>> = Vec::new();
for logical in lines.into_iter() {
if logical.spans.is_empty() {
physical.push(logical);
continue;
}
let mut builder = LineBuilder::new(term_width);
let mut buf_space = String::new();
for span in logical.spans.into_iter() {
let style = span.style;
let mut buf_word = String::new();
for ch in span.content.chars() {
if ch == '\n' {
builder.push_word(&mut buf_word, style, &mut physical);
buf_space.clear();
builder.flush_line(&mut physical);
continue;
}
if ch.is_whitespace() {
builder.push_word(&mut buf_word, style, &mut physical);
buf_space.push(ch);
} else {
builder.consume_whitespace(&mut buf_space, style, &mut physical);
buf_word.push(ch);
}
if builder.width >= term_width {
builder.flush_line(&mut physical);
}
}
builder.push_word(&mut buf_word, style, &mut physical);
// whitespace intentionally left to allow collapsing across spans
}
if !builder.spans.is_empty() {
physical.push(Line::from(std::mem::take(&mut builder.spans)));
} else {
// Preserve explicit blank line (e.g. due to a trailing newline).
physical.push(Line::from(Vec::<Span<'static>>::new()));
}
}
let total = physical.len() as u16;
terminal
.insert_before(total, |buf| {
let width = buf.area.width;
for (i, line) in physical.into_iter().enumerate() {
let area = Rect {
x: 0,
y: i as u16,
width,
height: 1,
};
Paragraph::new(line).render(area, buf);
}
})
.ok();
}

View File

@@ -33,10 +33,10 @@ mod file_search;
mod get_git_diff;
mod git_warning_screen;
mod history_cell;
mod insert_history;
mod log_layer;
mod login_screen;
mod markdown;
mod mouse_capture;
mod scroll_event_helper;
mod slash_command;
mod status_indicator_widget;
@@ -47,7 +47,10 @@ mod user_approval_widget;
pub use cli::Cli;
pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::Result<()> {
pub fn run_main(
cli: Cli,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<codex_core::protocol::TokenUsage> {
let (sandbox_mode, approval_policy) = if cli.full_auto {
(
Some(SandboxMode::WorkspaceWrite),
@@ -147,24 +150,8 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> std::io::
// `--allow-no-git-exec` flag.
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
try_run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx);
Ok(())
}
#[expect(
clippy::print_stderr,
reason = "Resort to stderr in exceptional situations."
)]
fn try_run_ratatui_app(
cli: Cli,
config: Config,
show_login_screen: bool,
show_git_warning: bool,
log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) {
if let Err(report) = run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) {
eprintln!("Error: {report:?}");
}
run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
.map_err(|err| std::io::Error::other(err.to_string()))
}
fn run_ratatui_app(
@@ -173,16 +160,15 @@ fn run_ratatui_app(
show_login_screen: bool,
show_git_warning: bool,
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> color_eyre::Result<()> {
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
color_eyre::install()?;
// Forward panic reports through the tracing stack so that they appear in
// the status indicator instead of breaking the alternate screen the
// normal coloureyre hook writes to stderr which would corrupt the UI.
// Forward panic reports through tracing so they appear in the UI status
// line instead of interleaving raw panic output with the interface.
std::panic::set_hook(Box::new(|info| {
tracing::error!("panic: {info}");
}));
let (mut terminal, mut mouse_capture) = tui::init(&config)?;
let mut terminal = tui::init(&config)?;
terminal.clear()?;
let Cli { prompt, images, .. } = cli;
@@ -204,10 +190,12 @@ fn run_ratatui_app(
});
}
let app_result = app.run(&mut terminal, &mut mouse_capture);
let app_result = app.run(&mut terminal);
let usage = app.token_usage();
restore();
app_result
// ignore error when collecting usage report underlying error instead
app_result.map(|_| usage)
}
#[expect(

View File

@@ -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(())
})
}

View File

@@ -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)
}

View File

@@ -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)"

View File

@@ -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'], " ");

View File

@@ -4,31 +4,39 @@ use std::io::stdout;
use codex_core::config::Config;
use crossterm::event::DisableBracketedPaste;
use crossterm::event::DisableMouseCapture;
use crossterm::event::EnableBracketedPaste;
use ratatui::Terminal;
use ratatui::TerminalOptions;
use ratatui::Viewport;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::EnterAlternateScreen;
use ratatui::crossterm::terminal::LeaveAlternateScreen;
use ratatui::crossterm::terminal::disable_raw_mode;
use ratatui::crossterm::terminal::enable_raw_mode;
use crate::mouse_capture::MouseCapture;
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal
pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> {
execute!(stdout(), EnterAlternateScreen)?;
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
pub fn init(_config: &Config) -> Result<Tui> {
execute!(stdout(), EnableBracketedPaste)?;
let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?;
enable_raw_mode()?;
set_panic_hook();
let tui = Terminal::new(CrosstermBackend::new(stdout()))?;
Ok((tui, mouse_capture))
// Reserve a fixed number of lines for the interactive viewport (composer,
// status, popups). History is injected above using `insert_before`. This
// is an initial step of the refactor later the height can become
// dynamic. For now a conservative default keeps enough room for the
// multiline composer while not occupying the whole screen.
const BOTTOM_VIEWPORT_HEIGHT: u16 = 8;
let backend = CrosstermBackend::new(stdout());
let tui = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(BOTTOM_VIEWPORT_HEIGHT),
},
)?;
Ok(tui)
}
fn set_panic_hook() {
@@ -41,14 +49,7 @@ fn set_panic_hook() {
/// Restore the terminal to its original state
pub fn restore() -> Result<()> {
// We are shutting down, and we cannot reference the `MouseCapture`, so we
// categorically disable mouse capture just to be safe.
if execute!(stdout(), DisableMouseCapture).is_err() {
// It is possible that `DisableMouseCapture` is written more than once
// on shutdown, so ignore the error in this case.
}
execute!(stdout(), DisableBracketedPaste)?;
execute!(stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())
}

View File

@@ -116,10 +116,6 @@ pub(crate) struct UserApprovalWidget<'a> {
done: bool,
}
// Number of lines automatically added by ratatuis [`Block`] when
// borders are enabled (one at the top, one at the bottom).
const BORDER_LINES: u16 = 2;
impl UserApprovalWidget<'_> {
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let input = Input::default();
@@ -190,28 +186,6 @@ impl UserApprovalWidget<'_> {
}
}
pub(crate) fn get_height(&self, area: &Rect) -> u16 {
let confirmation_prompt_height =
self.get_confirmation_prompt_height(area.width - BORDER_LINES);
match self.mode {
Mode::Select => {
let num_option_lines = SELECT_OPTIONS.len() as u16;
confirmation_prompt_height + num_option_lines + BORDER_LINES
}
Mode::Input => {
// 1. "Give the model feedback ..." prompt
// 2. A singleline input field (we allocate exactly one row;
// the `tui-input` widget will scroll horizontally if the
// text exceeds the width).
const INPUT_PROMPT_LINES: u16 = 1;
const INPUT_FIELD_LINES: u16 = 1;
confirmation_prompt_height + INPUT_PROMPT_LINES + INPUT_FIELD_LINES + BORDER_LINES
}
}
}
fn get_confirmation_prompt_height(&self, width: u16) -> u16 {
// Should cache this for last value of width.
self.confirmation_prompt.line_count(width) as u16
@@ -333,7 +307,32 @@ impl WidgetRef for &UserApprovalWidget<'_> {
.borders(Borders::ALL)
.border_type(BorderType::Rounded);
let inner = outer.inner(area);
let prompt_height = self.get_confirmation_prompt_height(inner.width);
// Determine how many rows we can allocate for the static confirmation
// prompt while *always* keeping enough space for the interactive
// response area (select list or input field). When the full prompt
// would exceed the available height we truncate it so the response
// options never get pushed out of view. This keeps the approval modal
// usable even when the overall bottom viewport is small.
// Full height of the prompt (may be larger than the available area).
let full_prompt_height = self.get_confirmation_prompt_height(inner.width);
// Minimum rows that must remain for the interactive section.
let min_response_rows = match self.mode {
Mode::Select => SELECT_OPTIONS.len() as u16,
// In input mode we need exactly two rows: one for the guidance
// prompt and one for the single-line input field.
Mode::Input => 2,
};
// Clamp prompt height so confirmation + response never exceed the
// available space. `saturating_sub` avoids underflow when the area is
// too small even for the minimal layout in this unlikely case we
// fall back to zero-height prompt so at least the options are
// visible.
let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows));
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
@@ -342,8 +341,7 @@ impl WidgetRef for &UserApprovalWidget<'_> {
let response_chunk = chunks[1];
// Build the inner lines based on the mode. Collect them into a List of
// non-wrapping lines rather than a Paragraph because get_height(Rect)
// depends on this behavior for its calculation.
// non-wrapping lines rather than a Paragraph for predictable layout.
let lines = match self.mode {
Mode::Select => SELECT_OPTIONS
.iter()