From c22f48d3f1e5f01d7bf500b657bbfdea2cdb300a Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Mon, 6 Oct 2025 11:52:44 -0700 Subject: [PATCH 01/38] Make my status module work properly --- codex-rs/Cargo.lock | 23 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/message_history.rs | 36 +- codex-rs/protocol/src/protocol.rs | 11 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 10 +- codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 29 +- codex-rs/tui/src/bottom_pane/footer.rs | 17 +- codex-rs/tui/src/bottom_pane/mod.rs | 331 +---- ...tom_pane__chat_composer__tests__empty.snap | 3 +- ...oter__tests__footer_shortcuts_default.snap | 3 +- codex-rs/tui/src/chatwidget.rs | 358 ++++- ...chatwidget__tests__chat_small_idle_h1.snap | 2 +- ...chatwidget__tests__chat_small_idle_h2.snap | 2 +- ...chatwidget__tests__chat_small_idle_h3.snap | 2 +- ...twidget__tests__chat_small_running_h1.snap | 2 +- ...twidget__tests__chat_small_running_h2.snap | 2 +- ...twidget__tests__chat_small_running_h3.snap | 2 +- ...exec_and_status_layout_vt100_snapshot.snap | 4 +- ...dget__tests__exec_approval_modal_exec.snap | 4 +- ...atwidget__tests__status_widget_active.snap | 12 - ...sts__status_widget_and_approval_modal.snap | 19 - codex-rs/tui/src/chatwidget/tests.rs | 90 +- codex-rs/tui/src/history_cell.rs | 13 +- codex-rs/tui/src/lib.rs | 2 +- ...coalesces_reads_across_multiple_calls.snap | 2 +- ...sces_sequential_reads_within_one_call.snap | 2 +- ...ator_widget__tests__renders_truncated.snap | 6 - ...__tests__renders_with_queued_messages.snap | 12 - ...t__tests__renders_with_working_header.snap | 6 - codex-rs/tui/src/status/mod.rs | 4 + codex-rs/tui/src/status_indicator_widget.rs | 297 ---- codex-rs/tui/src/statusline/mod.rs | 1266 +++++++++++++++++ codex-rs/tui/src/statusline/palette.rs | 42 + ...atusline__tests__statusline_narrow_40.snap | 5 + ...statusline__tests__statusline_wide_80.snap | 28 + codex-rs/tui/src/statusline/state.rs | 306 ++++ codex-rs/tui/tests/suite/mod.rs | 1 - codex-rs/tui/tests/suite/status_indicator.rs | 24 - flake.lock | 20 +- flake.nix | 104 +- 42 files changed, 2162 insertions(+), 945 deletions(-) delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap delete mode 100644 codex-rs/tui/src/status_indicator_widget.rs create mode 100644 codex-rs/tui/src/statusline/mod.rs create mode 100644 codex-rs/tui/src/statusline/palette.rs create mode 100644 codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_narrow_40.snap create mode 100644 codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap create mode 100644 codex-rs/tui/src/statusline/state.rs delete mode 100644 codex-rs/tui/tests/suite/status_indicator.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d579f29346ff..893d10657523 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1042,6 +1042,7 @@ dependencies = [ "env-flags", "escargot", "eventsource-stream", + "fs2", "futures", "indexmap 2.10.0", "landlock", @@ -1386,6 +1387,7 @@ dependencies = [ "diffy", "dirs", "dunce", + "hostname", "image", "insta", "itertools 0.14.0", @@ -2355,6 +2357,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.31" @@ -2642,6 +2654,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "http" version = "1.3.1" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 3d499f900733..aa85f704329b 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -70,6 +70,7 @@ tree-sitter-bash = { workspace = true } uuid = { workspace = true, features = ["serde", "v4"] } which = { workspace = true } wildmatch = { workspace = true } +fs2 = "0.4.3" [target.'cfg(target_os = "linux")'.dependencies] diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index d102b2ef7c64..d91fc0954db5 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -27,6 +27,8 @@ use std::time::Duration; use tokio::fs; use tokio::io::AsyncReadExt; +use fs2::FileExt; + use crate::config::Config; use crate::config_types::HistoryPersistence; @@ -112,19 +114,21 @@ pub(crate) async fn append_entry( // Perform a blocking write under an advisory write lock using std::fs. tokio::task::spawn_blocking(move || -> Result<()> { - // Retry a few times to avoid indefinite blocking when contended. for _ in 0..MAX_RETRIES { - match history_file.try_lock() { + match FileExt::try_lock_exclusive(&history_file) { Ok(()) => { - // While holding the exclusive lock, write the full line. - history_file.write_all(line.as_bytes())?; - history_file.flush()?; + let write_result = history_file + .write_all(line.as_bytes()) + .and_then(|_| history_file.flush()); + let unlock_result = FileExt::unlock(&history_file); + write_result?; + unlock_result?; return Ok(()); } - Err(std::fs::TryLockError::WouldBlock) => { + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(RETRY_SLEEP); } - Err(e) => return Err(e.into()), + Err(err) => return Err(err), } } @@ -216,15 +220,15 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option { + let mut found = None; let reader = BufReader::new(&file); for (idx, line_res) in reader.lines().enumerate() { let line = match line_res { Ok(l) => l, Err(e) => { + let _ = FileExt::unlock(&file); tracing::warn!(error = %e, "failed to read line from history file"); return None; } @@ -232,18 +236,22 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option(&line) { - Ok(entry) => return Some(entry), + Ok(entry) => { + found = Some(entry); + break; + } Err(e) => { + let _ = FileExt::unlock(&file); tracing::warn!(error = %e, "failed to parse history entry"); return None; } } } } - // Not found at requested offset. - return None; + let _ = FileExt::unlock(&file); + return found; } - Err(std::fs::TryLockError::WouldBlock) => { + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(RETRY_SLEEP); } Err(e) => { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b6b279e0766c..6fcd62660742 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -360,11 +360,12 @@ impl SandboxPolicy { // Linux or Windows, but supporting it here gives users a way to // provide the model with their own temporary directory without // having to hardcode it in the config. - if !exclude_tmpdir_env_var - && let Some(tmpdir) = std::env::var_os("TMPDIR") - && !tmpdir.is_empty() - { - roots.push(PathBuf::from(tmpdir)); + if !exclude_tmpdir_env_var { + if let Some(tmpdir) = std::env::var_os("TMPDIR") { + if !tmpdir.is_empty() { + roots.push(PathBuf::from(tmpdir)); + } + } } // For each root, compute subpaths that should remain read-only. diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 7356ae334bfa..817ee58a8dc9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,6 +42,7 @@ codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } +hostname = "0.4" diffy = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cb3dea5e60d9..5b23ffd43c5e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -327,6 +327,14 @@ impl App { AppEvent::OpenReasoningPopup { model, presets } => { self.chat_widget.open_reasoning_popup(model, presets); } + AppEvent::StatusLineGit(snapshot) => { + self.chat_widget.update_statusline_git(snapshot); + tui.frame_requester().schedule_frame(); + } + AppEvent::StatusLineKubeContext(context) => { + self.chat_widget.update_statusline_kube_context(context); + tui.frame_requester().schedule_frame(); + } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); match persist_model_selection(&self.config.codex_home, profile, &model, effort) @@ -428,7 +436,7 @@ impl App { } // Esc primes/advances backtracking only in normal (not working) mode // with an empty composer. In any other state, forward Esc so the - // active UI (e.g. status indicator, modals, popups) handles it. + // active UI (e.g. status line, modals, popups) handles it. KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d79c8ae1325..2a5e042426df 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -7,6 +7,7 @@ use codex_file_search::FileMatch; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; +use crate::statusline::StatusLineGitSnapshot; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; @@ -67,6 +68,8 @@ pub(crate) enum AppEvent { presets: Vec, }, + StatusLineGit(Option), + StatusLineKubeContext(Option), /// Update the current approval policy in the running app and widget. UpdateAskForApprovalPolicy(AskForApproval), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ad9770726a31..476066883fe2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1645,33 +1645,10 @@ mod tests { row }; - let mut hint_row: Option<(u16, String)> = None; - for y in 0..area.height { - let row = row_to_string(y); - if row.contains("? for shortcuts") { - hint_row = Some((y, row)); - break; - } - } - - let (hint_row_idx, hint_row_contents) = - hint_row.expect("expected footer hint row to be rendered"); - assert_eq!( - hint_row_idx, - area.height - 1, - "hint row should occupy the bottom line: {hint_row_contents:?}", - ); - + let bottom_row = row_to_string(area.height - 1); assert!( - hint_row_idx > 0, - "expected a spacing row above the footer hints", - ); - - let spacing_row = row_to_string(hint_row_idx - 1); - assert_eq!( - spacing_row.trim(), - "", - "expected blank spacing row above hints but saw: {spacing_row:?}", + bottom_row.trim().is_empty(), + "expected footer row to be blank when idle but saw: {bottom_row:?}", ); } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index b4c5617ddf24..983eafa46076 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -80,10 +80,7 @@ fn footer_lines(props: FooterProps) -> Vec> { if props.is_task_running { vec![context_window_line(props.context_window_percent)] } else { - vec![Line::from(vec![ - key_hint::plain(KeyCode::Char('?')).into(), - " for shortcuts".dim(), - ])] + Vec::new() } } FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { @@ -220,15 +217,9 @@ fn build_columns(entries: Vec>) -> Vec> { fn context_window_line(percent: Option) -> Line<'static> { let mut spans: Vec> = Vec::new(); - match percent { - Some(percent) => { - spans.push(format!("{percent}%").dim()); - spans.push(" context left".dim()); - } - None => { - spans.push(key_hint::plain(KeyCode::Char('?')).into()); - spans.push(" for shortcuts".dim()); - } + if let Some(percent) = percent { + spans.push(format!("{percent}%").dim()); + spans.push(" context left".dim()); } Line::from(spans) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6fe673a23b54..1621d311c375 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -8,8 +8,6 @@ use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::widgets::WidgetRef; use std::time::Duration; @@ -43,7 +41,6 @@ pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; -use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use list_selection_view::SelectionAction; pub(crate) use list_selection_view::SelectionItem; @@ -64,10 +61,6 @@ pub(crate) struct BottomPane { ctrl_c_quit_hint: bool, esc_backtrack_hint: bool, - /// Inline status indicator shown above the composer while a task is running. - status: Option, - /// Queued user messages to show under the status indicator. - queued_user_messages: Vec, context_window_percent: Option, } @@ -98,21 +91,19 @@ impl BottomPane { has_input_focus: params.has_input_focus, is_task_running: false, ctrl_c_quit_hint: false, - status: None, - queued_user_messages: Vec::new(), esc_backtrack_hint: false, context_window_percent: None, } } - pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { - self.status.as_ref() - } - fn active_view(&self) -> Option<&dyn BottomPaneView> { self.view_stack.last().map(std::convert::AsRef::as_ref) } + pub(crate) fn has_active_view(&self) -> bool { + self.active_view().is_some() + } + fn push_view(&mut self, view: Box) { self.view_stack.push(view); self.request_redraw(); @@ -122,16 +113,10 @@ impl BottomPane { // Always reserve one blank row above the pane for visual spacing. let top_margin = 1; - // Base height depends on whether a modal/overlay is active. let base = match self.active_view().as_ref() { Some(view) => view.desired_height(width), - None => self.composer.desired_height(width).saturating_add( - self.status - .as_ref() - .map_or(0, |status| status.desired_height(width)), - ), + None => self.composer.desired_height(width), }; - // Account for bottom padding rows. Top spacing is handled in layout(). base.saturating_add(Self::BOTTOM_PAD_LINES) .saturating_add(top_margin) } @@ -150,25 +135,14 @@ impl BottomPane { width: area.width, height: area.height - top_margin - bottom_margin, }; - match self.active_view() { - Some(_) => [Rect::ZERO, area], - None => { - let status_height = self - .status - .as_ref() - .map_or(0, |status| status.desired_height(area.width)) - .min(area.height.saturating_sub(1)); - - Layout::vertical([Constraint::Max(status_height), Constraint::Min(1)]).areas(area) - } - } + + [Rect::ZERO, area] } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - // Hide the cursor whenever an overlay view is active (e.g. the - // status indicator shown while a task is running, or approval modal). - // In these states the textarea is not interactable, so we should not - // show its caret. + // Hide the cursor whenever an overlay view is active. In these + // states the textarea is not interactable, so we should not show its + // caret. let [_, content] = self.layout(area); if let Some(view) = self.active_view() { view.cursor_pos(content) @@ -197,17 +171,6 @@ impl BottomPane { self.request_redraw(); InputResult::None } else { - // If a task is running and a status line is visible, allow Esc to - // send an interrupt even while the composer has focus. - if matches!(key_event.code, crossterm::event::KeyCode::Esc) - && self.is_task_running - && let Some(status) = &self.status - { - // Send Op::Interrupt - status.interrupt(); - self.request_redraw(); - return InputResult::None; - } let (input_result, needs_redraw) = self.composer.handle_key_event(key_event); if needs_redraw { self.request_redraw(); @@ -275,16 +238,6 @@ impl BottomPane { self.composer.current_text() } - /// Update the animated header shown to the left of the brackets in the - /// status indicator (defaults to "Working"). No-ops if the status - /// indicator is not active. - pub(crate) fn update_status_header(&mut self, header: String) { - if let Some(status) = self.status.as_mut() { - status.update_header(header); - self.request_redraw(); - } - } - pub(crate) fn show_ctrl_c_quit_hint(&mut self) { self.ctrl_c_quit_hint = true; self.composer @@ -325,29 +278,7 @@ impl BottomPane { pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; self.composer.set_task_running(running); - - if running { - if self.status.is_none() { - self.status = Some(StatusIndicatorWidget::new( - self.app_event_tx.clone(), - self.frame_requester.clone(), - )); - } - if let Some(status) = self.status.as_mut() { - status.set_queued_messages(self.queued_user_messages.clone()); - } - self.request_redraw(); - } else { - // Hide the status indicator when a task completes, but keep other modal views. - self.hide_status_indicator(); - } - } - - /// Hide the status indicator while leaving task-running state untouched. - pub(crate) fn hide_status_indicator(&mut self) { - if self.status.take().is_some() { - self.request_redraw(); - } + self.request_redraw(); } pub(crate) fn set_context_window_percent(&mut self, percent: Option) { @@ -366,15 +297,6 @@ impl BottomPane { self.push_view(Box::new(view)); } - /// Update the queued messages shown under the status header. - pub(crate) fn set_queued_user_messages(&mut self, queued: Vec) { - self.queued_user_messages = queued.clone(); - if let Some(status) = self.status.as_mut() { - status.set_queued_messages(queued); - } - self.request_redraw(); - } - /// Update custom prompts available for the slash popup. pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.composer.set_custom_prompts(prompts); @@ -416,25 +338,10 @@ impl BottomPane { // Otherwise create a new approval modal overlay. let modal = ApprovalOverlay::new(request, self.app_event_tx.clone()); - self.pause_status_timer_for_modal(); self.push_view(Box::new(modal)); } - fn on_active_view_complete(&mut self) { - self.resume_status_timer_after_modal(); - } - - fn pause_status_timer_for_modal(&mut self) { - if let Some(status) = self.status.as_mut() { - status.pause_timer(); - } - } - - fn resume_status_timer_after_modal(&mut self) { - if let Some(status) = self.status.as_mut() { - status.resume_timer(); - } - } + fn on_active_view_complete(&mut self) {} /// Height (terminal rows) required by the current bottom pane. pub(crate) fn request_redraw(&self) { @@ -500,19 +407,11 @@ impl BottomPane { impl WidgetRef for &BottomPane { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [status_area, content] = self.layout(area); + let [_, content] = self.layout(area); - // When a modal view is active, it owns the whole content area. if let Some(view) = self.active_view() { view.render(content, buf); } else { - // No active modal: - // If a status indicator is active, render it above the composer. - if let Some(status) = &self.status { - status.render_ref(status_area, buf); - } - - // Render the composer in the remaining area. self.composer.render_ref(content, buf); } } @@ -552,10 +451,8 @@ mod tests { assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); } - // live ring removed; related tests deleted. - #[test] - fn overlay_not_shown_above_approval_modal() { + fn composer_rendered_while_task_running() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { @@ -567,74 +464,14 @@ mod tests { disable_paste_burst: false, }); - // Create an approval modal (active view). - pane.push_approval_request(exec_request()); - - // Render and verify the top row does not include an overlay. - let area = Rect::new(0, 0, 60, 6); - let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); - - let mut r0 = String::new(); - for x in 0..area.width { - r0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - !r0.contains("Working"), - "overlay should not render above modal" - ); - } - - #[test] - fn composer_shown_after_denied_while_task_running() { - let (tx_raw, rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - // Start a running task so the status indicator is active above the composer. pane.set_task_running(true); - // Push an approval modal (e.g., command approval) which should hide the status view. - pane.push_approval_request(exec_request()); - - // Simulate pressing 'n' (No) on the modal. - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - pane.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - - // After denial, since the task is still running, the status indicator should be - // visible above the composer. The modal should be gone. - assert!( - pane.view_stack.is_empty(), - "no active modal view after denial" - ); - - // Render and ensure the top row includes the Working header and a composer line below. - // Give the animation thread a moment to tick. - std::thread::sleep(Duration::from_millis(120)); - let area = Rect::new(0, 0, 40, 6); + let area = Rect::new(0, 0, 40, 4); let mut buf = Buffer::empty(area); (&pane).render_ref(area, &mut buf); - let mut row1 = String::new(); - for x in 0..area.width { - row1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - row1.contains("Working"), - "expected Working header after denial on row 1: {row1:?}" - ); - // Composer placeholder should be visible somewhere below. let mut found_composer = false; - for y in 1..area.height { + for y in 0..area.height { let mut row = String::new(); for x in 0..area.width { row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); @@ -646,141 +483,7 @@ mod tests { } assert!( found_composer, - "expected composer visible under status line" - ); - - // Drain the channel to avoid unused warnings. - drop(rx); - } - - #[test] - fn status_indicator_visible_during_command_execution() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - // Begin a task: show initial status. - pane.set_task_running(true); - - // Use a height that allows the status line to be visible above the composer. - let area = Rect::new(0, 0, 40, 6); - let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); - - let mut row0 = String::new(); - for x in 0..area.width { - row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - row0.contains("Working"), - "expected Working header: {row0:?}" - ); - } - - #[test] - fn bottom_padding_present_with_status_above_composer() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - // Activate spinner (status view replaces composer) with no live ring. - pane.set_task_running(true); - - // Use height == desired_height; expect 1 status row at top and 2 bottom padding rows. - let height = pane.desired_height(30); - assert!( - height >= 3, - "expected at least 3 rows with bottom padding; got {height}" - ); - let area = Rect::new(0, 0, 30, height); - let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); - - // Row 1 contains the status header (row 0 is the spacer) - let mut top = String::new(); - for x in 0..area.width { - top.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - top.trim_start().starts_with("• Working"), - "expected top row to start with '• Working': {top:?}" - ); - assert!( - top.contains("Working"), - "expected Working header on top row: {top:?}" - ); - - // Last row should be blank padding; the row above should generally contain composer content. - let mut r_last = String::new(); - for x in 0..area.width { - r_last.push(buf[(x, height - 1)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - r_last.trim().is_empty(), - "expected last row blank: {r_last:?}" - ); - } - - #[test] - fn bottom_padding_shrinks_when_tiny() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - pane.set_task_running(true); - - // Height=2 → status on one row, composer on the other. - let area2 = Rect::new(0, 0, 20, 2); - let mut buf2 = Buffer::empty(area2); - (&pane).render_ref(area2, &mut buf2); - let mut row0 = String::new(); - let mut row1 = String::new(); - for x in 0..area2.width { - row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' ')); - row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' ')); - } - let has_composer = row0.contains("Ask Codex") || row1.contains("Ask Codex"); - assert!( - has_composer, - "expected composer to be visible on one of the rows: row0={row0:?}, row1={row1:?}" - ); - assert!( - row0.contains("Working") || row1.contains("Working"), - "expected status header to be visible at height=2: row0={row0:?}, row1={row1:?}" - ); - - // Height=1 → no padding; single row is the composer (status hidden). - let area1 = Rect::new(0, 0, 20, 1); - let mut buf1 = Buffer::empty(area1); - (&pane).render_ref(area1, &mut buf1); - let mut only = String::new(); - for x in 0..area1.width { - only.push(buf1[(x, 0)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - only.contains("Ask Codex"), - "expected composer with no padding: {only:?}" + "composer placeholder should be visible while task running" ); } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index db92427864d1..2ae146291b4e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1938 expression: terminal.backend() --- " " @@ -12,4 +11,4 @@ expression: terminal.backend() " " " " " " -" ? for shortcuts " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index b2de215488fe..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- -" ? for shortcuts " +" " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 79ee810fa287..a24f0f0e39b8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3,8 +3,12 @@ use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; +use std::env; +use std::fs; + use codex_core::config::Config; use codex_core::config_types::Notifications; +use codex_core::git_info::collect_git_info; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::protocol::AgentMessageDeltaEvent; @@ -25,6 +29,7 @@ use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::InputItem; use codex_core::protocol::InputMessageKind; use codex_core::protocol::ListCustomPromptsResponseEvent; +use codex_core::protocol::McpInvocation; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; @@ -53,9 +58,12 @@ use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use tokio::process::Command; use tokio::sync::mpsc::UnboundedSender; +use tokio::task::spawn_blocking; use tracing::debug; use crate::app_event::AppEvent; @@ -83,6 +91,8 @@ use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; +use crate::statusline::StatusLineGitSnapshot; +use crate::statusline::StatusLineState; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; @@ -112,7 +122,9 @@ use codex_git_tooling::GitToolingError; use codex_git_tooling::create_ghost_commit; use codex_git_tooling::restore_ghost_commit; use codex_protocol::plan_tool::UpdatePlanArgs; +use hostname::get as get_hostname; use strum::IntoEnumIterator; +use tokio::runtime::Handle; const MAX_TRACKED_GHOST_COMMITS: usize = 20; @@ -222,6 +234,7 @@ pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, bottom_pane: BottomPane, + status_line: StatusLineState, active_cell: Option>, config: Config, auth_manager: Arc, @@ -303,14 +316,72 @@ impl ChatWidget { } } + fn bootstrap_status_line(&mut self) { + self.sync_status_line_model(); + let initial_tokens = self.token_info.clone(); + self.status_line.update_tokens(initial_tokens); + self.status_line.set_devspace(detect_devspace()); + self.status_line.set_hostname(detect_hostname()); + self.status_line.set_aws_profile(detect_aws_profile()); + self.refresh_queued_user_messages(); + self.spawn_status_line_background_tasks(); + } + + fn sync_status_line_model(&mut self) { + self.status_line.update_model( + self.config.model.clone(), + self.config.model_reasoning_effort, + ); + } + + fn spawn_status_line_background_tasks(&self) { + self.spawn_git_refresh(); + self.spawn_kube_refresh(); + } + + fn spawn_git_refresh(&self) { + let Ok(handle) = Handle::try_current() else { + return; + }; + let cwd = self.config.cwd.clone(); + let tx = self.app_event_tx.clone(); + handle.spawn(async move { + let snapshot = collect_status_line_git_snapshot(cwd).await; + tx.send(AppEvent::StatusLineGit(snapshot)); + }); + } + + fn spawn_kube_refresh(&self) { + let Ok(handle) = Handle::try_current() else { + return; + }; + let tx = self.app_event_tx.clone(); + handle.spawn(async move { + let context = detect_kube_context_async().await; + tx.send(AppEvent::StatusLineKubeContext(context)); + }); + } + + pub(crate) fn update_statusline_git(&mut self, git: Option) { + self.status_line.set_git_info(git); + } + + pub(crate) fn update_statusline_kube_context(&mut self, context: Option) { + self.status_line.set_kubernetes_context(context); + } + // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.conversation_id = Some(event.session_id); + self.status_line + .set_session_id(Some(event.session_id.to_string())); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); + self.sync_status_line_model(); + self.spawn_status_line_background_tasks(); self.add_to_history(history_cell::new_session_info( &self.config, event, @@ -351,10 +422,7 @@ impl ChatWidget { self.reasoning_buffer.push_str(&delta); if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - // Update the shimmer header to the extracted reasoning chunk header. - self.bottom_pane.update_status_header(header); - } else { - // Fallback while we don't yet have a bold header: leave existing header as-is. + self.status_line.update_run_header(&header); } self.request_redraw(); } @@ -386,6 +454,7 @@ impl ChatWidget { fn on_task_started(&mut self) { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); + self.status_line.start_task("Working"); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.request_redraw(); @@ -397,6 +466,8 @@ impl ChatWidget { // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); self.running_commands.clear(); + self.status_line.complete_task(); + self.spawn_git_refresh(); self.request_redraw(); // If there is a queued user message, send exactly one now to begin the next turn. @@ -408,7 +479,7 @@ impl ChatWidget { } pub(crate) fn set_token_info(&mut self, info: Option) { - if let Some(info) = info { + if let Some(ref info) = info { let context_window = info .model_context_window .or(self.config.model_context_window); @@ -417,8 +488,11 @@ impl ChatWidget { .percent_of_context_window_remaining(window) }); self.bottom_pane.set_context_window_percent(percent); - self.token_info = Some(info); + } else { + self.bottom_pane.set_context_window_percent(None); } + self.token_info = info.clone(); + self.status_line.update_tokens(info); } fn on_rate_limit_snapshot(&mut self, snapshot: Option) { @@ -460,6 +534,8 @@ impl ChatWidget { self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.stream_controller = None; + self.status_line.complete_task(); + self.spawn_git_refresh(); } fn on_error(&mut self, message: String) { @@ -501,7 +577,7 @@ impl ChatWidget { format!("{queued_text}\n{existing_text}") }; self.bottom_pane.set_composer_text(combined); - // Clear the queue and update the status indicator list. + // Clear the queue and update the status line queue preview. self.queued_user_messages.clear(); self.refresh_queued_user_messages(); } @@ -545,6 +621,8 @@ impl ChatWidget { } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { + self.status_line.resume_timer(); + self.status_line.update_run_header("Applying patch"); self.add_to_history(history_cell::new_patch_event( event.changes, &self.config.cwd, @@ -632,7 +710,6 @@ impl ChatWidget { if let Some(controller) = self.stream_controller.as_mut() { let (cell, is_idle) = controller.on_commit_tick(); if let Some(cell) = cell { - self.bottom_pane.hide_status_indicator(); self.add_boxed_history(cell); } if is_idle { @@ -665,7 +742,6 @@ impl ChatWidget { fn handle_stream_finished(&mut self) { if self.task_complete_pending { - self.bottom_pane.hide_status_indicator(); self.task_complete_pending = false; } // A completed stream indicates non-exec content was just inserted. @@ -679,10 +755,7 @@ impl ChatWidget { if self.stream_controller.is_none() { if self.needs_final_message_separator { - let elapsed_seconds = self - .bottom_pane - .status_widget() - .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); + let elapsed_seconds = self.status_line.elapsed_seconds(); self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); self.needs_final_message_separator = false; } @@ -739,17 +812,21 @@ impl ChatWidget { self.flush_active_cell(); } } + if self.running_commands.is_empty() { + self.status_line.update_run_header("Working"); + self.spawn_git_refresh(); + } } pub(crate) fn handle_patch_apply_end_now( &mut self, event: codex_core::protocol::PatchApplyEndEvent, ) { - // If the patch was successful, just let the "Edited" block stand. - // Otherwise, add a failure block. if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } + self.status_line.update_run_header("Working"); + self.spawn_git_refresh(); } pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { @@ -763,6 +840,8 @@ impl ChatWidget { command: ev.command, reason: ev.reason, }; + self.status_line + .update_run_header(&Self::approval_status_label("command")); self.bottom_pane.push_approval_request(request); self.request_redraw(); } @@ -780,6 +859,8 @@ impl ChatWidget { changes: ev.changes.clone(), cwd: self.config.cwd.clone(), }; + self.status_line + .update_run_header(&Self::approval_status_label("patch")); self.bottom_pane.push_approval_request(request); self.request_redraw(); self.notify(Notification::EditApprovalRequested { @@ -789,7 +870,9 @@ impl ChatWidget { } pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { - // Ensure the status indicator is visible while the command runs. + self.status_line.resume_timer(); + self.status_line + .update_run_header(&Self::exec_status_label(&ev.command)); self.running_commands.insert( ev.call_id.clone(), RunningCommand { @@ -823,7 +906,10 @@ impl ChatWidget { pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); + self.status_line.resume_timer(); self.flush_active_cell(); + self.status_line + .update_run_header(&Self::tool_status_label(&ev.invocation)); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( ev.call_id, ev.invocation, @@ -859,25 +945,28 @@ impl ChatWidget { if let Some(extra) = extra_cell { self.add_boxed_history(extra); } + self.status_line.update_run_header("Working"); } - fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height); - let remaining = area.height.saturating_sub(bottom_min); + fn layout_areas(&self, area: Rect) -> [Rect; 4] { + let status_height = if area.height > 0 { 1 } else { 0 }; + let available = area.height.saturating_sub(status_height); + let bottom_min = self.bottom_pane.desired_height(area.width).min(available); + let remaining = available.saturating_sub(bottom_min); let active_desired = self .active_cell .as_ref() .map_or(0, |c| c.desired_height(area.width) + 1); let active_height = active_desired.min(remaining); - // Note: no header area; remaining is not used beyond computing active height. let header_height = 0u16; Layout::vertical([ Constraint::Length(header_height), Constraint::Length(active_height), - Constraint::Min(bottom_min), + Constraint::Length(bottom_min), + Constraint::Length(status_height), ]) .areas(area) } @@ -899,18 +988,24 @@ impl ChatWidget { let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); - Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), + let frame_requester_clone = frame_requester.clone(); + let app_event_tx_clone = app_event_tx.clone(); + let status_line = StatusLineState::new(&config, frame_requester_clone.clone()); + let bottom_pane = BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + }); + + let mut widget = Self { + app_event_tx: app_event_tx_clone, + frame_requester: frame_requester_clone, codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - }), + bottom_pane, + status_line, active_cell: None, config: config.clone(), auth_manager, @@ -938,7 +1033,10 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), - } + }; + + widget.bootstrap_status_line(); + widget } /// Create a ChatWidget attached to an existing conversation (e.g., a fork). @@ -962,18 +1060,24 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); - Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), + let frame_requester_clone = frame_requester.clone(); + let app_event_tx_clone = app_event_tx.clone(); + let status_line = StatusLineState::new(&config, frame_requester_clone.clone()); + let bottom_pane = BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + }); + + let mut widget = Self { + app_event_tx: app_event_tx_clone, + frame_requester: frame_requester_clone, codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - }), + bottom_pane, + status_line, active_cell: None, config: config.clone(), auth_manager, @@ -994,14 +1098,17 @@ impl ChatWidget { conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, - suppress_session_configured_redraw: true, + suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), - } + }; + + widget.bootstrap_status_line(); + widget } pub fn desired_height(&self, width: u16) -> u16 { @@ -1010,6 +1117,7 @@ impl ChatWidget { .active_cell .as_ref() .map_or(0, |c| c.desired_height(width) + 1) + + 1 } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { @@ -1558,13 +1666,37 @@ impl ChatWidget { } /// Rebuild and update the queued user messages from the current queue. + fn exec_status_label(command: &[String]) -> String { + if command.is_empty() { + return "Running command".to_string(); + } + let joined = command.join(" "); + let summary = truncate_text(&joined, 40); + format!("Running {summary}") + } + + fn tool_status_label(invocation: &McpInvocation) -> String { + let label = if invocation.server.is_empty() { + invocation.tool.clone() + } else { + format!("{}::{}", invocation.server, invocation.tool) + }; + let summary = truncate_text(&label, 36); + format!("Calling {summary}") + } + + fn approval_status_label(subject: &str) -> String { + format!("Awaiting {subject} approval") + } + fn refresh_queued_user_messages(&mut self) { let messages: Vec = self .queued_user_messages .iter() .map(|m| m.text.clone()) .collect(); - self.bottom_pane.set_queued_user_messages(messages); + self.status_line.set_queued_messages(messages); + self.request_redraw(); } pub(crate) fn add_diff_in_progress(&mut self) { @@ -1823,12 +1955,14 @@ impl ChatWidget { /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.config.model_reasoning_effort = effort; + self.sync_status_line_model(); } /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); self.config.model = model.to_string(); + self.sync_status_line_model(); } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { @@ -1874,7 +2008,7 @@ impl ChatWidget { } /// True when the UI is in the regular composer state with no running task, - /// no modal overlay (e.g. approvals or status indicator), and no composer popups. + /// no modal overlay (e.g. approvals), and no composer popups. /// In this state Esc-Esc backtracking is enabled. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { self.bottom_pane.is_normal_backtrack_mode() @@ -2106,17 +2240,19 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; + self.bottom_pane.set_context_window_percent(None); + self.status_line.update_tokens(None); } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let [_, _, bottom_pane_area] = self.layout_areas(area); + let [_, _, bottom_pane_area, _] = self.layout_areas(area); self.bottom_pane.cursor_pos(bottom_pane_area) } } impl WidgetRef for &ChatWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area); + let [_, active_cell_area, bottom_pane_area, status_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); if !active_cell_area.is_empty() && let Some(cell) = &self.active_cell @@ -2130,10 +2266,132 @@ impl WidgetRef for &ChatWidget { tool.render_ref(area, buf); } } + if !status_area.is_empty() { + if self.bottom_pane.has_active_view() { + Paragraph::new("").render(status_area, buf); + } else { + let line = self.status_line.render_line(status_area.width); + Paragraph::new(line).render(status_area, buf); + } + } self.last_rendered_width.set(Some(area.width as usize)); } } +async fn collect_status_line_git_snapshot(cwd: PathBuf) -> Option { + let info = collect_git_info(&cwd).await?; + let (dirty, ahead, behind) = git_status_porcelain(&cwd) + .await + .unwrap_or((false, None, None)); + Some(StatusLineGitSnapshot { + branch: info.branch, + dirty, + ahead, + behind, + }) +} + +async fn git_status_porcelain(cwd: &Path) -> Option<(bool, Option, Option)> { + let output = Command::new("git") + .args(["status", "--porcelain=2", "--branch"]) + .current_dir(cwd) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let mut dirty = false; + let mut ahead = None; + let mut behind = None; + for line in text.lines() { + if !line.starts_with('#') { + dirty = true; + continue; + } + if let Some(rest) = line.strip_prefix("# branch.ab ") { + let mut parts = rest.split_whitespace(); + if let Some(ahead_part) = parts.next() { + ahead = ahead_part.strip_prefix('+').and_then(|s| s.parse().ok()); + } + if let Some(behind_part) = parts.next() { + behind = behind_part.strip_prefix('-').and_then(|s| s.parse().ok()); + } + } + } + Some((dirty, ahead, behind)) +} + +async fn detect_kube_context_async() -> Option { + spawn_blocking(detect_kube_context_sync) + .await + .ok() + .flatten() +} + +fn detect_kube_context_sync() -> Option { + for path in kube_config_paths() { + if let Ok(contents) = fs::read_to_string(&path) { + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') { + continue; + } + if let Some(value) = trimmed.strip_prefix("current-context:") { + let context = value.trim(); + if !context.is_empty() { + return Some(trim_kube_context(context)); + } + } + } + } + } + None +} + +fn kube_config_paths() -> Vec { + if let Some(paths) = env::var_os("KUBECONFIG") { + env::split_paths(&paths).collect() + } else if let Some(home) = env::var_os("HOME") { + vec![PathBuf::from(home).join(".kube/config")] + } else { + Vec::new() + } +} + +fn trim_kube_context(context: &str) -> String { + context.rsplit('/').next().unwrap_or(context).to_string() +} + +fn detect_devspace() -> Option { + env::var("TMUX_DEVSPACE") + .ok() + .filter(|s| !s.trim().is_empty()) +} + +fn detect_aws_profile() -> Option { + env::var("AWS_PROFILE") + .or_else(|_| env::var("AWS_VAULT")) + .ok() + .map(|profile| { + profile + .trim() + .trim_start_matches("export AWS_PROFILE=") + .to_string() + }) + .filter(|s| !s.is_empty()) +} + +fn detect_hostname() -> Option { + if let Ok(host) = env::var("HOSTNAME") + && !host.trim().is_empty() + { + return Some(host); + } + get_hostname().ok().and_then(|os| os.into_string().ok()) +} + enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap index 51f7901d3a63..a979a98d85e8 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"› Ask Codex to do anything " +" tui  󱚥 gpt-5-codex   vermissian  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap index fb07268dd387..0e6da96a1c07 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -" " "› Ask Codex to do anything " +" tui  󱚥 gpt-5-codex   vermissian  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index 60ef79386d12..477aa4a1e753 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -4,4 +4,4 @@ expression: terminal.backend() --- " " "› Ask Codex to do anything " -" " +" tui  󱚥 gpt-5-codex   vermissian  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap index 51f7901d3a63..f4616e8cf758 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"› Ask Codex to do anything " +" ⠋  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index 630d1a3bbec1..aee748e675f7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -2,5 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -"• Thinking (0s • esc to interrupt) " "› Ask Codex to do anything " +" ⠋  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index 60ef79386d12..74a4c4d51f3c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -4,4 +4,4 @@ expression: terminal.backend() --- " " "› Ask Codex to do anything " -" " +" ⠋  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index b83bf8a91a63..706d23934ce9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -9,7 +9,7 @@ expression: term.backend().vt100().screen().contents() └ Search Change Approved Read diff_render.rs -• Investigating rendering code (0s • esc to interrupt) - › Summarize recent commits + + ⠋  tui  󱚥 gpt-5-codex   󰋩 earth   vermissian   ck-kubero-admin  diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index f52a0f38a29b..0a4f71608ea4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -1,9 +1,10 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 413 expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 15 }, + area: Rect { x: 0, y: 0, width: 80, height: 16 }, content: [ " ", " ", @@ -20,6 +21,7 @@ Buffer { " ", " Press enter to confirm or esc to cancel ", " ", + " ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap deleted file mode 100644 index 7c3253a2ea94..000000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -"• Analyzing (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" ? for shortcuts " -" " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap deleted file mode 100644 index d1951cd08258..000000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() ---- -" " -" " -" Would you like to run the following command? " -" " -" Reason: this is a test reason such as one that would be produced by the " -" model " -" " -" $ echo 'hello world' " -" " -"› 1. Yes, proceed " -" 2. Yes, and don't ask again for this command " -" 3. No, and tell Codex what to do differently esc " -" " -" Press enter to confirm or esc to cancel " -" " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ef24c82f5354..dc1dbc1aa184 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::statusline::StatusLineState; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use codex_core::AuthManager; @@ -248,19 +249,23 @@ fn make_chatwidget_manual() -> ( let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let cfg = test_config(); + let frame_requester = FrameRequester::test_dummy(); let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), - frame_requester: FrameRequester::test_dummy(), + frame_requester: frame_requester.clone(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - let widget = ChatWidget { + let status_line = StatusLineState::new(&cfg, frame_requester.clone()); + let mut widget = ChatWidget { app_event_tx, + frame_requester, codex_op_tx: op_tx, bottom_pane: bottom, + status_line, active_cell: None, config: cfg.clone(), auth_manager, @@ -276,17 +281,17 @@ fn make_chatwidget_manual() -> ( reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), conversation_id: None, - frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, ghost_snapshots: Vec::new(), - ghost_snapshots_disabled: false, + ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), }; + widget.bootstrap_status_line(); (widget, rx, op_rx) } @@ -623,7 +628,6 @@ fn streaming_final_answer_keeps_task_running_state() { chat.on_commit_tick(); assert!(chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_widget().is_none()); chat.bottom_pane .set_composer_text("queued submission".to_string()); @@ -1500,82 +1504,6 @@ fn ui_snapshots_small_heights_task_running() { } } -// Snapshot test: status widget + approval modal active together -// The modal takes precedence visually; this captures the layout with a running -// task (status indicator active) while an approval request is shown. -#[test] -fn status_widget_and_approval_modal_snapshot() { - use codex_core::protocol::ExecApprovalRequestEvent; - - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - // Begin a running task so the status indicator would be active. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TaskStarted(TaskStartedEvent { - model_context_window: None, - }), - }); - // Provide a deterministic header for the status line. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); - - // Now show an approval modal (e.g. exec approval). - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-exec".into(), - command: vec!["echo".into(), "hello world".into()], - cwd: std::path::PathBuf::from("/tmp"), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - }; - chat.handle_codex_event(Event { - id: "sub-approve-exec".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - // Render at the widget's desired height and snapshot. - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) - .expect("create terminal"); - terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) - .expect("draw status + approval modal"); - assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); -} - -// Snapshot test: status widget active (StatusIndicatorView) -// Ensures the VT100 rendering of the status indicator is stable when active. -#[test] -fn status_widget_active_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - // Activate the status indicator by simulating a task start. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TaskStarted(TaskStartedEvent { - model_context_window: None, - }), - }); - // Provide a deterministic header via a bold reasoning chunk. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); - // Render and snapshot. - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) - .expect("create terminal"); - terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) - .expect("draw status widget"); - assert_snapshot!("status_widget_active", terminal.backend()); -} - #[test] fn apply_patch_events_emit_history_cells() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index e3a89da34079..54d081c5f112 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -10,6 +10,7 @@ use crate::markdown::MarkdownCitationContext; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; +use crate::statusline::format_elapsed_compact; use crate::style::user_message_style; use crate::terminal_palette::default_bg; use crate::text_formatting::format_and_truncate_tool_result; @@ -1103,9 +1104,7 @@ impl FinalMessageSeparator { } impl HistoryCell for FinalMessageSeparator { fn display_lines(&self, width: u16) -> Vec> { - let elapsed_seconds = self - .elapsed_seconds - .map(super::status_indicator_widget::fmt_elapsed_compact); + let elapsed_seconds = self.elapsed_seconds.map(format_elapsed_compact); if let Some(elapsed_seconds) = elapsed_seconds { let worked_for = format!("─ Worked for {elapsed_seconds} ─"); let worked_for_width = worked_for.width(); @@ -1437,8 +1436,8 @@ mod tests { cmd: "cat shimmer.rs".into(), }, ParsedCommand::Read { - name: "status_indicator_widget.rs".into(), - cmd: "cat status_indicator_widget.rs".into(), + name: "statusline/mod.rs".into(), + cmd: "cat statusline/mod.rs".into(), }, ], output: None, @@ -1514,8 +1513,8 @@ mod tests { "c3".into(), vec!["bash".into(), "-lc".into(), "echo".into()], vec![ParsedCommand::Read { - name: "status_indicator_widget.rs".into(), - cmd: "cat status_indicator_widget.rs".into(), + name: "statusline/mod.rs".into(), + cmd: "cat statusline/mod.rs".into(), }], ) .unwrap(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 28d2a3f08b4c..c56a48e8164c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -63,7 +63,7 @@ mod session_log; mod shimmer; mod slash_command; mod status; -mod status_indicator_widget; +mod statusline; mod streaming; mod style; mod terminal_palette; diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap index fcfa31eedec6..463183f8c762 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap @@ -4,4 +4,4 @@ expression: rendered --- • Explored └ Search shimmer_spans - Read shimmer.rs, status_indicator_widget.rs + Read shimmer.rs, statusline/mod.rs diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap index 533a489ee6f0..67df000f72fb 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap @@ -5,4 +5,4 @@ expression: rendered • Explored └ Search shimmer_spans Read shimmer.rs - Read status_indicator_widget.rs + Read statusline/mod.rs diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap deleted file mode 100644 index ba179808bd1d..000000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_truncated.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/status_indicator_widget.rs -expression: terminal.backend() ---- -"• Working (0s • esc " -" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap deleted file mode 100644 index a46fbcc0439b..000000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_queued_messages.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/status_indicator_widget.rs -expression: terminal.backend() ---- -"• Working (0s • esc to interrupt) " -" " -" ↳ first " -" ↳ second " -" alt + ↑ edit " -" " -" " -" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap deleted file mode 100644 index 2b80830f81ad..000000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_with_working_header.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/status_indicator_widget.rs -expression: terminal.backend() ---- -"• Working (0s • esc to interrupt) " -" " diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index eccb6b72b5a7..52db0d4319cf 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -5,6 +5,10 @@ mod helpers; mod rate_limits; pub(crate) use card::new_status_output; +pub(crate) use format::line_display_width; +pub(crate) use format::truncate_line_to_width; +pub(crate) use helpers::format_directory_display; + pub(crate) use rate_limits::RateLimitSnapshotDisplay; pub(crate) use rate_limits::rate_limit_snapshot_display; diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs deleted file mode 100644 index 2660aa1b2ac1..000000000000 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ /dev/null @@ -1,297 +0,0 @@ -//! A live status indicator that shows the *latest* log line emitted by the -//! application while the agent is processing a long‑running task. - -use std::time::Duration; -use std::time::Instant; - -use codex_core::protocol::Op; -use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::key_hint; -use crate::shimmer::shimmer_spans; -use crate::tui::FrameRequester; - -pub(crate) struct StatusIndicatorWidget { - /// Animated header text (defaults to "Working"). - header: String, - /// Queued user messages to display under the status line. - queued_messages: Vec, - - elapsed_running: Duration, - last_resume_at: Instant, - is_paused: bool, - app_event_tx: AppEventSender, - frame_requester: FrameRequester, -} - -// Format elapsed seconds into a compact human-friendly form used by the status line. -// Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s -pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { - if elapsed_secs < 60 { - return format!("{elapsed_secs}s"); - } - if elapsed_secs < 3600 { - let minutes = elapsed_secs / 60; - let seconds = elapsed_secs % 60; - return format!("{minutes}m {seconds:02}s"); - } - let hours = elapsed_secs / 3600; - let minutes = (elapsed_secs % 3600) / 60; - let seconds = elapsed_secs % 60; - format!("{hours}h {minutes:02}m {seconds:02}s") -} - -impl StatusIndicatorWidget { - pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { - Self { - header: String::from("Working"), - queued_messages: Vec::new(), - elapsed_running: Duration::ZERO, - last_resume_at: Instant::now(), - is_paused: false, - - app_event_tx, - frame_requester, - } - } - - pub fn desired_height(&self, width: u16) -> u16 { - // Status line + optional blank line + wrapped queued messages (up to 3 lines per message) - // + optional ellipsis line per truncated message + 1 spacer line - let inner_width = width.max(1) as usize; - let mut total: u16 = 1; // status line - if !self.queued_messages.is_empty() { - total = total.saturating_add(1); // blank line between status and queued messages - } - let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix - if text_width > 0 { - for q in &self.queued_messages { - let wrapped = textwrap::wrap(q, text_width); - let lines = wrapped.len().min(3) as u16; - total = total.saturating_add(lines); - if wrapped.len() > 3 { - total = total.saturating_add(1); // ellipsis line - } - } - if !self.queued_messages.is_empty() { - total = total.saturating_add(1); // keybind hint line - } - } else { - // At least one line per message if width is extremely narrow - total = total.saturating_add(self.queued_messages.len() as u16); - } - total.saturating_add(1) // spacer line - } - - pub(crate) fn interrupt(&self) { - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); - } - - /// Update the animated header label (left of the brackets). - pub(crate) fn update_header(&mut self, header: String) { - if self.header != header { - self.header = header; - } - } - - /// Replace the queued messages displayed beneath the header. - pub(crate) fn set_queued_messages(&mut self, queued: Vec) { - self.queued_messages = queued; - // Ensure a redraw so changes are visible. - self.frame_requester.schedule_frame(); - } - - pub(crate) fn pause_timer(&mut self) { - self.pause_timer_at(Instant::now()); - } - - pub(crate) fn resume_timer(&mut self) { - self.resume_timer_at(Instant::now()); - } - - pub(crate) fn pause_timer_at(&mut self, now: Instant) { - if self.is_paused { - return; - } - self.elapsed_running += now.saturating_duration_since(self.last_resume_at); - self.is_paused = true; - } - - pub(crate) fn resume_timer_at(&mut self, now: Instant) { - if !self.is_paused { - return; - } - self.last_resume_at = now; - self.is_paused = false; - self.frame_requester.schedule_frame(); - } - - fn elapsed_seconds_at(&self, now: Instant) -> u64 { - let mut elapsed = self.elapsed_running; - if !self.is_paused { - elapsed += now.saturating_duration_since(self.last_resume_at); - } - elapsed.as_secs() - } - - pub fn elapsed_seconds(&self) -> u64 { - self.elapsed_seconds_at(Instant::now()) - } -} - -impl WidgetRef for StatusIndicatorWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if area.is_empty() { - return; - } - - // Schedule next animation frame. - self.frame_requester - .schedule_frame_in(Duration::from_millis(32)); - let elapsed = self.elapsed_seconds(); - let pretty_elapsed = fmt_elapsed_compact(elapsed); - - // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. - let mut spans = vec!["• ".dim()]; - spans.extend(shimmer_spans(&self.header)); - spans.extend(vec![ - " ".into(), - format!("({pretty_elapsed} • ").dim(), - key_hint::plain(KeyCode::Esc).into(), - " to interrupt)".dim(), - ]); - - // Build lines: status, then queued messages, then spacer. - let mut lines: Vec> = Vec::new(); - lines.push(Line::from(spans)); - if !self.queued_messages.is_empty() { - lines.push(Line::from("")); - } - // Wrap queued messages using textwrap and show up to the first 3 lines per message. - let text_width = area.width.saturating_sub(3); // " ↳ " prefix - for q in &self.queued_messages { - let wrapped = textwrap::wrap(q, text_width as usize); - for (i, piece) in wrapped.iter().take(3).enumerate() { - let prefix = if i == 0 { " ↳ " } else { " " }; - let content = format!("{prefix}{piece}"); - lines.push(Line::from(content.dim().italic())); - } - if wrapped.len() > 3 { - lines.push(Line::from(" …".dim().italic())); - } - } - if !self.queued_messages.is_empty() { - lines.push( - Line::from(vec![ - " ".into(), - key_hint::alt(KeyCode::Up).into(), - " edit".into(), - ]) - .dim(), - ); - } - - let paragraph = Paragraph::new(lines); - paragraph.render_ref(area, buf); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use crate::app_event_sender::AppEventSender; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - use std::time::Duration; - use std::time::Instant; - use tokio::sync::mpsc::unbounded_channel; - - use pretty_assertions::assert_eq; - - #[test] - fn fmt_elapsed_compact_formats_seconds_minutes_hours() { - assert_eq!(fmt_elapsed_compact(0), "0s"); - assert_eq!(fmt_elapsed_compact(1), "1s"); - assert_eq!(fmt_elapsed_compact(59), "59s"); - assert_eq!(fmt_elapsed_compact(60), "1m 00s"); - assert_eq!(fmt_elapsed_compact(61), "1m 01s"); - assert_eq!(fmt_elapsed_compact(3 * 60 + 5), "3m 05s"); - assert_eq!(fmt_elapsed_compact(59 * 60 + 59), "59m 59s"); - assert_eq!(fmt_elapsed_compact(3600), "1h 00m 00s"); - assert_eq!(fmt_elapsed_compact(3600 + 60 + 1), "1h 01m 01s"); - assert_eq!(fmt_elapsed_compact(25 * 3600 + 2 * 60 + 3), "25h 02m 03s"); - } - - #[test] - fn renders_with_working_header() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - - // Render into a fixed-size test terminal and snapshot the backend. - let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); - terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn renders_truncated() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - - // Render into a fixed-size test terminal and snapshot the backend. - let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); - terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn renders_with_queued_messages() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - w.set_queued_messages(vec!["first".to_string(), "second".to_string()]); - - // Render into a fixed-size test terminal and snapshot the backend. - let mut terminal = Terminal::new(TestBackend::new(80, 8)).expect("terminal"); - terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn timer_pauses_when_requested() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut widget = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - - let baseline = Instant::now(); - widget.last_resume_at = baseline; - - let before_pause = widget.elapsed_seconds_at(baseline + Duration::from_secs(5)); - assert_eq!(before_pause, 5); - - widget.pause_timer_at(baseline + Duration::from_secs(5)); - let paused_elapsed = widget.elapsed_seconds_at(baseline + Duration::from_secs(10)); - assert_eq!(paused_elapsed, before_pause); - - widget.resume_timer_at(baseline + Duration::from_secs(10)); - let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13)); - assert_eq!(after_resume, before_pause + 3); - } -} diff --git a/codex-rs/tui/src/statusline/mod.rs b/codex-rs/tui/src/statusline/mod.rs new file mode 100644 index 000000000000..22875bb14759 --- /dev/null +++ b/codex-rs/tui/src/statusline/mod.rs @@ -0,0 +1,1266 @@ +use std::borrow::Cow; +use std::time::Duration; +use std::time::Instant; + +use crate::exec_cell::spinner; +use crate::key_hint; +use crate::status::line_display_width; +use crate::status::truncate_line_to_width; +use crossterm::event::KeyCode; +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +mod palette; +pub(crate) mod state; + +pub(crate) use state::StatusLineState; + +use palette::BASE; +use palette::GREEN; +use palette::GREEN_LIGHT; +use palette::LAVENDER; +use palette::MAUVE; +use palette::PEACH; +use palette::PEACH_LIGHT; +use palette::RED; +use palette::RED_LIGHT; +use palette::ROSEWATER; +use palette::SKY; +use palette::SUBTEXT0; +use palette::TEAL; +use palette::YELLOW; +use palette::YELLOW_LIGHT; +use palette::queue_preview_style; + +const LEFT_CURVE: &str = ""; +const RIGHT_CURVE: &str = ""; +const LEFT_CHEVRON: &str = ""; +const RIGHT_CHEVRON: &str = ""; +const GIT_ICON: &str = " "; +const AWS_ICON: &str = " "; +const K8S_ICON: &str = "☸ "; +const HOSTNAME_ICON: &str = " "; +const CONTEXT_ICON: &str = " "; +const PROGRESS_LEFT_EMPTY: &str = ""; +const PROGRESS_MID_EMPTY: &str = ""; +const PROGRESS_RIGHT_EMPTY: &str = ""; +const PROGRESS_LEFT_FULL: &str = ""; +const PROGRESS_MID_FULL: &str = ""; +const PROGRESS_RIGHT_FULL: &str = ""; +const MODEL_ICONS: &[char] = &['󰚩', '󱚝', '󱚟', '󱚡', '󱚣', '󱚥']; +const DEVSPACE_ICONS: &[&str] = &["󰠖 ", "󰠶 ", "󰋩 ", "󰚌 "]; +const CONTEXT_PADDING: usize = 4; + +fn span(text: S, style: Style) -> Span<'static> +where + S: Into>, +{ + Span::styled(text.into(), style) +} + +fn accent_fg(color: Color) -> Style { + Style::default().fg(color) +} + +fn segment_fill(color: Color) -> Style { + Style::default().fg(BASE).bg(color) +} + +fn bridge_left(prev: Color, next: Color) -> Style { + Style::default().fg(prev).bg(next) +} + +fn bridge_right(prev: Color, next: Color) -> Style { + Style::default().fg(next).bg(prev) +} + +fn dim_text() -> Style { + Style::default().fg(SUBTEXT0).add_modifier(Modifier::DIM) +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineSnapshot { + pub cwd_display: Option, + pub cwd_basename: Option, + pub cwd_fallback: Option, + pub model: Option, + pub tokens: Option, + pub context: Option, + pub run_state: Option, + pub git: Option, + pub environment: StatusLineEnvironmentSnapshot, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineEnvironmentSnapshot { + pub devspace: Option, + pub hostname: Option, + pub aws_profile: Option, + pub kubernetes_context: Option, + pub session_id: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineModelSnapshot { + pub label: String, + pub detail: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineTokenSnapshot { + pub total: TokenCountSnapshot, + pub last: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Default)] +pub(crate) struct TokenCountSnapshot { + pub total_tokens: u64, + pub input_tokens: u64, + pub cached_input_tokens: u64, + pub output_tokens: u64, + pub reasoning_output_tokens: u64, +} + +impl TokenCountSnapshot { + fn blended_total(&self) -> u64 { + self.input_without_cache() + self.output_tokens + } + + fn input_without_cache(&self) -> u64 { + self.input_tokens.saturating_sub(self.cached_input_tokens) + } +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineContextSnapshot { + pub percent_remaining: u8, + pub tokens_in_context: u64, + pub window: u64, +} + +impl StatusLineContextSnapshot { + fn percent_used(&self) -> u8 { + 100u8.saturating_sub(self.percent_remaining) + } +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineGitSnapshot { + pub branch: Option, + pub dirty: bool, + pub ahead: Option, + pub behind: Option, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineDevspaceSnapshot { + pub name: String, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct StatusLineRunState { + pub label: String, + pub spinner_started_at: Option, + pub timer: Option, + pub queued_messages: Vec, + pub show_interrupt_hint: bool, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct RunTimerSnapshot { + pub elapsed_running: Duration, + pub last_resume_at: Option, + pub is_paused: bool, +} + +impl RunTimerSnapshot { + fn elapsed_at(&self, now: Instant) -> Duration { + if self.is_paused { + return self.elapsed_running; + } + let Some(last_resume) = self.last_resume_at else { + return self.elapsed_running; + }; + self.elapsed_running + .saturating_add(now.saturating_duration_since(last_resume)) + } +} + +pub(crate) fn format_elapsed_compact(elapsed_secs: u64) -> String { + if elapsed_secs < 60 { + return format!("{elapsed_secs}s"); + } + if elapsed_secs < 3600 { + let minutes = elapsed_secs / 60; + let seconds = elapsed_secs % 60; + return format!("{minutes}m {seconds:02}s"); + } + let hours = elapsed_secs / 3600; + let minutes = (elapsed_secs % 3600) / 60; + let seconds = elapsed_secs % 60; + format!("{hours}h {minutes:02}m {seconds:02}s") +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum PathVariant { + Full, + Basename, + Hidden, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum TokenVariant { + Full, + Compact, + Minimal, + Hidden, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum ContextVariant { + Bar, + Compact, + Hidden, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum GitVariant { + BranchWithStatus, + BranchOnly, + Hidden, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum RunLabelVariant { + Full, + Short, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum DegradeOp { + DropSession, + DropDevspace, + DropKubernetes, + DropAwsProfile, + DropHostname, + DropQueuePreview, + HideInterruptHint, + HideRunTimer, + ShortenRunLabel, + HideRunLabel, + SimplifyGit, + SimplifyTokens, + MinimalTokens, + HideTokens, + SimplifyContext, + HideContext, + BasenamePath, + HidePath, + HideGit, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct EnvironmentInclusion { + hostname: bool, + aws_profile: bool, + kubernetes: bool, + devspace: bool, + session_id: bool, +} + +impl EnvironmentInclusion { + fn new(snapshot: &StatusLineEnvironmentSnapshot) -> Self { + Self { + hostname: snapshot.hostname.is_some(), + aws_profile: snapshot.aws_profile.is_some(), + kubernetes: snapshot.kubernetes_context.is_some(), + devspace: snapshot.devspace.is_some(), + session_id: snapshot.session_id.is_some(), + } + } +} + +#[derive(Debug)] +pub(crate) struct StatusLineRenderer; + +impl StatusLineRenderer { + pub fn render(&self, snapshot: &StatusLineSnapshot, width: u16, now: Instant) -> Line<'static> { + let mut model = RenderModel::new(snapshot, now); + let target_width = width as usize; + + loop { + if let Some(line) = model.try_render_line(target_width) { + return line; + } + if !model.apply_next_degrade() { + let fallback = model.fallback_line(); + return truncate_line_to_width(fallback, target_width); + } + } + } +} + +struct RenderModel<'a> { + snapshot: &'a StatusLineSnapshot, + now: Instant, + path_variant: PathVariant, + token_variant: TokenVariant, + context_variant: ContextVariant, + git_variant: GitVariant, + include_queue_preview: bool, + show_interrupt_hint: bool, + show_run_timer: bool, + show_run_label: bool, + run_label_variant: RunLabelVariant, + env: EnvironmentInclusion, + degrade_cursor: usize, +} + +impl<'a> RenderModel<'a> { + fn new(snapshot: &'a StatusLineSnapshot, now: Instant) -> Self { + let run_state = snapshot.run_state.as_ref(); + let has_timer = run_state.and_then(|state| state.timer.as_ref()).is_some(); + let show_hint = run_state + .map(|state| state.show_interrupt_hint) + .unwrap_or(false); + Self { + snapshot, + now, + path_variant: PathVariant::Full, + token_variant: TokenVariant::Full, + context_variant: ContextVariant::Bar, + git_variant: GitVariant::BranchWithStatus, + include_queue_preview: true, + show_interrupt_hint: show_hint, + show_run_timer: has_timer, + show_run_label: run_state.is_some(), + run_label_variant: RunLabelVariant::Full, + env: EnvironmentInclusion::new(&snapshot.environment), + degrade_cursor: 0, + } + } + + fn fallback_line(&self) -> Line<'static> { + let mut parts: Vec = Vec::new(); + if let Some(path) = self + .snapshot + .cwd_fallback + .as_ref() + .or(self.snapshot.cwd_display.as_ref()) + { + parts.push(path.clone()); + } + if let Some(model) = self.snapshot.model.as_ref() { + parts.push(model.label.clone()); + } + if let Some(git) = self.snapshot.git.as_ref() + && let Some(branch) = git.branch.as_ref() + { + let mut branch_text = branch.clone(); + if git.dirty { + branch_text.push('*'); + } + parts.push(branch_text); + } + if parts.is_empty() { + return Line::from("codex"); + } + Line::from(parts.join(" | ")) + } + + fn apply_next_degrade(&mut self) -> bool { + const DEGRADE_ORDER: &[DegradeOp] = &[ + DegradeOp::DropQueuePreview, + DegradeOp::HideInterruptHint, + DegradeOp::HideRunTimer, + DegradeOp::ShortenRunLabel, + DegradeOp::HideRunLabel, + DegradeOp::BasenamePath, + DegradeOp::SimplifyTokens, + DegradeOp::MinimalTokens, + DegradeOp::HideTokens, + DegradeOp::SimplifyContext, + DegradeOp::HideContext, + DegradeOp::SimplifyGit, + DegradeOp::HideGit, + DegradeOp::DropSession, + DegradeOp::DropDevspace, + DegradeOp::DropKubernetes, + DegradeOp::DropAwsProfile, + DegradeOp::DropHostname, + DegradeOp::HidePath, + ]; + + while self.degrade_cursor < DEGRADE_ORDER.len() { + let op = DEGRADE_ORDER[self.degrade_cursor]; + self.degrade_cursor += 1; + if self.apply_degrade(op) { + return true; + } + } + false + } + + fn apply_degrade(&mut self, op: DegradeOp) -> bool { + match op { + DegradeOp::DropSession if self.env.session_id => { + self.env.session_id = false; + true + } + DegradeOp::DropDevspace if self.env.devspace => { + self.env.devspace = false; + true + } + DegradeOp::DropKubernetes if self.env.kubernetes => { + self.env.kubernetes = false; + true + } + DegradeOp::DropAwsProfile if self.env.aws_profile => { + self.env.aws_profile = false; + true + } + DegradeOp::DropHostname if self.env.hostname => { + self.env.hostname = false; + true + } + DegradeOp::DropQueuePreview if self.include_queue_preview => { + self.include_queue_preview = false; + true + } + DegradeOp::HideInterruptHint if self.show_interrupt_hint => { + self.show_interrupt_hint = false; + true + } + DegradeOp::HideRunTimer if self.show_run_timer => { + self.show_run_timer = false; + true + } + DegradeOp::ShortenRunLabel + if self.show_run_label && self.run_label_variant == RunLabelVariant::Full => + { + self.run_label_variant = RunLabelVariant::Short; + true + } + DegradeOp::HideRunLabel if self.show_run_label => { + self.show_run_label = false; + true + } + DegradeOp::SimplifyGit if self.git_variant == GitVariant::BranchWithStatus => { + self.git_variant = GitVariant::BranchOnly; + true + } + DegradeOp::SimplifyTokens if self.token_variant == TokenVariant::Full => { + self.token_variant = TokenVariant::Compact; + true + } + DegradeOp::MinimalTokens if self.token_variant == TokenVariant::Compact => { + self.token_variant = TokenVariant::Minimal; + true + } + DegradeOp::HideTokens if self.token_variant != TokenVariant::Hidden => { + self.token_variant = TokenVariant::Hidden; + true + } + DegradeOp::SimplifyContext if self.context_variant == ContextVariant::Bar => { + self.context_variant = ContextVariant::Compact; + true + } + DegradeOp::HideContext if self.context_variant != ContextVariant::Hidden => { + self.context_variant = ContextVariant::Hidden; + true + } + DegradeOp::BasenamePath if self.path_variant == PathVariant::Full => { + self.path_variant = PathVariant::Basename; + true + } + DegradeOp::HidePath if self.path_variant != PathVariant::Hidden => { + self.path_variant = PathVariant::Hidden; + true + } + DegradeOp::HideGit if self.git_variant != GitVariant::Hidden => { + self.git_variant = GitVariant::Hidden; + true + } + _ => false, + } + } + + fn try_render_line(&self, target_width: usize) -> Option> { + let left_spans = self.render_left_segments()?; + let right_spans = self.render_right_segments()?; + + let left_line = Line::from(left_spans.clone()); + let right_line = Line::from(right_spans.clone()); + let left_width = line_display_width(&left_line); + let right_width = line_display_width(&right_line); + let has_left = left_width > 0; + let has_right = right_width > 0; + + let mut separators = 0usize; + if has_left && (self.should_render_middle() || has_right) { + separators += 1; + } + if has_right && self.should_render_middle() { + separators += 1; + } + if separators > target_width { + return None; + } + + let available_for_middle = + target_width.checked_sub(left_width + right_width + separators)?; + let (middle_spans, _middle_width) = self.render_middle(available_for_middle)?; + + let mut spans: Vec> = Vec::new(); + spans.extend(left_spans); + spans.extend(middle_spans); + spans.extend(right_spans); + + let mut line = Line::from(spans); + let current_width = line_display_width(&line); + if current_width < target_width { + line.spans + .push(" ".repeat(target_width - current_width).into()); + } + + if line_display_width(&line) == target_width { + Some(line) + } else { + None + } + } + + fn should_render_middle(&self) -> bool { + match self.context_variant { + ContextVariant::Hidden => false, + _ => self.snapshot.context.is_some(), + } + } + + fn render_left_segments(&self) -> Option>> { + let segments = self.collect_left_segments(); + if segments.is_empty() { + return Some(Vec::new()); + } + + let mut spans: Vec> = Vec::new(); + let mut previous: Option = None; + for segment in segments { + let accent = segment.accent; + if let Some(prev) = previous { + spans.push(span(LEFT_CHEVRON, bridge_left(prev, accent))); + } else { + spans.push(span(LEFT_CURVE, accent_fg(accent))); + } + spans.extend(segment.into_padded_spans()); + previous = Some(accent); + } + if let Some(last) = previous { + spans.push(span(LEFT_CHEVRON, accent_fg(last))); + } + Some(spans) + } + + fn collect_left_segments(&self) -> Vec { + let mut segments: Vec = Vec::new(); + if let Some(state) = self.snapshot.run_state.as_ref() { + segments.extend(self.run_state_segments(state)); + } + if let Some(segment) = self.path_segment() { + segments.push(segment); + } + if let Some(segment) = self.model_segment() { + segments.push(segment); + } + segments + } + + fn path_segment(&self) -> Option { + let text = self.path_text()?; + Some(PowerlineSegment::text(LAVENDER, text)) + } + + fn path_text(&self) -> Option { + match self.path_variant { + PathVariant::Hidden => None, + PathVariant::Full => self + .snapshot + .cwd_display + .as_ref() + .map(|path| truncate_graphemes(path, 40)), + PathVariant::Basename => self + .snapshot + .cwd_basename + .clone() + .or_else(|| self.snapshot.cwd_fallback.clone()) + .map(|path| truncate_graphemes(&path, 28)), + } + } + + fn model_segment(&self) -> Option { + let model = self.snapshot.model.as_ref()?; + let mut spans: Vec> = Vec::new(); + let icon = select_model_icon(&model.label).to_string(); + spans.push(icon.into()); + if !model.label.is_empty() { + spans.push(" ".into()); + spans.push(Span::styled( + model.label.clone(), + Style::default().add_modifier(Modifier::BOLD), + )); + } + if let Some(detail) = model.detail.as_ref() { + spans.push(" ".into()); + spans.push(Span::styled( + detail.clone(), + Style::default().fg(BASE).add_modifier(Modifier::ITALIC), + )); + } + if let Some(tokens) = self.format_token_summary() { + spans.push(" ".into()); + spans.push(Span::styled(tokens, dim_text())); + } + Some(PowerlineSegment::from_spans(SKY, spans)) + } + + fn format_token_summary(&self) -> Option { + let tokens = self.snapshot.tokens.as_ref()?; + match self.token_variant { + TokenVariant::Hidden => None, + TokenVariant::Minimal => Some(format!( + "Σ{}", + format_token_count(tokens.total.blended_total()) + )), + TokenVariant::Compact | TokenVariant::Full => { + let mut parts = Vec::new(); + parts.push(format!( + "Σ{}", + format_token_count(tokens.total.blended_total()) + )); + parts.push(format!( + "↑{}", + format_token_count(tokens.total.input_without_cache()) + )); + if tokens.total.cached_input_tokens > 0 { + parts.push(format!( + "↺{}", + format_token_count(tokens.total.cached_input_tokens) + )); + } + parts.push(format!( + "↓{}", + format_token_count(tokens.total.output_tokens) + )); + Some(parts.join(" ")) + } + } + } + + fn run_state_segments(&self, state: &StatusLineRunState) -> Vec { + let mut segments: Vec = Vec::new(); + + let spinner_text = spinner(state.spinner_started_at).content.to_string(); + let mut label_parts: Vec = vec![spinner_text]; + if self.show_run_label { + let label = match self.run_label_variant { + RunLabelVariant::Full => state.label.clone(), + RunLabelVariant::Short => state + .label + .split_whitespace() + .next() + .unwrap_or("") + .to_string(), + }; + if !label.is_empty() { + label_parts.push(label); + } + } + let label_text = label_parts.join(" "); + if !label_text.is_empty() { + segments.push(PowerlineSegment::text(GREEN, label_text)); + } + + if self.show_run_timer + && let Some(timer) = state.timer.as_ref() + { + let elapsed = timer.elapsed_at(self.now).as_secs(); + let text = format!("󰔟 {}", format_elapsed_compact(elapsed)); + segments.push(PowerlineSegment::text(GREEN, text)); + } + + if self.show_interrupt_hint { + let mut spans: Vec> = Vec::new(); + spans.push(key_hint::plain(KeyCode::Esc).into()); + spans.push(" ".into()); + spans.push("interrupt".dim()); + segments.push(PowerlineSegment::from_spans(PEACH, spans)); + } + + if self.include_queue_preview && !state.queued_messages.is_empty() { + let (preview, extra) = queue_preview(&state.queued_messages); + let mut spans: Vec> = Vec::new(); + spans.push("next:".dim()); + spans.push(" ".into()); + spans.push(Span::styled(preview, queue_preview_style())); + if extra > 0 { + spans.push(" ".into()); + spans.push(Span::styled(format!("(+{extra})"), queue_preview_style())); + } + spans.push(" ".into()); + spans.push(key_hint::alt(KeyCode::Up).into()); + spans.push(" edit".dim()); + segments.push(PowerlineSegment::from_spans(MAUVE, spans)); + } + + segments + } + + fn render_right_segments(&self) -> Option>> { + let segments = self.collect_right_segments(); + if segments.is_empty() { + return Some(Vec::new()); + } + let mut spans: Vec> = Vec::new(); + let mut previous_accent: Option = None; + for segment in segments { + let accent = segment.accent; + if let Some(prev) = previous_accent { + spans.push(span(RIGHT_CHEVRON, bridge_right(prev, accent))); + } else { + spans.push(span(RIGHT_CHEVRON, accent_fg(accent))); + } + spans.extend(segment.into_padded_spans()); + previous_accent = Some(accent); + } + if let Some(last) = previous_accent { + spans.push(span(RIGHT_CURVE, accent_fg(last))); + } + Some(spans) + } + + fn collect_right_segments(&self) -> Vec { + let mut segments: Vec = Vec::new(); + if self.env.devspace + && let Some(devspace) = self.snapshot.environment.devspace.as_ref() + { + let icon = devspace_icon(&devspace.name); + let text = format!("{icon}{}", truncate_graphemes(&devspace.name, 16)); + if !text.trim().is_empty() { + segments.push(PowerlineSegment::text(MAUVE, text)); + } + } + if self.env.hostname + && let Some(host) = self.snapshot.environment.hostname.as_ref() + { + let text = format!("{HOSTNAME_ICON}{}", truncate_graphemes(host, 20)); + segments.push(PowerlineSegment::text(ROSEWATER, text)); + } + if let Some(git) = self.build_git_segment() { + segments.push(git); + } + if self.env.aws_profile + && let Some(profile) = self.snapshot.environment.aws_profile.as_ref() + { + let trimmed = profile.trim_start_matches("export AWS_PROFILE="); + let text = format!("{AWS_ICON}{}", truncate_graphemes(trimmed, 16)); + segments.push(PowerlineSegment::text(PEACH, text)); + } + if self.env.kubernetes + && let Some(ctx) = self.snapshot.environment.kubernetes_context.as_ref() + { + let trimmed = ctx + .trim_start_matches("arn:aws:eks:") + .trim_start_matches("gke_"); + let text = format!("{K8S_ICON}{}", truncate_graphemes(trimmed, 18)); + segments.push(PowerlineSegment::text(TEAL, text)); + } + if self.env.session_id + && let Some(id) = self.snapshot.environment.session_id.as_ref() + { + let short = id.graphemes(true).take(8).collect::(); + segments.push(PowerlineSegment::text(YELLOW, format!("session {short}"))); + } + segments + } + + fn build_git_segment(&self) -> Option { + let git = self.snapshot.git.as_ref()?; + let branch = git.branch.as_ref()?; + let mut text = format!("{GIT_ICON}{branch}"); + if git.dirty { + text.push('*'); + } + if let Some(ahead) = git.ahead.filter(|value| *value > 0) { + text.push_str(&format!(" ↑{ahead}")); + } + if let Some(behind) = git.behind.filter(|value| *value > 0) { + text.push_str(&format!(" ↓{behind}")); + } + Some(PowerlineSegment::text(SKY, truncate_graphemes(&text, 24))) + } + + fn render_middle(&self, width: usize) -> Option<(Vec>, usize)> { + if width == 0 { + return Some((Vec::new(), 0)); + } + match self.context_variant { + ContextVariant::Hidden => { + Some((vec![span(" ".repeat(width), Style::default())], width)) + } + ContextVariant::Compact => self + .render_context_compact(width) + .map(|spans| (spans, width)), + ContextVariant::Bar => self.render_context_bar(width).map(|spans| (spans, width)), + } + } + + fn render_context_compact(&self, width: usize) -> Option>> { + let context = self.snapshot.context.as_ref()?; + let percentage = if context.window > 0 { + (context.tokens_in_context as f64 / context.window as f64 * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let text = format!("{CONTEXT_ICON} {percentage:.1}%"); + let display_width = UnicodeWidthStr::width(text.as_str()); + if display_width > width { + return None; + } + let mut spans = vec![span(text, dim_text())]; + if width > display_width { + spans.push(span(" ".repeat(width - display_width), Style::default())); + } + Some(spans) + } + + fn render_context_bar(&self, width: usize) -> Option>> { + let context = self.snapshot.context.as_ref()?; + if width <= CONTEXT_PADDING * 2 + 2 { + return Some(vec![span(" ".repeat(width), Style::default())]); + } + + let available = width.saturating_sub(CONTEXT_PADDING * 2); + let percentage = if context.window > 0 { + (context.tokens_in_context as f64 / context.window as f64 * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + + let label = format!("{CONTEXT_ICON}Context "); + let percent_text = format!(" {percentage:.1}%"); + let label_width = UnicodeWidthStr::width(label.as_str()); + let percent_width = UnicodeWidthStr::width(percent_text.as_str()); + let curves_width = 2usize; + let text_width = label_width + percent_width + curves_width; + if available <= text_width { + return Some(vec![span(" ".repeat(width), Style::default())]); + } + + let fill_width = available - text_width; + if fill_width < 4 { + return Some(vec![span(" ".repeat(width), Style::default())]); + } + + let filled = ((fill_width as f64) * (percentage / 100.0)).round() as usize; + let (accent, light_bg) = context_bar_colors(percentage); + + let mut spans: Vec> = Vec::new(); + spans.push(span(" ".repeat(CONTEXT_PADDING), Style::default())); + spans.push(span(LEFT_CURVE, accent_fg(accent))); + spans.push(span(label, segment_fill(accent))); + spans.extend(build_progress_bar(fill_width, filled, accent, light_bg)); + spans.push(span(percent_text, segment_fill(accent))); + spans.push(span(RIGHT_CURVE, accent_fg(accent))); + spans.push(span(" ".repeat(CONTEXT_PADDING), Style::default())); + Some(spans) + } +} + +struct PowerlineSegment { + accent: Color, + spans: Vec>, +} + +impl PowerlineSegment { + fn text(accent: Color, text: String) -> Self { + Self { + accent, + spans: vec![Span::from(text)], + } + } + + fn from_spans(accent: Color, spans: Vec>) -> Self { + Self { accent, spans } + } + + fn into_padded_spans(self) -> Vec> { + let mut output = Vec::with_capacity(self.spans.len() + 2); + output.push(pad_segment_span(self.accent)); + for mut span in self.spans { + apply_segment_fill(&mut span, self.accent); + output.push(span); + } + output.push(pad_segment_span(self.accent)); + output + } +} + +fn pad_segment_span(accent: Color) -> Span<'static> { + let mut span: Span<'static> = " ".into(); + apply_segment_fill(&mut span, accent); + span +} + +fn apply_segment_fill(span: &mut Span<'static>, accent: Color) { + span.style = span.style.bg(accent); + if span.style.fg.is_none() { + span.style = span.style.fg(BASE); + } +} + +fn truncate_graphemes(text: &str, max_graphemes: usize) -> String { + if max_graphemes == 0 { + return String::new(); + } + let graphemes: Vec<&str> = text.graphemes(true).collect(); + if graphemes.len() <= max_graphemes { + return text.to_string(); + } + if max_graphemes == 1 { + return "…".to_string(); + } + let mut truncated = graphemes[..max_graphemes - 1].concat(); + truncated.push('…'); + truncated +} + +fn queue_preview(commands: &[String]) -> (String, usize) { + if commands.is_empty() { + return (String::new(), 0); + } + let raw = commands + .first() + .map(|value| value.lines().next().unwrap_or("")) + .unwrap_or(""); + let normalized = raw.split_whitespace().collect::>().join(" "); + let mut preview = if normalized.is_empty() { + String::new() + } else { + normalized + }; + + const MAX_WIDTH: usize = 32; + let width = UnicodeWidthStr::width(preview.as_str()); + if width > MAX_WIDTH { + let mut truncated = String::new(); + let mut used = 0usize; + for grapheme in preview.graphemes(true) { + let g_width = UnicodeWidthStr::width(grapheme); + if used + g_width > MAX_WIDTH.saturating_sub(1) { + break; + } + truncated.push_str(grapheme); + used += g_width; + } + truncated.push('…'); + preview = truncated; + } + + (preview, commands.len().saturating_sub(1)) +} + +fn build_progress_bar( + fill_width: usize, + filled_width: usize, + accent: Color, + light_bg: Color, +) -> Vec> { + let mut spans = Vec::with_capacity(fill_width); + for position in 0..fill_width { + let glyph = select_progress_char(position, fill_width, filled_width); + spans.push(span(glyph, Style::default().fg(accent).bg(light_bg))); + } + spans +} + +fn select_progress_char(position: usize, fill_width: usize, filled_width: usize) -> &'static str { + if position == 0 { + if filled_width > 0 { + PROGRESS_LEFT_FULL + } else { + PROGRESS_LEFT_EMPTY + } + } else if position == fill_width.saturating_sub(1) { + if position < filled_width { + PROGRESS_RIGHT_FULL + } else { + PROGRESS_RIGHT_EMPTY + } + } else if position < filled_width { + PROGRESS_MID_FULL + } else { + PROGRESS_MID_EMPTY + } +} + +fn format_token_count(value: u64) -> String { + const MILLION: f64 = 1_000_000.0; + const THOUSAND: f64 = 1_000.0; + if value as f64 >= MILLION { + let mut formatted = format!("{:.1}M", value as f64 / MILLION); + if formatted.ends_with(".0M") { + formatted.truncate(formatted.len() - 3); + formatted.push('M'); + } + formatted + } else if value as f64 >= THOUSAND { + let mut formatted = format!("{:.1}k", value as f64 / THOUSAND); + if formatted.ends_with(".0k") { + formatted.truncate(formatted.len() - 3); + formatted.push('k'); + } + formatted + } else { + value.to_string() + } +} + +fn select_model_icon(model: &str) -> char { + if MODEL_ICONS.is_empty() { + return '󰚩'; + } + if model.is_empty() { + return MODEL_ICONS[0]; + } + let mut hash: u64 = 0; + for byte in model.as_bytes() { + hash = hash.wrapping_mul(131).wrapping_add(*byte as u64); + } + MODEL_ICONS[(hash as usize) % MODEL_ICONS.len()] +} + +fn devspace_icon(name: &str) -> &'static str { + if DEVSPACE_ICONS.is_empty() { + return "󰠖 "; + } + let mut hash: u64 = 0; + for byte in name.as_bytes() { + hash = hash.wrapping_mul(167).wrapping_add(*byte as u64); + } + DEVSPACE_ICONS[(hash as usize) % DEVSPACE_ICONS.len()] +} + +fn context_bar_colors(percent_used: f64) -> (Color, Color) { + match percent_used { + value if value <= 60.0 => (GREEN, GREEN_LIGHT), + value if value <= 80.0 => (YELLOW, YELLOW_LIGHT), + value if value <= 92.0 => (PEACH, PEACH_LIGHT), + _ => (RED, RED_LIGHT), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use ratatui::style::Modifier; + use ratatui::style::Style; + use std::time::Duration; + use std::time::Instant; + use unicode_width::UnicodeWidthStr; + + #[test] + fn elapsed_formatting_matches_indicator() { + assert_eq!(format_elapsed_compact(0), "0s"); + assert_eq!(format_elapsed_compact(59), "59s"); + assert_eq!(format_elapsed_compact(60), "1m 00s"); + assert_eq!(format_elapsed_compact(3_661), "1h 01m 01s"); + } + + #[test] + fn queue_preview_handles_extra_count() { + let long = "x".repeat(80); + let (preview, extra) = queue_preview(&[long, "second".to_string(), "third".to_string()]); + assert!(preview.ends_with('…')); + assert_eq!(extra, 2); + assert!(UnicodeWidthStr::width(preview.as_str()) <= 32); + } + + #[test] + fn context_bar_colors_follow_thresholds() { + let (green, _) = context_bar_colors(10.0); + assert_eq!(green, GREEN); + let (yellow, _) = context_bar_colors(70.0); + assert_eq!(yellow, YELLOW); + let (peach, _) = context_bar_colors(85.0); + assert_eq!(peach, PEACH); + let (red, _) = context_bar_colors(98.0); + assert_eq!(red, RED); + } + + #[test] + fn renderer_renders_core_segments() { + let snapshot = StatusLineSnapshot { + cwd_display: Some("codex".to_string()), + model: Some(StatusLineModelSnapshot { + label: "codex-model".to_string(), + detail: Some("reasoning medium".to_string()), + }), + tokens: Some(StatusLineTokenSnapshot { + total: TokenCountSnapshot { + input_tokens: 600, + cached_input_tokens: 0, + output_tokens: 424, + ..TokenCountSnapshot::default() + }, + last: None, + }), + context: Some(StatusLineContextSnapshot { + percent_remaining: 80, + ..StatusLineContextSnapshot::default() + }), + git: Some(StatusLineGitSnapshot { + branch: Some("main".to_string()), + dirty: true, + ahead: Some(1), + behind: None, + }), + environment: StatusLineEnvironmentSnapshot { + hostname: Some("vermissian".to_string()), + aws_profile: Some("prod".to_string()), + ..StatusLineEnvironmentSnapshot::default() + }, + ..StatusLineSnapshot::default() + }; + let renderer = StatusLineRenderer; + let line = renderer.render(&snapshot, 80, Instant::now()); + let rendered: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + assert!(rendered.contains("codex-model")); + assert!(rendered.contains("Σ")); + assert!(rendered.contains("main*")); + assert!(rendered.contains(" codex") || rendered.contains(" tui")); + assert!(rendered.contains("vermissian")); + } + + #[test] + fn renderer_snapshot_wide_width() { + let snapshot = sample_snapshot(); + let now = Instant::now(); + let renderer = StatusLineRenderer; + let line = renderer.render(&snapshot, 80, now); + assert_snapshot!("statusline_wide_80", snapshot_line_repr(&line)); + } + + #[test] + fn renderer_snapshot_narrow_width_degrades() { + let snapshot = sample_snapshot(); + let now = Instant::now(); + let renderer = StatusLineRenderer; + let line = renderer.render(&snapshot, 40, now); + assert_snapshot!("statusline_narrow_40", snapshot_line_repr(&line)); + } + + fn sample_snapshot() -> StatusLineSnapshot { + StatusLineSnapshot { + cwd_display: Some("~/workspace/codex".to_string()), + cwd_basename: Some("codex".to_string()), + cwd_fallback: Some("codex".to_string()), + model: Some(StatusLineModelSnapshot { + label: "gpt-5-codex".to_string(), + detail: Some("reasoning medium".to_string()), + }), + tokens: Some(StatusLineTokenSnapshot { + total: TokenCountSnapshot { + total_tokens: 48_234, + input_tokens: 30_000, + cached_input_tokens: 8_000, + output_tokens: 18_234, + reasoning_output_tokens: 234, + }, + last: Some(TokenCountSnapshot { + total_tokens: 2_345, + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 45, + }), + }), + context: Some(StatusLineContextSnapshot { + percent_remaining: 68, + tokens_in_context: 52_000, + window: 160_000, + }), + run_state: Some(StatusLineRunState { + label: "Applying patch".to_string(), + spinner_started_at: None, + timer: Some(RunTimerSnapshot { + elapsed_running: Duration::from_secs(125), + last_resume_at: None, + is_paused: true, + }), + queued_messages: vec!["git status".to_string(), "cargo test --all".to_string()], + show_interrupt_hint: true, + }), + git: Some(StatusLineGitSnapshot { + branch: Some("feature/fix-tests".to_string()), + dirty: true, + ahead: Some(1), + behind: Some(0), + }), + environment: StatusLineEnvironmentSnapshot { + devspace: Some(StatusLineDevspaceSnapshot { + name: "earth".to_string(), + }), + hostname: Some("vermissian".to_string()), + aws_profile: Some("prod".to_string()), + kubernetes_context: Some("codex-dev".to_string()), + session_id: Some("session-abcdef12".to_string()), + }, + } + } + + fn snapshot_line_repr(line: &Line<'_>) -> String { + line.spans + .iter() + .enumerate() + .map(|(idx, span)| { + format!( + "{idx:02}: {} {:?}", + describe_style(span.style), + span.content.as_ref() + ) + }) + .collect::>() + .join("\n") + } + + fn describe_style(style: Style) -> String { + let mut parts: Vec = Vec::new(); + if let Some(fg) = style.fg { + parts.push(format!("fg={fg:?}")); + } + if let Some(bg) = style.bg { + parts.push(format!("bg={bg:?}")); + } + if style.add_modifier != Modifier::empty() { + parts.push(format!("mod={:?}", style.add_modifier)); + } + if parts.is_empty() { + "plain".to_string() + } else { + parts.join("|") + } + } +} diff --git a/codex-rs/tui/src/statusline/palette.rs b/codex-rs/tui/src/statusline/palette.rs new file mode 100644 index 000000000000..2841210c9e38 --- /dev/null +++ b/codex-rs/tui/src/statusline/palette.rs @@ -0,0 +1,42 @@ +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; + +#[allow(clippy::disallowed_methods)] +pub(crate) const BASE: Color = Color::Rgb(30, 30, 46); +#[allow(clippy::disallowed_methods)] +pub(crate) const LAVENDER: Color = Color::Rgb(180, 190, 254); +#[allow(clippy::disallowed_methods)] +pub(crate) const SKY: Color = Color::Rgb(137, 220, 235); +#[allow(clippy::disallowed_methods)] +pub(crate) const MAUVE: Color = Color::Rgb(203, 166, 247); +#[allow(clippy::disallowed_methods)] +pub(crate) const PEACH: Color = Color::Rgb(250, 179, 135); +#[allow(clippy::disallowed_methods)] +pub(crate) const GREEN: Color = Color::Rgb(166, 227, 161); +#[allow(clippy::disallowed_methods)] +pub(crate) const YELLOW: Color = Color::Rgb(249, 226, 175); +#[allow(clippy::disallowed_methods)] +pub(crate) const RED: Color = Color::Rgb(243, 139, 168); +#[allow(clippy::disallowed_methods)] +pub(crate) const ROSEWATER: Color = Color::Rgb(245, 224, 220); +#[allow(clippy::disallowed_methods)] +pub(crate) const TEAL: Color = Color::Rgb(148, 226, 213); +#[allow(clippy::disallowed_methods)] +pub(crate) const SURFACE0: Color = Color::Rgb(49, 50, 68); +#[allow(clippy::disallowed_methods)] +pub(crate) const SUBTEXT0: Color = Color::Rgb(166, 173, 200); +#[allow(clippy::disallowed_methods)] +pub(crate) const GREEN_LIGHT: Color = Color::Rgb(86, 127, 81); +#[allow(clippy::disallowed_methods)] +pub(crate) const YELLOW_LIGHT: Color = Color::Rgb(149, 136, 95); +#[allow(clippy::disallowed_methods)] +pub(crate) const PEACH_LIGHT: Color = Color::Rgb(150, 107, 81); +#[allow(clippy::disallowed_methods)] +pub(crate) const RED_LIGHT: Color = Color::Rgb(146, 83, 100); + +pub(crate) fn queue_preview_style() -> Style { + Style::default() + .fg(SUBTEXT0) + .add_modifier(Modifier::ITALIC | Modifier::DIM) +} diff --git a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_narrow_40.snap b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_narrow_40.snap new file mode 100644 index 000000000000..d3028ef98228 --- /dev/null +++ b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_narrow_40.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/statusline/mod.rs +expression: snapshot_line_repr(&line) +--- +00: plain "codex | gpt-5-codex | feature/fix-tests*" diff --git a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap new file mode 100644 index 000000000000..81fab2cc3b50 --- /dev/null +++ b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap @@ -0,0 +1,28 @@ +--- +source: tui/src/statusline/mod.rs +expression: snapshot_line_repr(&line) +--- +00: fg=Rgb(166, 227, 161) "\u{e0b6}" +01: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) " " +02: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) "⠋" +03: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) " " +04: fg=Rgb(166, 227, 161)|bg=Rgb(180, 190, 254) "\u{e0b0}" +05: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) " " +06: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) "codex" +07: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) " " +08: fg=Rgb(180, 190, 254)|bg=Rgb(137, 220, 235) "\u{e0b0}" +09: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +10: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) "\u{f16a5}" +11: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +12: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=BOLD "gpt-5-codex" +13: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +14: fg=Rgb(166, 173, 200)|bg=Rgb(137, 220, 235)|mod=DIM | ITALIC "reasoning medium" +15: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +16: fg=Rgb(137, 220, 235) "\u{e0b0}" +17: plain " " +18: fg=Rgb(137, 220, 235) "\u{e0b2}" +19: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +20: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) "\u{e0a0} feature/fix-tests* ↑1" +21: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +22: fg=Rgb(137, 220, 235) "\u{e0b4}" +23: plain " " diff --git a/codex-rs/tui/src/statusline/state.rs b/codex-rs/tui/src/statusline/state.rs new file mode 100644 index 000000000000..61e74a343abf --- /dev/null +++ b/codex-rs/tui/src/statusline/state.rs @@ -0,0 +1,306 @@ +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use codex_core::config::Config; +use codex_core::protocol::TokenUsageInfo; +use codex_core::protocol_config_types::ReasoningEffort; +use ratatui::text::Line; + +use crate::status::format_directory_display; +use crate::tui::FrameRequester; + +use super::RunTimerSnapshot; +use super::StatusLineContextSnapshot; +use super::StatusLineDevspaceSnapshot; +use super::StatusLineGitSnapshot; +use super::StatusLineModelSnapshot; +use super::StatusLineRenderer; +use super::StatusLineRunState; +use super::StatusLineSnapshot; +use super::StatusLineTokenSnapshot; +use super::TokenCountSnapshot; + +#[derive(Debug)] +pub(crate) struct StatusLineState { + cwd: PathBuf, + frame_requester: FrameRequester, + renderer: StatusLineRenderer, + snapshot: StatusLineSnapshot, + run_timer: Option, + queued_messages: Vec, + esc_hint: bool, + context_window_hint: Option, +} + +impl StatusLineState { + pub(crate) fn new(config: &Config, frame_requester: FrameRequester) -> Self { + let cwd = config.cwd.clone(); + let mut state = Self { + cwd: cwd.clone(), + frame_requester, + renderer: StatusLineRenderer, + snapshot: StatusLineSnapshot::default(), + run_timer: None, + queued_messages: Vec::new(), + esc_hint: true, + context_window_hint: config.model_context_window, + }; + state.set_working_directory(&cwd); + state + } + + pub(crate) fn set_working_directory(&mut self, cwd: &Path) { + self.cwd = cwd.to_path_buf(); + let display = format_directory_display(cwd, None); + let basename = cwd + .file_name() + .map(|os| os.to_string_lossy().to_string()) + .filter(|s| !s.is_empty()); + self.snapshot.cwd_display = Some(display.clone()); + self.snapshot.cwd_basename = basename.clone(); + self.snapshot.cwd_fallback = basename.or(Some(display)); + self.request_redraw(); + } + + pub(crate) fn update_model( + &mut self, + label: impl Into, + effort: Option, + ) { + let detail = reasoning_detail(effort); + self.snapshot.model = Some(StatusLineModelSnapshot { + label: label.into(), + detail, + }); + self.request_redraw(); + } + + pub(crate) fn update_tokens(&mut self, info: Option) { + if let Some(info) = info { + let context_window = info.model_context_window.or(self.context_window_hint); + let (token_snapshot, context_snapshot) = + token_snapshot_from_info(&info, context_window); + self.snapshot.tokens = Some(token_snapshot); + self.snapshot.context = context_snapshot; + } else { + self.snapshot.tokens = None; + self.snapshot.context = None; + } + self.request_redraw(); + } + + pub(crate) fn set_git_info(&mut self, git: Option) { + self.snapshot.git = git; + self.request_redraw(); + } + + pub(crate) fn set_devspace(&mut self, devspace: Option) { + self.snapshot.environment.devspace = + devspace.map(|name| StatusLineDevspaceSnapshot { name }); + self.request_redraw(); + } + + pub(crate) fn set_hostname(&mut self, hostname: Option) { + self.snapshot.environment.hostname = hostname; + self.request_redraw(); + } + + pub(crate) fn set_aws_profile(&mut self, profile: Option) { + self.snapshot.environment.aws_profile = profile; + self.request_redraw(); + } + + pub(crate) fn set_kubernetes_context(&mut self, context: Option) { + self.snapshot.environment.kubernetes_context = context; + self.request_redraw(); + } + + pub(crate) fn set_session_id(&mut self, session_id: Option) { + self.snapshot.environment.session_id = session_id; + self.request_redraw(); + } + + pub(crate) fn set_queued_messages(&mut self, messages: Vec) { + self.queued_messages = messages; + if let Some(run_state) = self.snapshot.run_state.as_mut() { + run_state.queued_messages = self.queued_messages.clone(); + } + self.request_redraw(); + } + + pub(crate) fn update_run_header(&mut self, header: &str) { + if let Some(run_state) = self.snapshot.run_state.as_mut() { + if run_state.label != header { + run_state.label = header.to_string(); + self.request_redraw(); + } + } else { + self.snapshot.run_state = Some(StatusLineRunState { + label: header.to_string(), + show_interrupt_hint: self.esc_hint, + queued_messages: self.queued_messages.clone(), + ..StatusLineRunState::default() + }); + self.request_redraw(); + } + } + + pub(crate) fn start_task(&mut self, header: impl Into) { + let header = header.into(); + let now = Instant::now(); + match self.run_timer.as_mut() { + Some(timer) => timer.resume(now), + None => self.run_timer = Some(RunTimer::new(now)), + } + let mut run_state = self.snapshot.run_state.clone().unwrap_or_default(); + run_state.label = header; + run_state.show_interrupt_hint = self.esc_hint; + run_state.queued_messages = self.queued_messages.clone(); + self.snapshot.run_state = Some(run_state); + self.request_redraw(); + } + + pub(crate) fn complete_task(&mut self) { + if let Some(timer) = self.run_timer.as_mut() { + timer.pause(Instant::now()); + } + self.run_timer = None; + self.snapshot.run_state = None; + self.request_redraw(); + } + + pub(crate) fn resume_timer(&mut self) { + if let Some(timer) = self.run_timer.as_mut() { + timer.resume(Instant::now()); + self.request_redraw(); + } + } + + pub(crate) fn elapsed_seconds(&self) -> Option { + let timer = self.run_timer.as_ref()?; + Some(timer.snapshot(Instant::now()).elapsed_running.as_secs()) + } + + pub(crate) fn snapshot_for_render(&self, now: Instant) -> StatusLineSnapshot { + let mut snapshot = self.snapshot.clone(); + if let (Some(run_state), Some(timer)) = + (snapshot.run_state.as_mut(), self.run_timer.as_ref()) + { + run_state.timer = Some(timer.snapshot(now)); + run_state.spinner_started_at = Some(timer.spinner_started_at); + run_state.queued_messages = self.queued_messages.clone(); + run_state.show_interrupt_hint = self.esc_hint; + } + if let Some(timer) = self.run_timer.as_ref() + && !timer.is_paused + { + self.frame_requester + .schedule_frame_in(Duration::from_millis(48)); + } + snapshot + } + + pub(crate) fn render_line(&self, width: u16) -> Line<'static> { + let now = Instant::now(); + let snapshot = self.snapshot_for_render(now); + self.renderer.render(&snapshot, width, now) + } + + fn request_redraw(&self) { + self.frame_requester.schedule_frame(); + } +} + +#[derive(Debug)] +struct RunTimer { + elapsed_running: Duration, + last_resume_at: Option, + is_paused: bool, + spinner_started_at: Instant, +} + +impl RunTimer { + fn new(now: Instant) -> Self { + Self { + elapsed_running: Duration::ZERO, + last_resume_at: Some(now), + is_paused: false, + spinner_started_at: now, + } + } + + fn resume(&mut self, now: Instant) { + if self.is_paused { + self.last_resume_at = Some(now); + self.is_paused = false; + } + } + + fn pause(&mut self, now: Instant) { + if self.is_paused { + return; + } + if let Some(last) = self.last_resume_at { + self.elapsed_running += now.saturating_duration_since(last); + } + self.is_paused = true; + } + + fn snapshot(&self, now: Instant) -> RunTimerSnapshot { + let mut elapsed = self.elapsed_running; + if !self.is_paused + && let Some(last) = self.last_resume_at + { + elapsed += now.saturating_duration_since(last); + } + RunTimerSnapshot { + elapsed_running: elapsed, + last_resume_at: self.last_resume_at, + is_paused: self.is_paused, + } + } +} + +fn reasoning_detail(effort: Option) -> Option { + effort.map(|eff| match eff { + ReasoningEffort::Minimal => "reasoning minimal".to_string(), + ReasoningEffort::Low => "reasoning low".to_string(), + ReasoningEffort::Medium => "reasoning medium".to_string(), + ReasoningEffort::High => "reasoning high".to_string(), + }) +} + +fn token_snapshot_from_info( + info: &TokenUsageInfo, + context_window: Option, +) -> (StatusLineTokenSnapshot, Option) { + let total = info.total_token_usage.clone(); + let last = info.last_token_usage.clone(); + + let token_snapshot = StatusLineTokenSnapshot { + total: TokenCountSnapshot { + total_tokens: total.total_tokens, + input_tokens: total.input_tokens, + cached_input_tokens: total.cached_input_tokens, + output_tokens: total.output_tokens, + reasoning_output_tokens: total.reasoning_output_tokens, + }, + last: Some(TokenCountSnapshot { + total_tokens: last.total_tokens, + input_tokens: last.input_tokens, + cached_input_tokens: last.cached_input_tokens, + output_tokens: last.output_tokens, + reasoning_output_tokens: last.reasoning_output_tokens, + }), + }; + + let context_snapshot = context_window.map(|window| StatusLineContextSnapshot { + percent_remaining: total.percent_of_context_window_remaining(window), + tokens_in_context: total.tokens_in_context_window(), + window, + }); + + (token_snapshot, context_snapshot) +} diff --git a/codex-rs/tui/tests/suite/mod.rs b/codex-rs/tui/tests/suite/mod.rs index 944e33876992..964748b63f5c 100644 --- a/codex-rs/tui/tests/suite/mod.rs +++ b/codex-rs/tui/tests/suite/mod.rs @@ -1,4 +1,3 @@ // Aggregates all former standalone integration tests as modules. -mod status_indicator; mod vt100_history; mod vt100_live_commit; diff --git a/codex-rs/tui/tests/suite/status_indicator.rs b/codex-rs/tui/tests/suite/status_indicator.rs deleted file mode 100644 index 62f190d269f1..000000000000 --- a/codex-rs/tui/tests/suite/status_indicator.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Regression test: ensure that `StatusIndicatorWidget` sanitises ANSI escape -//! sequences so that no raw `\x1b` bytes are written into the backing -//! buffer. Rendering logic is tricky to unit‑test end‑to‑end, therefore we -//! verify the *public* contract of `ansi_escape_line()` which the widget now -//! relies on. - -use codex_ansi_escape::ansi_escape_line; - -#[test] -fn ansi_escape_line_strips_escape_sequences() { - let text_in_ansi_red = "\x1b[31mRED\x1b[0m"; - - // The returned line must contain three printable glyphs and **no** raw - // escape bytes. - let line = ansi_escape_line(text_in_ansi_red); - - let combined: String = line - .spans - .iter() - .map(|span| span.content.to_string()) - .collect(); - - assert_eq!(combined, "RED"); -} diff --git a/flake.lock b/flake.lock index 6e4f3acce7e3..36f1d39a7222 100644 --- a/flake.lock +++ b/flake.lock @@ -34,6 +34,22 @@ "type": "github" } }, + "nixpkgs_2": { + "locked": { + "lastModified": 1759632233, + "narHash": "sha256-krgZxGAIIIKFJS+UB0l8do3sYUDWJc75M72tepmVMzE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d7f52a7a640bc54c7bb414cca603835bf8dd4b10", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", @@ -43,9 +59,7 @@ }, "rust-overlay": { "inputs": { - "nixpkgs": [ - "nixpkgs" - ] + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1746844454, diff --git a/flake.nix b/flake.nix index 7247333c5a31..a1bebfe67406 100644 --- a/flake.nix +++ b/flake.nix @@ -1,57 +1,85 @@ { - description = "Development Nix flake for OpenAI Codex CLI"; - inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; - }; + rust-overlay.url = "github:oxalica/rust-overlay"; }; - outputs = { nixpkgs, flake-utils, rust-overlay, ... }: + outputs = { self, nixpkgs, flake-utils, rust-overlay }: flake-utils.lib.eachDefaultSystem (system: let + overlays = [ (import rust-overlay) ]; pkgs = import nixpkgs { - inherit system; - }; - pkgsWithRust = import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; + inherit system overlays; }; - monorepo-deps = with pkgs; [ - # for precommit hook - pnpm - husky - ]; - codex-cli = import ./codex-cli { - inherit pkgs monorepo-deps; + rustToolchain = pkgs.rust-bin.nightly.latest.default; + rustPlatform = pkgs.makeRustPlatform { + inherit (pkgs) stdenv; + cargo = rustToolchain; + rustc = rustToolchain; }; - codex-rs = import ./codex-rs { - pkgs = pkgsWithRust; - inherit monorepo-deps; + rmcpSrc = pkgs.fetchgit { + url = "https://github.com/modelcontextprotocol/rust-sdk"; + rev = "c0b777c7f784ba2d456b03c2ec3b98c9b28b5e10"; + hash = "sha256-uAEBai6Uzmpi5fcIn9v4MPE9DbzPvemkaaZ+alwM4PQ="; }; - in - rec { - packages = { - codex-cli = codex-cli.package; - codex-rs = codex-rs.package; + ratatuiSrc = pkgs.fetchgit { + url = "https://github.com/nornagon/ratatui"; + rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2"; + hash = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; }; + cargoPatchConfig = pkgs.writeText "cargo-config.toml" '' + [patch."https://github.com/modelcontextprotocol/rust-sdk"] + rmcp = { path = "${rmcpSrc}/crates/rmcp" } + rmcp-macros = { path = "${rmcpSrc}/crates/rmcp-macros" } - devShells = { - codex-cli = codex-cli.devShell; - codex-rs = codex-rs.devShell; + [patch.crates-io] + ratatui = { path = "${ratatuiSrc}" } + ''; + codex-tui = rustPlatform.buildRustPackage { + pname = "codex-tui"; + version = "unstable"; + src = ./codex-rs; + cargoLock = { + lockFile = ./codex-rs/Cargo.lock; + outputHashes = { + "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; + "rmcp-0.7.0" = "sha256-uAEBai6Uzmpi5fcIn9v4MPE9DbzPvemkaaZ+alwM4PQ="; + "rmcp-macros-0.7.0" = "sha256-uAEBai6Uzmpi5fcIn9v4MPE9DbzPvemkaaZ+alwM4PQ="; + }; + }; + cargoSha256 = "sha256-NP94EW+XS1PrbFfMnGOCnwoNoT1S7txJ8bDD6xRb5hw="; + cargoBuildFlags = [ "--package" "codex-tui" "--bin" "codex-tui" ]; + nativeBuildInputs = with pkgs; [ pkg-config ]; + buildInputs = with pkgs; + [ openssl libgit2 curl zlib ] + ++ lib.optionals stdenv.isDarwin [ libiconv Security CoreServices ]; + preBuild = '' + export CARGO_HOME="$TMPDIR/cargo-home" + mkdir -p "$CARGO_HOME" + cp ${cargoPatchConfig} "$CARGO_HOME/config.toml" + ''; + doCheck = false; + meta = with pkgs.lib; { + description = "Codex TUI built from codex-rs"; + homepage = "https://github.com/sourcegraph/codex"; + license = licenses.asl20; + mainProgram = "codex-tui"; + platforms = platforms.unix; + }; }; - - apps = { - codex-cli = codex-cli.app; - codex-rs = codex-rs.app; + in { + packages = { + codex-tui = codex-tui; + default = codex-tui; }; - - defaultPackage = packages.codex-cli; - defaultApp = apps.codex-cli; - defaultDevShell = devShells.codex-cli; + apps = + let + codexApp = flake-utils.lib.mkApp { drv = codex-tui; }; + in { + codex-tui = codexApp; + default = codexApp; + }; } ); } From b981812d3a9890573fc0a698d3641d2009666b0b Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Mon, 6 Oct 2025 12:25:05 -0700 Subject: [PATCH 02/38] Update statusline snapshots --- .../codex_tui__chatwidget__tests__chat_small_running_h1.snap | 2 +- .../codex_tui__chatwidget__tests__chat_small_running_h2.snap | 2 +- .../codex_tui__chatwidget__tests__chat_small_running_h3.snap | 2 +- ...sts__chatwidget_exec_and_status_layout_vt100_snapshot.snap | 2 +- .../codex_tui__statusline__tests__statusline_wide_80.snap | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap index f4616e8cf758..74ba0dca6620 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h1.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -" ⠋  tui  󱚥 gpt-5-codex  " +" •  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index aee748e675f7..68e4f910dde2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- "› Ask Codex to do anything " -" ⠋  tui  󱚥 gpt-5-codex  " +" •  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index 74a4c4d51f3c..b88829eb9ead 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -4,4 +4,4 @@ expression: terminal.backend() --- " " "› Ask Codex to do anything " -" ⠋  tui  󱚥 gpt-5-codex  " +" •  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 706d23934ce9..a04565bee24c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -12,4 +12,4 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - ⠋  tui  󱚥 gpt-5-codex   󰋩 earth   vermissian   ck-kubero-admin  + •  tui  󱚥 gpt-5-codex   󰋩 earth   vermissian   ck-kubero-admin  diff --git a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap index 81fab2cc3b50..02d345db3cb3 100644 --- a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap +++ b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap @@ -4,7 +4,7 @@ expression: snapshot_line_repr(&line) --- 00: fg=Rgb(166, 227, 161) "\u{e0b6}" 01: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) " " -02: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) "⠋" +02: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) "◦" 03: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) " " 04: fg=Rgb(166, 227, 161)|bg=Rgb(180, 190, 254) "\u{e0b0}" 05: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) " " @@ -16,7 +16,7 @@ expression: snapshot_line_repr(&line) 11: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " 12: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=BOLD "gpt-5-codex" 13: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " -14: fg=Rgb(166, 173, 200)|bg=Rgb(137, 220, 235)|mod=DIM | ITALIC "reasoning medium" +14: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=ITALIC "reasoning medium" 15: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " 16: fg=Rgb(137, 220, 235) "\u{e0b0}" 17: plain " " From b2eba864c49b545682ec2b43414164644d819409 Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Mon, 6 Oct 2025 16:35:28 -0700 Subject: [PATCH 03/38] Some more improvements --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 9 -- codex-rs/tui/src/bottom_pane/footer.rs | 25 +--- codex-rs/tui/src/bottom_pane/mod.rs | 13 -- ...sts__footer_shortcuts_context_running.snap | 2 +- codex-rs/tui/src/chatwidget.rs | 38 +++-- codex-rs/tui/src/chatwidget/tests.rs | 5 + codex-rs/tui/src/lib.rs | 65 --------- codex-rs/tui/src/statusline/mod.rs | 46 ++---- ...statusline__tests__statusline_wide_80.snap | 20 +-- codex-rs/tui/src/statusline/state.rs | 14 +- codex-rs/tui/src/updates.rs | 132 ------------------ 11 files changed, 60 insertions(+), 309 deletions(-) delete mode 100644 codex-rs/tui/src/updates.rs diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 476066883fe2..afc1037d222c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -109,7 +109,6 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, - context_window_percent: Option, } /// Popup state – at most one can be visible at any time. @@ -152,7 +151,6 @@ impl ChatComposer { custom_prompts: Vec::new(), footer_mode: FooterMode::ShortcutPrompt, footer_hint_override: None, - context_window_percent: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -1337,7 +1335,6 @@ impl ChatComposer { esc_backtrack_hint: self.esc_backtrack_hint, use_shift_enter_hint: self.use_shift_enter_hint, is_task_running: self.is_task_running, - context_window_percent: self.context_window_percent, } } @@ -1468,12 +1465,6 @@ impl ChatComposer { self.is_task_running = running; } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { - if self.context_window_percent != percent { - self.context_window_percent = percent; - } - } - pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { self.esc_backtrack_hint = show; if show { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 983eafa46076..1ce826fa7912 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -17,7 +17,6 @@ pub(crate) struct FooterProps { pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, - pub(crate) context_window_percent: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -76,13 +75,7 @@ fn footer_lines(props: FooterProps) -> Vec> { FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { is_task_running: props.is_task_running, })], - FooterMode::ShortcutPrompt => { - if props.is_task_running { - vec![context_window_line(props.context_window_percent)] - } else { - Vec::new() - } - } + FooterMode::ShortcutPrompt => Vec::new(), FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, @@ -215,15 +208,6 @@ fn build_columns(entries: Vec>) -> Vec> { .collect() } -fn context_window_line(percent: Option) -> Line<'static> { - let mut spans: Vec> = Vec::new(); - if let Some(percent) = percent { - spans.push(format!("{percent}%").dim()); - spans.push(" context left".dim()); - } - Line::from(spans) -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShortcutId { Commands, @@ -397,7 +381,6 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, - context_window_percent: None, }, ); @@ -408,7 +391,6 @@ mod tests { esc_backtrack_hint: true, use_shift_enter_hint: true, is_task_running: false, - context_window_percent: None, }, ); @@ -419,7 +401,6 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, - context_window_percent: None, }, ); @@ -430,7 +411,6 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, - context_window_percent: None, }, ); @@ -441,7 +421,6 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, - context_window_percent: None, }, ); @@ -452,7 +431,6 @@ mod tests { esc_backtrack_hint: true, use_shift_enter_hint: false, is_task_running: false, - context_window_percent: None, }, ); @@ -463,7 +441,6 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, - context_window_percent: Some(72), }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 1621d311c375..cac456811bc3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -60,8 +60,6 @@ pub(crate) struct BottomPane { is_task_running: bool, ctrl_c_quit_hint: bool, esc_backtrack_hint: bool, - - context_window_percent: Option, } pub(crate) struct BottomPaneParams { @@ -92,7 +90,6 @@ impl BottomPane { is_task_running: false, ctrl_c_quit_hint: false, esc_backtrack_hint: false, - context_window_percent: None, } } @@ -281,16 +278,6 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn set_context_window_percent(&mut self, percent: Option) { - if self.context_window_percent == percent { - return; - } - - self.context_window_percent = percent; - self.composer.set_context_window_percent(percent); - self.request_redraw(); - } - /// Show a generic list selection view with the provided items. pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) { let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone()); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap index 77b3796c2002..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" 72% context left " +" " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5900c7204588..95b7514c98f0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2,10 +2,15 @@ use std::collections::HashMap; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; +#[cfg(test)] +use std::sync::Mutex; use std::env; use std::fs; +#[cfg(test)] +use lazy_static::lazy_static; + use codex_core::config::Config; use codex_core::config_types::Notifications; use codex_core::git_info::collect_git_info; @@ -479,18 +484,6 @@ impl ChatWidget { } pub(crate) fn set_token_info(&mut self, info: Option) { - if let Some(ref info) = info { - let context_window = info - .model_context_window - .or(self.config.model_context_window); - let percent = context_window.map(|window| { - info.last_token_usage - .percent_of_context_window_remaining(window) - }); - self.bottom_pane.set_context_window_percent(percent); - } else { - self.bottom_pane.set_context_window_percent(None); - } self.token_info = info.clone(); self.status_line.update_tokens(info); } @@ -2240,7 +2233,6 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; - self.bottom_pane.set_context_window_percent(None); self.status_line.update_tokens(None); } @@ -2364,7 +2356,27 @@ fn trim_kube_context(context: &str) -> String { context.rsplit('/').next().unwrap_or(context).to_string() } +#[cfg(test)] +lazy_static! { + static ref DEVSPACE_OVERRIDE: Mutex>> = Mutex::new(None); +} + +#[cfg(test)] +pub(crate) fn set_devspace_override_for_tests(value: Option) { + *DEVSPACE_OVERRIDE.lock().unwrap() = Some(value); +} + +#[cfg(test)] +pub(crate) fn clear_devspace_override_for_tests() { + *DEVSPACE_OVERRIDE.lock().unwrap() = None; +} + fn detect_devspace() -> Option { + #[cfg(test)] + if let Some(override_value) = DEVSPACE_OVERRIDE.lock().unwrap().clone() { + return override_value; + } + env::var("TMUX_DEVSPACE") .ok() .filter(|s| !s.trim().is_empty()) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 37e9ca1497da..4f877db94fdb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::chatwidget::clear_devspace_override_for_tests; +use crate::chatwidget::set_devspace_override_for_tests; use crate::statusline::StatusLineState; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; @@ -292,7 +294,10 @@ fn make_chatwidget_manual() -> ( needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), }; + // Force a deterministic devspace so status line snapshots stay stable. + set_devspace_override_for_tests(Some("earth".to_string())); widget.bootstrap_status_line(); + clear_devspace_override_for_tests(); (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 77bbd00f5ef3..2af7e137e82b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -76,9 +76,6 @@ mod wrapping; #[cfg(test)] pub mod test_backend; -#[cfg(not(debug_assertions))] -mod updates; - use crate::onboarding::TrustDirectorySelection; use crate::onboarding::WSL_INSTRUCTIONS; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; @@ -299,68 +296,6 @@ async fn run_ratatui_app( let mut tui = Tui::new(terminal); - // Show update banner in terminal history (instead of stderr) so it is visible - // within the TUI scrollback. Building spans keeps styling consistent. - #[cfg(not(debug_assertions))] - if let Some(latest_version) = updates::get_upgrade_version(&config) { - use crate::history_cell::padded_emoji; - use crate::history_cell::with_border_with_inner_width; - use ratatui::style::Stylize as _; - use ratatui::text::Line; - - let current_version = env!("CARGO_PKG_VERSION"); - let exe = std::env::current_exe()?; - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - - let mut content_lines: Vec> = vec![ - Line::from(vec![ - padded_emoji("✨").bold().cyan(), - "Update available!".bold().cyan(), - " ".into(), - format!("{current_version} -> {latest_version}.").bold(), - ]), - Line::from(""), - Line::from("See full release notes:"), - Line::from(""), - Line::from( - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ), - Line::from(""), - ]; - - if managed_by_npm { - let npm_cmd = "npm install -g @openai/codex@latest"; - content_lines.push(Line::from(vec![ - "Run ".into(), - npm_cmd.cyan(), - " to update.".into(), - ])); - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - let brew_cmd = "brew upgrade codex"; - content_lines.push(Line::from(vec![ - "Run ".into(), - brew_cmd.cyan(), - " to update.".into(), - ])); - } else { - content_lines.push(Line::from(vec![ - "See ".into(), - "https://github.com/openai/codex".cyan().underlined(), - " for installation options.".into(), - ])); - } - - let viewport_width = tui.terminal.viewport_area.width as usize; - let inner_width = viewport_width.saturating_sub(4).max(1); - let mut lines = with_border_with_inner_width(content_lines, inner_width); - lines.push("".into()); - tui.insert_history_lines(lines); - } - // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&config); diff --git a/codex-rs/tui/src/statusline/mod.rs b/codex-rs/tui/src/statusline/mod.rs index 22875bb14759..21704ec63c86 100644 --- a/codex-rs/tui/src/statusline/mod.rs +++ b/codex-rs/tui/src/statusline/mod.rs @@ -103,7 +103,6 @@ pub(crate) struct StatusLineEnvironmentSnapshot { pub hostname: Option, pub aws_profile: Option, pub kubernetes_context: Option, - pub session_id: Option, } #[derive(Debug, Clone, Default)] @@ -246,7 +245,6 @@ enum RunLabelVariant { #[derive(Copy, Clone, Debug, Eq, PartialEq)] enum DegradeOp { - DropSession, DropDevspace, DropKubernetes, DropAwsProfile, @@ -273,7 +271,6 @@ struct EnvironmentInclusion { aws_profile: bool, kubernetes: bool, devspace: bool, - session_id: bool, } impl EnvironmentInclusion { @@ -283,7 +280,6 @@ impl EnvironmentInclusion { aws_profile: snapshot.aws_profile.is_some(), kubernetes: snapshot.kubernetes_context.is_some(), devspace: snapshot.devspace.is_some(), - session_id: snapshot.session_id.is_some(), } } } @@ -335,7 +331,7 @@ impl<'a> RenderModel<'a> { snapshot, now, path_variant: PathVariant::Full, - token_variant: TokenVariant::Full, + token_variant: TokenVariant::Hidden, context_variant: ContextVariant::Bar, git_variant: GitVariant::BranchWithStatus, include_queue_preview: true, @@ -391,7 +387,6 @@ impl<'a> RenderModel<'a> { DegradeOp::HideContext, DegradeOp::SimplifyGit, DegradeOp::HideGit, - DegradeOp::DropSession, DegradeOp::DropDevspace, DegradeOp::DropKubernetes, DegradeOp::DropAwsProfile, @@ -411,10 +406,6 @@ impl<'a> RenderModel<'a> { fn apply_degrade(&mut self, op: DegradeOp) -> bool { match op { - DegradeOp::DropSession if self.env.session_id => { - self.env.session_id = false; - true - } DegradeOp::DropDevspace if self.env.devspace => { self.env.devspace = false; true @@ -696,14 +687,6 @@ impl<'a> RenderModel<'a> { segments.push(PowerlineSegment::text(GREEN, text)); } - if self.show_interrupt_hint { - let mut spans: Vec> = Vec::new(); - spans.push(key_hint::plain(KeyCode::Esc).into()); - spans.push(" ".into()); - spans.push("interrupt".dim()); - segments.push(PowerlineSegment::from_spans(PEACH, spans)); - } - if self.include_queue_preview && !state.queued_messages.is_empty() { let (preview, extra) = queue_preview(&state.queued_messages); let mut spans: Vec> = Vec::new(); @@ -782,12 +765,6 @@ impl<'a> RenderModel<'a> { let text = format!("{K8S_ICON}{}", truncate_graphemes(trimmed, 18)); segments.push(PowerlineSegment::text(TEAL, text)); } - if self.env.session_id - && let Some(id) = self.snapshot.environment.session_id.as_ref() - { - let short = id.graphemes(true).take(8).collect::(); - segments.push(PowerlineSegment::text(YELLOW, format!("session {short}"))); - } segments } @@ -848,14 +825,11 @@ impl<'a> RenderModel<'a> { } let available = width.saturating_sub(CONTEXT_PADDING * 2); - let percentage = if context.window > 0 { - (context.tokens_in_context as f64 / context.window as f64 * 100.0).clamp(0.0, 100.0) - } else { - 0.0 - }; + let percent_remaining = f64::from(context.percent_remaining); + let percent_used = (100.0 - percent_remaining).clamp(0.0, 100.0); let label = format!("{CONTEXT_ICON}Context "); - let percent_text = format!(" {percentage:.1}%"); + let percent_text = format!(" {percent_remaining:.1}% left"); let label_width = UnicodeWidthStr::width(label.as_str()); let percent_width = UnicodeWidthStr::width(percent_text.as_str()); let curves_width = 2usize; @@ -869,8 +843,8 @@ impl<'a> RenderModel<'a> { return Some(vec![span(" ".repeat(width), Style::default())]); } - let filled = ((fill_width as f64) * (percentage / 100.0)).round() as usize; - let (accent, light_bg) = context_bar_colors(percentage); + let filled = ((fill_width as f64) * (percent_used / 100.0)).round() as usize; + let (accent, light_bg) = context_bar_colors(percent_used); let mut spans: Vec> = Vec::new(); spans.push(span(" ".repeat(CONTEXT_PADDING), Style::default())); @@ -1112,7 +1086,7 @@ mod tests { cwd_display: Some("codex".to_string()), model: Some(StatusLineModelSnapshot { label: "codex-model".to_string(), - detail: Some("reasoning medium".to_string()), + detail: Some("high".to_string()), }), tokens: Some(StatusLineTokenSnapshot { total: TokenCountSnapshot { @@ -1148,7 +1122,8 @@ mod tests { .map(|span| span.content.as_ref()) .collect(); assert!(rendered.contains("codex-model")); - assert!(rendered.contains("Σ")); + assert!(rendered.contains("high")); + assert!(!rendered.contains('Σ')); assert!(rendered.contains("main*")); assert!(rendered.contains(" codex") || rendered.contains(" tui")); assert!(rendered.contains("vermissian")); @@ -1179,7 +1154,7 @@ mod tests { cwd_fallback: Some("codex".to_string()), model: Some(StatusLineModelSnapshot { label: "gpt-5-codex".to_string(), - detail: Some("reasoning medium".to_string()), + detail: Some("high".to_string()), }), tokens: Some(StatusLineTokenSnapshot { total: TokenCountSnapshot { @@ -1226,7 +1201,6 @@ mod tests { hostname: Some("vermissian".to_string()), aws_profile: Some("prod".to_string()), kubernetes_context: Some("codex-dev".to_string()), - session_id: Some("session-abcdef12".to_string()), }, } } diff --git a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap index 02d345db3cb3..a17e11b715a6 100644 --- a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap +++ b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap @@ -16,13 +16,17 @@ expression: snapshot_line_repr(&line) 11: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " 12: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=BOLD "gpt-5-codex" 13: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " -14: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=ITALIC "reasoning medium" +14: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=ITALIC "high" 15: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " 16: fg=Rgb(137, 220, 235) "\u{e0b0}" -17: plain " " -18: fg=Rgb(137, 220, 235) "\u{e0b2}" -19: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " -20: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) "\u{e0a0} feature/fix-tests* ↑1" -21: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " -22: fg=Rgb(137, 220, 235) "\u{e0b4}" -23: plain " " +17: plain " " +18: fg=Rgb(245, 224, 220) "\u{e0b2}" +19: fg=Rgb(30, 30, 46)|bg=Rgb(245, 224, 220) " " +20: fg=Rgb(30, 30, 46)|bg=Rgb(245, 224, 220) "\u{f233} vermissian" +21: fg=Rgb(30, 30, 46)|bg=Rgb(245, 224, 220) " " +22: fg=Rgb(137, 220, 235)|bg=Rgb(245, 224, 220) "\u{e0b2}" +23: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +24: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) "\u{e0a0} feature/fix-tests* ↑1" +25: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " +26: fg=Rgb(137, 220, 235) "\u{e0b4}" +27: plain " " diff --git a/codex-rs/tui/src/statusline/state.rs b/codex-rs/tui/src/statusline/state.rs index 61e74a343abf..727aee457a0f 100644 --- a/codex-rs/tui/src/statusline/state.rs +++ b/codex-rs/tui/src/statusline/state.rs @@ -118,8 +118,7 @@ impl StatusLineState { } pub(crate) fn set_session_id(&mut self, session_id: Option) { - self.snapshot.environment.session_id = session_id; - self.request_redraw(); + let _ = session_id; } pub(crate) fn set_queued_messages(&mut self, messages: Vec) { @@ -264,12 +263,11 @@ impl RunTimer { } fn reasoning_detail(effort: Option) -> Option { - effort.map(|eff| match eff { - ReasoningEffort::Minimal => "reasoning minimal".to_string(), - ReasoningEffort::Low => "reasoning low".to_string(), - ReasoningEffort::Medium => "reasoning medium".to_string(), - ReasoningEffort::High => "reasoning high".to_string(), - }) + match effort { + Some(ReasoningEffort::High) => Some("high".to_string()), + Some(ReasoningEffort::Low) => Some("low".to_string()), + _ => None, + } } fn token_snapshot_from_info( diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs deleted file mode 100644 index 7344e24c780b..000000000000 --- a/codex-rs/tui/src/updates.rs +++ /dev/null @@ -1,132 +0,0 @@ -#![cfg(any(not(debug_assertions), test))] - -use chrono::DateTime; -use chrono::Duration; -use chrono::Utc; -use serde::Deserialize; -use serde::Serialize; -use std::path::Path; -use std::path::PathBuf; - -use codex_core::config::Config; -use codex_core::default_client::create_client; - -use crate::version::CODEX_CLI_VERSION; - -pub fn get_upgrade_version(config: &Config) -> Option { - let version_file = version_filepath(config); - let info = read_version_info(&version_file).ok(); - - if match &info { - None => true, - Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20), - } { - // Refresh the cached latest version in the background so TUI startup - // isn’t blocked by a network call. The UI reads the previously cached - // value (if any) for this run; the next run shows the banner if needed. - tokio::spawn(async move { - check_for_update(&version_file) - .await - .inspect_err(|e| tracing::error!("Failed to update version: {e}")) - }); - } - - info.and_then(|info| { - if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) { - Some(info.latest_version) - } else { - None - } - }) -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -struct VersionInfo { - latest_version: String, - // ISO-8601 timestamp (RFC3339) - last_checked_at: DateTime, -} - -#[derive(Deserialize, Debug, Clone)] -struct ReleaseInfo { - tag_name: String, -} - -const VERSION_FILENAME: &str = "version.json"; -const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; - -fn version_filepath(config: &Config) -> PathBuf { - config.codex_home.join(VERSION_FILENAME) -} - -fn read_version_info(version_file: &Path) -> anyhow::Result { - let contents = std::fs::read_to_string(version_file)?; - Ok(serde_json::from_str(&contents)?) -} - -async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { - let ReleaseInfo { - tag_name: latest_tag_name, - } = create_client() - .get(LATEST_RELEASE_URL) - .send() - .await? - .error_for_status()? - .json::() - .await?; - - let info = VersionInfo { - latest_version: latest_tag_name - .strip_prefix("rust-v") - .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))? - .into(), - last_checked_at: Utc::now(), - }; - - let json_line = format!("{}\n", serde_json::to_string(&info)?); - if let Some(parent) = version_file.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(version_file, json_line).await?; - Ok(()) -} - -fn is_newer(latest: &str, current: &str) -> Option { - match (parse_version(latest), parse_version(current)) { - (Some(l), Some(c)) => Some(l > c), - _ => None, - } -} - -fn parse_version(v: &str) -> Option<(u64, u64, u64)> { - let mut iter = v.trim().split('.'); - let maj = iter.next()?.parse::().ok()?; - let min = iter.next()?.parse::().ok()?; - let pat = iter.next()?.parse::().ok()?; - Some((maj, min, pat)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn prerelease_version_is_not_considered_newer() { - assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); - assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None); - } - - #[test] - fn plain_semver_comparisons_work() { - assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true)); - assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false)); - assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true)); - assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false)); - } - - #[test] - fn whitespace_is_ignored() { - assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); - assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); - } -} From 181282ca9b513c0650f27364085c200511d58ef5 Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Tue, 7 Oct 2025 09:15:21 -0700 Subject: [PATCH 04/38] More width improvements --- ...chatwidget__tests__chat_small_idle_h1.snap | 3 +- ...widget__tests__chat_small_idle_h2.snap.new | 7 ++++ ...exec_and_status_layout_vt100_snapshot.snap | 3 +- codex-rs/tui/src/custom_terminal.rs | 5 +-- codex-rs/tui/src/statusline/mod.rs | 33 ++----------------- ...statusline__tests__statusline_wide_80.snap | 4 +-- 6 files changed, 19 insertions(+), 36 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap.new diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap index a979a98d85e8..8fd25f5c9415 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1479 expression: terminal.backend() --- -" tui  󱚥 gpt-5-codex   vermissian  " +" tui  󱚥 gpt-5-codex    vermissian " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap.new b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap.new new file mode 100644 index 000000000000..5539c5d98433 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap.new @@ -0,0 +1,7 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 1479 +expression: terminal.backend() +--- +"› Ask Codex to do anything " +" tui  󱚥 gpt-5-codex    vermissian " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index a04565bee24c..374a5a1ce538 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 2188 expression: term.backend().vt100().screen().contents() --- • I’m going to search the repo for where “Change Approved” is rendered to update @@ -12,4 +13,4 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - •  tui  󱚥 gpt-5-codex   󰋩 earth   vermissian   ck-kubero-admin  + •  tui  󱚥 gpt-5-codex   󰋩 earth   vermissian   ck-kubero-admin  diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index aa79e980c19c..21d216aa1893 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -411,8 +411,9 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { .rposition(|cell| cell.symbol() != " " || cell.bg != bg) .unwrap_or(0); last_nonblank_column[y as usize] = x as u16; - let (x_abs, y_abs) = a.pos_of(row_start + x + 1); - if x < (a.area.width as usize).saturating_sub(1) { + let clear_start = row_start + x + 1; + if x < (a.area.width as usize).saturating_sub(1) && clear_start < previous_buffer.len() { + let (x_abs, y_abs) = a.pos_of(clear_start); updates.push(DrawCommand::ClearToEnd { x: x_abs, y: y_abs, diff --git a/codex-rs/tui/src/statusline/mod.rs b/codex-rs/tui/src/statusline/mod.rs index 21704ec63c86..583fe3b45e17 100644 --- a/codex-rs/tui/src/statusline/mod.rs +++ b/codex-rs/tui/src/statusline/mod.rs @@ -492,22 +492,7 @@ impl<'a> RenderModel<'a> { let right_line = Line::from(right_spans.clone()); let left_width = line_display_width(&left_line); let right_width = line_display_width(&right_line); - let has_left = left_width > 0; - let has_right = right_width > 0; - - let mut separators = 0usize; - if has_left && (self.should_render_middle() || has_right) { - separators += 1; - } - if has_right && self.should_render_middle() { - separators += 1; - } - if separators > target_width { - return None; - } - - let available_for_middle = - target_width.checked_sub(left_width + right_width + separators)?; + let available_for_middle = target_width.checked_sub(left_width + right_width)?; let (middle_spans, _middle_width) = self.render_middle(available_for_middle)?; let mut spans: Vec> = Vec::new(); @@ -515,13 +500,8 @@ impl<'a> RenderModel<'a> { spans.extend(middle_spans); spans.extend(right_spans); - let mut line = Line::from(spans); - let current_width = line_display_width(&line); - if current_width < target_width { - line.spans - .push(" ".repeat(target_width - current_width).into()); - } - + let line = Line::from(spans); + debug_assert!(line_display_width(&line) <= target_width); if line_display_width(&line) == target_width { Some(line) } else { @@ -529,13 +509,6 @@ impl<'a> RenderModel<'a> { } } - fn should_render_middle(&self) -> bool { - match self.context_variant { - ContextVariant::Hidden => false, - _ => self.snapshot.context.is_some(), - } - } - fn render_left_segments(&self) -> Option>> { let segments = self.collect_left_segments(); if segments.is_empty() { diff --git a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap index a17e11b715a6..9085e428cd5f 100644 --- a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap +++ b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap @@ -1,5 +1,6 @@ --- source: tui/src/statusline/mod.rs +assertion_line: 1110 expression: snapshot_line_repr(&line) --- 00: fg=Rgb(166, 227, 161) "\u{e0b6}" @@ -19,7 +20,7 @@ expression: snapshot_line_repr(&line) 14: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235)|mod=ITALIC "high" 15: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " 16: fg=Rgb(137, 220, 235) "\u{e0b0}" -17: plain " " +17: plain " " 18: fg=Rgb(245, 224, 220) "\u{e0b2}" 19: fg=Rgb(30, 30, 46)|bg=Rgb(245, 224, 220) " " 20: fg=Rgb(30, 30, 46)|bg=Rgb(245, 224, 220) "\u{f233} vermissian" @@ -29,4 +30,3 @@ expression: snapshot_line_repr(&line) 24: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) "\u{e0a0} feature/fix-tests* ↑1" 25: fg=Rgb(30, 30, 46)|bg=Rgb(137, 220, 235) " " 26: fg=Rgb(137, 220, 235) "\u{e0b4}" -27: plain " " From 0400c79e71d6dc5403257268f2e175ff1b03ffa4 Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Tue, 7 Oct 2025 16:48:23 -0700 Subject: [PATCH 05/38] Restore marquee statusline --- codex-rs/Cargo.lock | 2 +- .../tui/src/bottom_pane/approval_overlay.rs | 7 +- codex-rs/tui/src/bottom_pane/mod.rs | 116 +------ ...er_fill_height_without_bottom_padding.snap | 11 - ...hidden_when_height_too_small_height_1.snap | 5 - ...hidden_when_height_too_small_height_2.snap | 6 - ...ly_patch_manual_flow_history_approved.snap | 2 +- ...atwidget__tests__approval_modal_patch.snap | 8 +- ...chatwidget__tests__chat_small_idle_h1.snap | 2 +- ...chatwidget__tests__chat_small_idle_h2.snap | 3 +- ...widget__tests__chat_small_idle_h2.snap.new | 7 - ...chatwidget__tests__chat_small_idle_h3.snap | 4 +- ...twidget__tests__chat_small_running_h2.snap | 2 - ...twidget__tests__chat_small_running_h3.snap | 3 +- ...dget__tests__exec_approval_modal_exec.snap | 7 +- ...atwidget__tests__status_widget_active.snap | 12 - codex-rs/tui/src/diff_render.rs | 116 ++----- codex-rs/tui/src/exec_cell/render.rs | 15 +- codex-rs/tui/src/history_cell.rs | 3 +- codex-rs/tui/src/render/mod.rs | 1 - codex-rs/tui/src/render/renderable.rs | 26 -- ...__diff_render__tests__apply_add_block.snap | 4 +- ...iff_render__tests__apply_delete_block.snap | 6 +- ...er__tests__apply_multiple_files_block.snap | 6 +- ...iff_render__tests__apply_update_block.snap | 9 +- ..._block_line_numbers_three_digits_text.snap | 13 - ...der__tests__apply_update_block_manual.snap | 16 + ...__apply_update_block_relativizes_path.snap | 7 +- ...__apply_update_block_wraps_long_lines.snap | 13 +- ...ly_update_block_wraps_long_lines_text.snap | 23 +- ...tests__apply_update_with_rename_block.snap | 9 +- ...f_render__tests__wrap_behavior_insert.snap | 4 +- ...ript_overlay_apply_patch_scroll_vt100.snap | 8 +- codex-rs/tui/src/status_indicator_widget.rs | 305 ------------------ codex-rs/tui/src/statusline/mod.rs | 301 ++++++++++++++--- codex-rs/tui/src/statusline/palette.rs | 1 + ...statusline__tests__statusline_wide_80.snap | 12 +- codex-rs/tui/src/statusline/state.rs | 43 ++- codex-rs/tui/src/wrapping.rs | 7 +- 39 files changed, 416 insertions(+), 729 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap.new delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_line_numbers_three_digits_text.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap delete mode 100644 codex-rs/tui/src/status_indicator_widget.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a9ff8094c232..8105794dc29c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -992,7 +992,7 @@ dependencies = [ "tokio-stream", "tracing", "tracing-subscriber", - "unicode-width 0.1.14", + "unicode-width 0.2.1", ] [[package]] diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 6585124c5287..7f52b11bfaf8 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -315,16 +315,15 @@ impl From for ApprovalRequestState { changes, } => { let mut header: Vec> = Vec::new(); + header.push(DiffSummary::new(changes, cwd).into()); if let Some(reason) = reason && !reason.is_empty() { + header.push(Box::new(Line::from(""))); header.push(Box::new( - Paragraph::new(Line::from_iter(["Reason: ".into(), reason.italic()])) - .wrap(Wrap { trim: false }), + Paragraph::new(reason.italic()).wrap(Wrap { trim: false }), )); - header.push(Box::new(Line::from(""))); } - header.push(DiffSummary::new(changes, cwd).into()); Self { variant: ApprovalVariant::ApplyPatch { id }, header: Box::new(ColumnRenderable::new(header)), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 60b5a392eed7..cac456811bc3 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -72,7 +72,7 @@ pub(crate) struct BottomPaneParams { } impl BottomPane { - const BOTTOM_PAD_LINES: u16 = 0; + const BOTTOM_PAD_LINES: u16 = 1; pub fn new(params: BottomPaneParams) -> Self { let enhanced_keys_supported = params.enhanced_keys_supported; Self { @@ -408,29 +408,10 @@ impl WidgetRef for &BottomPane { mod tests { use super::*; use crate::app_event::AppEvent; - use insta::assert_snapshot; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use tokio::sync::mpsc::unbounded_channel; - fn snapshot_buffer(buf: &Buffer) -> String { - let mut lines = Vec::new(); - for y in 0..buf.area().height { - let mut row = String::new(); - for x in 0..buf.area().width { - row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - lines.push(row); - } - lines.join("\n") - } - - fn render_snapshot(pane: &BottomPane, area: Rect) -> String { - let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); - snapshot_buffer(&buf) - } - fn exec_request() -> ApprovalRequest { ApprovalRequest::Exec { id: "1".to_string(), @@ -489,100 +470,7 @@ mod tests { } assert!( found_composer, - "expected composer visible under status line" - ); - - // Drain the channel to avoid unused warnings. - drop(rx); - } - - #[test] - fn status_indicator_visible_during_command_execution() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - // Begin a task: show initial status. - pane.set_task_running(true); - - // Use a height that allows the status line to be visible above the composer. - let area = Rect::new(0, 0, 40, 6); - let mut buf = Buffer::empty(area); - (&pane).render_ref(area, &mut buf); - - let mut row0 = String::new(); - for x in 0..area.width { - row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' ')); - } - assert!( - row0.contains("Working"), - "expected Working header: {row0:?}" - ); - } - - #[test] - fn status_and_composer_fill_height_without_bottom_padding() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - // Activate spinner (status view replaces composer) with no live ring. - pane.set_task_running(true); - - // Use height == desired_height; expect spacer + status + composer rows without trailing padding. - let height = pane.desired_height(30); - assert!( - height >= 3, - "expected at least 3 rows to render spacer, status, and composer; got {height}" - ); - let area = Rect::new(0, 0, 30, height); - assert_snapshot!( - "status_and_composer_fill_height_without_bottom_padding", - render_snapshot(&pane, area) - ); - } - - #[test] - fn status_hidden_when_height_too_small() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut pane = BottomPane::new(BottomPaneParams { - app_event_tx: tx, - frame_requester: FrameRequester::test_dummy(), - has_input_focus: true, - enhanced_keys_supported: false, - placeholder_text: "Ask Codex to do anything".to_string(), - disable_paste_burst: false, - }); - - pane.set_task_running(true); - - // Height=2 → composer takes the full space; status collapses when there is no room. - let area2 = Rect::new(0, 0, 20, 2); - assert_snapshot!( - "status_hidden_when_height_too_small_height_2", - render_snapshot(&pane, area2) - ); - - // Height=1 → no padding; single row is the composer (status hidden). - let area1 = Rect::new(0, 0, 20, 1); - assert_snapshot!( - "status_hidden_when_height_too_small_height_1", - render_snapshot(&pane, area1) + "composer placeholder should be visible while task running" ); } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap deleted file mode 100644 index ae96888f5621..000000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_and_composer_fill_height_without_bottom_padding.snap +++ /dev/null @@ -1,11 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area)" ---- - -• Working (0s • esc to interru - - -› Ask Codex to do anything - - ? for shortcuts diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap deleted file mode 100644 index 310c32b40c08..000000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_1.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area1)" ---- -› Ask Codex to do an diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap deleted file mode 100644 index ea0beeedf34b..000000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__tests__status_hidden_when_height_too_small_height_2.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tui/src/bottom_pane/mod.rs -expression: "render_snapshot(&pane, area2)" ---- - -› Ask Codex to do an diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap index e139b5108813..6e22bcebaa85 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__apply_patch_manual_flow_history_approved.snap @@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs expression: lines_to_single_string(&approved_lines) --- • Added foo.txt (+1 -0) - 1 +hello + 1 +hello diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 96dde8fb9312..ab88ffaf7364 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -4,12 +4,12 @@ expression: terminal.backend().vt100().screen().contents() --- Would you like to make the following edits? - Reason: The model wants to apply changes - README.md (+2 -0) - 1 +hello - 2 +world + 1 +hello + 2 +world + + The model wants to apply changes › 1. Yes, proceed 2. No, and tell Codex what to do differently esc diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap index 8fd25f5c9415..cf613cc0f99d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests.rs assertion_line: 1479 expression: terminal.backend() --- -" tui  󱚥 gpt-5-codex    vermissian " +" ◦  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap index 0e6da96a1c07..a2663e27cb3d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -1,6 +1,7 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1479 expression: terminal.backend() --- "› Ask Codex to do anything " -" tui  󱚥 gpt-5-codex   vermissian  " +" ◦  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap.new b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap.new deleted file mode 100644 index 5539c5d98433..000000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap.new +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -assertion_line: 1479 -expression: terminal.backend() ---- -"› Ask Codex to do anything " -" tui  󱚥 gpt-5-codex    vermissian " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index adb00d4bc691..189a72db2ecb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -1,8 +1,8 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1470 +assertion_line: 1479 expression: terminal.backend() --- " " -" " "› Ask Codex to do anything " +" ◦  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap index b29b99a10ed1..68e4f910dde2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h2.snap @@ -1,8 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1500 expression: terminal.backend() --- -" " "› Ask Codex to do anything " " •  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index 45d69ab1bdf1..b88829eb9ead 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -1,8 +1,7 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1500 expression: terminal.backend() --- " " -"• Thinking (0s • esc to interrupt) " "› Ask Codex to do anything " +" •  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index f0cf3a2d330a..0a4f71608ea4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -1,10 +1,10 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 409 +assertion_line: 413 expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 14 }, + area: Rect { x: 0, y: 0, width: 80, height: 16 }, content: [ " ", " ", @@ -20,6 +20,8 @@ Buffer { " 3. No, and tell Codex what to do differently esc ", " ", " Press enter to confirm or esc to cancel ", + " ", + " ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, @@ -34,5 +36,6 @@ Buffer { x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 50, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 0, y: 14, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap deleted file mode 100644 index e288807b00a8..000000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_active.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -assertion_line: 1577 -expression: terminal.backend() ---- -" " -"• Analyzing (0s • esc to interrupt) " -" " -" " -"› Ask Codex to do anything " -" " -" ? for shortcuts " diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 676b0559f81a..6f5f7e7d8ffb 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -13,14 +13,13 @@ use std::path::Path; use std::path::PathBuf; use crate::exec_command::relativize_to_home; -use crate::render::Insets; -use crate::render::line_utils::prefix_lines; use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; +const SPACES_AFTER_LINE_NUMBER: usize = 6; + // Internal representation for diff line rendering enum DiffLineType { Insert, @@ -66,10 +65,7 @@ impl From for Box { path.extend(render_line_count_summary(row.added, row.removed)); rows.push(Box::new(path)); rows.push(Box::new(RtLine::from(""))); - rows.push(Box::new(InsetRenderable::new( - Box::new(row.change), - Insets::tlbr(0, 2, 0, 0), - ))); + rows.push(Box::new(row.change)); } Box::new(ColumnRenderable::new(rows)) @@ -185,9 +181,7 @@ fn render_changes_block(rows: Vec, wrap_cols: usize, cwd: &Path) -> Vec, wrap_cols: usize, cwd: &Path) -> Vec>, width: usize) { match change { FileChange::Add { content } => { - let line_number_width = line_number_width(content.lines().count()); for (i, raw) in content.lines().enumerate() { out.extend(push_wrapped_diff_line( i + 1, DiffLineType::Insert, raw, width, - line_number_width, )); } } FileChange::Delete { content } => { - let line_number_width = line_number_width(content.lines().count()); for (i, raw) in content.lines().enumerate() { out.extend(push_wrapped_diff_line( i + 1, DiffLineType::Delete, raw, width, - line_number_width, )); } } FileChange::Update { unified_diff, .. } => { if let Ok(patch) = diffy::Patch::from_str(unified_diff) { - let mut max_line_number = 0; - for h in patch.hunks() { - let mut old_ln = h.old_range().start(); - let mut new_ln = h.new_range().start(); - for l in h.lines() { - match l { - diffy::Line::Insert(_) => { - max_line_number = max_line_number.max(new_ln); - new_ln += 1; - } - diffy::Line::Delete(_) => { - max_line_number = max_line_number.max(old_ln); - old_ln += 1; - } - diffy::Line::Context(_) => { - max_line_number = max_line_number.max(new_ln); - old_ln += 1; - new_ln += 1; - } - } - } - } - let line_number_width = line_number_width(max_line_number); let mut is_first_hunk = true; for h in patch.hunks() { if !is_first_hunk { - let spacer = format!("{:width$} ", "", width = line_number_width.max(1)); - let spacer_span = RtSpan::styled(spacer, style_gutter()); - out.push(RtLine::from(vec![spacer_span, "⋮".dim()])); + out.push(RtLine::from(vec![" ".into(), "⋮".dim()])); } is_first_hunk = false; @@ -264,7 +229,6 @@ fn render_change(change: &FileChange, out: &mut Vec>, width: usi DiffLineType::Insert, s, width, - line_number_width, )); new_ln += 1; } @@ -275,7 +239,6 @@ fn render_change(change: &FileChange, out: &mut Vec>, width: usi DiffLineType::Delete, s, width, - line_number_width, )); old_ln += 1; } @@ -286,7 +249,6 @@ fn render_change(change: &FileChange, out: &mut Vec>, width: usi DiffLineType::Context, s, width, - line_number_width, )); old_ln += 1; new_ln += 1; @@ -336,15 +298,17 @@ fn push_wrapped_diff_line( kind: DiffLineType, text: &str, width: usize, - line_number_width: usize, ) -> Vec> { + let indent = " "; let ln_str = line_number.to_string(); let mut remaining_text: &str = text; - // Reserve a fixed number of spaces (equal to the widest line number plus a - // trailing spacer) so the sign column stays aligned across the diff block. - let gutter_width = line_number_width.max(1); - let prefix_cols = gutter_width + 1; + // Reserve a fixed number of spaces after the line number so that content starts + // at a consistent column. Content includes a 1-character diff sign prefix + // ("+"/"-" for inserts/deletes, or a space for context lines) so alignment + // stays consistent across all diff lines. + let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len()); + let prefix_cols = indent.len() + ln_str.len() + gap_after_ln; let mut first = true; let (sign_char, line_style) = match kind { @@ -368,8 +332,8 @@ fn push_wrapped_diff_line( remaining_text = rest; if first { - // Build gutter (right-aligned line number plus spacer) as a dimmed span - let gutter = format!("{ln_str:>gutter_width$} "); + // Build gutter (indent + line number + spacing) as a dimmed span + let gutter = format!("{indent}{ln_str}{}", " ".repeat(gap_after_ln)); // Content with a sign ('+'/'-'/' ') styled per diff kind let content = format!("{sign_char}{chunk}"); lines.push(RtLine::from(vec![ @@ -379,7 +343,7 @@ fn push_wrapped_diff_line( first = false; } else { // Continuation lines keep a space for the sign column so content aligns - let gutter = format!("{:gutter_width$} ", ""); + let gutter = format!("{indent}{} ", " ".repeat(ln_str.len() + gap_after_ln)); lines.push(RtLine::from(vec![ RtSpan::styled(gutter, style_gutter()), RtSpan::styled(chunk.to_string(), line_style), @@ -392,14 +356,6 @@ fn push_wrapped_diff_line( lines } -fn line_number_width(max_line_number: usize) -> usize { - if max_line_number == 0 { - 1 - } else { - max_line_number.to_string().len() - } -} - fn style_gutter() -> Style { Style::default().add_modifier(Modifier::DIM) } @@ -465,8 +421,7 @@ mod tests { let long_line = "this is a very long line that should wrap across multiple terminal columns and continue"; // Call the wrapping function directly so we can precisely control the width - let lines = - push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80, line_number_width(1)); + let lines = push_wrapped_diff_line(1, DiffLineType::Insert, long_line, 80); // Render into a small terminal to capture the visual layout snapshot_lines("wrap_behavior_insert", lines, 90, 8); @@ -487,9 +442,11 @@ mod tests { }, ); - let lines = diff_summary_for_tests(&changes); + for name in ["apply_update_block", "apply_update_block_manual"] { + let lines = diff_summary_for_tests(&changes); - snapshot_lines("apply_update_block", lines, 80, 12); + snapshot_lines(name, lines, 80, 12); + } } #[test] @@ -616,37 +573,14 @@ mod tests { }, ); - let lines = create_diff_summary(&changes, &PathBuf::from("/"), 28); + let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28); + // Drop the combined header for this text-only snapshot + if !lines.is_empty() { + lines.remove(0); + } snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines); } - #[test] - fn ui_snapshot_apply_update_block_line_numbers_three_digits_text() { - let original = (1..=110).map(|i| format!("line {i}\n")).collect::(); - let modified = (1..=110) - .map(|i| { - if i == 100 { - format!("line {i} changed\n") - } else { - format!("line {i}\n") - } - }) - .collect::(); - let patch = diffy::create_patch(&original, &modified).to_string(); - - let mut changes: HashMap = HashMap::new(); - changes.insert( - PathBuf::from("hundreds.txt"), - FileChange::Update { - unified_diff: patch, - move_path: None, - }, - ); - - let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80); - snapshot_lines_text("apply_update_block_line_numbers_three_digits_text", &lines); - } - #[test] fn ui_snapshot_apply_update_block_relativizes_path() { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 51824ddc3caf..9c1231f0cfa6 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -8,7 +8,6 @@ use crate::history_cell::HistoryCell; use crate::render::highlight::highlight_bash_to_lines; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; -use crate::shimmer::shimmer_spans; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use codex_ansi_escape::ansi_escape_line; @@ -117,16 +116,10 @@ pub(crate) fn output_lines( } pub(crate) fn spinner(start_time: Option) -> Span<'static> { - let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default(); - if supports_color::on_cached(supports_color::Stream::Stdout) - .map(|level| level.has_16m) - .unwrap_or(false) - { - shimmer_spans("•")[0].clone() - } else { - let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2); - if blink_on { "•".into() } else { "◦".dim() } - } + let blink_on = start_time + .map(|st| ((st.elapsed().as_millis() / 600) % 2) == 0) + .unwrap_or(false); + if blink_on { "•".into() } else { "◦".dim() } } impl HistoryCell for ExecCell { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4e648ca851d3..54d081c5f112 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -105,8 +105,7 @@ impl HistoryCell for UserHistoryCell { .lines() .map(|l| Line::from(l).style(style)) .collect::>(), - // Wrap algorithm matches textarea.rs. - RtOptions::new(wrap_width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + RtOptions::new(wrap_width as usize), ); lines.push(Line::from("").style(style)); diff --git a/codex-rs/tui/src/render/mod.rs b/codex-rs/tui/src/render/mod.rs index fe92eea91bd3..441c1d6b21eb 100644 --- a/codex-rs/tui/src/render/mod.rs +++ b/codex-rs/tui/src/render/mod.rs @@ -4,7 +4,6 @@ pub mod highlight; pub mod line_utils; pub mod renderable; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Insets { pub left: u16, pub top: u16, diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index c55426337ddd..868d07264b2a 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -4,9 +4,6 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; -use crate::render::Insets; -use crate::render::RectExt as _; - pub trait Renderable { fn render(&self, area: Rect, buf: &mut Buffer); fn desired_height(&self, width: u16) -> u16; @@ -103,26 +100,3 @@ impl ColumnRenderable { } } } - -pub struct InsetRenderable { - child: Box, - insets: Insets, -} - -impl Renderable for InsetRenderable { - fn render(&self, area: Rect, buf: &mut Buffer) { - self.child.render(area.inset(self.insets), buf); - } - fn desired_height(&self, width: u16) -> u16 { - self.child - .desired_height(width - self.insets.left - self.insets.right) - + self.insets.top - + self.insets.bottom - } -} - -impl InsetRenderable { - pub fn new(child: Box, insets: Insets) -> Self { - Self { child, insets } - } -} diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap index cd5aaf5fa0a9..254f51b64427 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_add_block.snap @@ -3,8 +3,8 @@ source: tui/src/diff_render.rs expression: terminal.backend() --- "• Added new_file.txt (+2 -0) " -" 1 +alpha " -" 2 +beta " +" 1 +alpha " +" 2 +beta " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap index edfdb2c05f34..989200b22858 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_delete_block.snap @@ -3,9 +3,9 @@ source: tui/src/diff_render.rs expression: terminal.backend() --- "• Deleted tmp_delete_example.txt (+0 -3) " -" 1 -first " -" 2 -second " -" 3 -third " +" 1 -first " +" 2 -second " +" 3 -third " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap index 62fc671d11e0..62e1f58e1b03 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_multiple_files_block.snap @@ -4,11 +4,11 @@ expression: terminal.backend() --- "• Edited 2 files (+2 -1) " " └ a.txt (+1 -1) " -" 1 -one " -" 1 +one changed " +" 1 -one " +" 1 +one changed " " " " └ b.txt (+1 -0) " -" 1 +new " +" 1 +new " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap index 8cc31efdded1..305120d388e3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block.snap @@ -1,12 +1,13 @@ --- source: tui/src/diff_render.rs +assertion_line: 748 expression: terminal.backend() --- "• Edited example.txt (+1 -1) " -" 1 line one " -" 2 -line two " -" 2 +line two changed " -" 3 line three " +" 1 line one " +" 2 -line two " +" 2 +line two changed " +" 3 line three " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_line_numbers_three_digits_text.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_line_numbers_three_digits_text.snap deleted file mode 100644 index 56058ee70e12..000000000000 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_line_numbers_three_digits_text.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/diff_render.rs -expression: text ---- -• Edited hundreds.txt (+1 -1) - 97 line 97 - 98 line 98 - 99 line 99 - 100 -line 100 - 100 +line 100 changed - 101 line 101 - 102 line 102 - 103 line 103 diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap new file mode 100644 index 000000000000..d188b2fd6fd0 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_manual.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"• Edited example.txt (+1 -1) " +" 1 line one " +" 2 -line two " +" 2 +line two changed " +" 3 line three " +" " +" " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap index a50f7700cf9a..825e50ebc9a7 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_relativizes_path.snap @@ -1,11 +1,12 @@ --- source: tui/src/diff_render.rs +assertion_line: 748 expression: terminal.backend() --- "• Edited abs_old.rs → abs_new.rs (+1 -1) " -" 1 -X " -" 1 +X changed " -" 2 Y " +" 1 -X " +" 1 +X changed " +" 2 Y " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap index 72b9528e5c90..d571b0063816 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines.snap @@ -1,14 +1,15 @@ --- source: tui/src/diff_render.rs +assertion_line: 748 expression: terminal.backend() --- "• Edited long_example.txt (+1 -1) " -" 1 line 1 " -" 2 -short " -" 2 +short this_is_a_very_long_modified_line_that_should_wrap_across_m " -" ultiple_terminal_columns_and_continue_even_further_beyond_eighty_ " -" columns_to_force_multiple_wraps " -" 3 line 3 " +" 1 line 1 " +" 2 -short " +" 2 +short this_is_a_very_long_modified_line_that_should_wrap_acro " +" ss_multiple_terminal_columns_and_continue_even_further_beyond " +" _eighty_columns_to_force_multiple_wraps " +" 3 line 3 " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap index 17c92c1b5d82..ea495c10cb9a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_block_wraps_long_lines_text.snap @@ -2,14 +2,15 @@ source: tui/src/diff_render.rs expression: text --- -• Edited wrap_demo.txt (+2 -2) - 1 1 - 2 -2 - 2 +added long line which - wraps and_if_there_i - s_a_long_token_it_wil - l_be_broken - 3 3 - 4 -4 - 4 +4 context line which - also wraps across + 1 1 + 2 -2 + 2 +added long line w + hich wraps and_if + _there_is_a_long_ + token_it_will_be_ + broken + 3 3 + 4 -4 + 4 +4 context line wh + ich also wraps ac + ross diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap index 29b321011ce1..89c2ddf1418b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__apply_update_with_rename_block.snap @@ -1,12 +1,13 @@ --- source: tui/src/diff_render.rs +assertion_line: 748 expression: terminal.backend() --- "• Edited old_name.rs → new_name.rs (+1 -1) " -" 1 A " -" 2 -B " -" 2 +B changed " -" 3 C " +" 1 A " +" 2 -B " +" 2 +B changed " +" 3 C " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap index 7532977cea97..b14dafaa3c3d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap @@ -2,8 +2,8 @@ source: tui/src/diff_render.rs expression: terminal.backend() --- -"1 +this is a very long line that should wrap across multiple terminal columns an " -" d continue " +" 1 +this is a very long line that should wrap across multiple terminal co " +" lumns and continue " " " " " " " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap index 1f086e81e433..7beca4a4c2a3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_apply_patch_scroll_vt100.snap @@ -4,12 +4,12 @@ expression: snapshot --- / T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / • Added foo.txt (+2 -0) - 1 +hello - 2 +world + 1 +hello + 2 +world • Added foo.txt (+2 -0) - 1 +hello - 2 +world + 1 +hello + 2 +world ─────────────────────────────────────────────────────────────────────────── 0% ─ ↑/↓ to scroll pgup/pgdn to page home/end to jump q to quit esc to edit prev diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs deleted file mode 100644 index ce4f6eabd852..000000000000 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ /dev/null @@ -1,305 +0,0 @@ -//! A live status indicator that shows the *latest* log line emitted by the -//! application while the agent is processing a long‑running task. - -use std::time::Duration; -use std::time::Instant; - -use codex_core::protocol::Op; -use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::Stylize; -use ratatui::text::Line; -use ratatui::widgets::Paragraph; -use ratatui::widgets::WidgetRef; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::exec_cell::spinner; -use crate::key_hint; -use crate::shimmer::shimmer_spans; -use crate::tui::FrameRequester; - -pub(crate) struct StatusIndicatorWidget { - /// Animated header text (defaults to "Working"). - header: String, - /// Queued user messages to display under the status line. - queued_messages: Vec, - - elapsed_running: Duration, - last_resume_at: Instant, - is_paused: bool, - app_event_tx: AppEventSender, - frame_requester: FrameRequester, -} - -// Format elapsed seconds into a compact human-friendly form used by the status line. -// Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s -pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { - if elapsed_secs < 60 { - return format!("{elapsed_secs}s"); - } - if elapsed_secs < 3600 { - let minutes = elapsed_secs / 60; - let seconds = elapsed_secs % 60; - return format!("{minutes}m {seconds:02}s"); - } - let hours = elapsed_secs / 3600; - let minutes = (elapsed_secs % 3600) / 60; - let seconds = elapsed_secs % 60; - format!("{hours}h {minutes:02}m {seconds:02}s") -} - -impl StatusIndicatorWidget { - pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { - Self { - header: String::from("Working"), - queued_messages: Vec::new(), - elapsed_running: Duration::ZERO, - last_resume_at: Instant::now(), - is_paused: false, - - app_event_tx, - frame_requester, - } - } - - pub fn desired_height(&self, width: u16) -> u16 { - // Status line + optional blank line + wrapped queued messages (up to 3 lines per message) - // + optional ellipsis line per truncated message + 1 spacer line - let inner_width = width.max(1) as usize; - let mut total: u16 = 1; // status line - if !self.queued_messages.is_empty() { - total = total.saturating_add(1); // blank line between status and queued messages - } - let text_width = inner_width.saturating_sub(3); // account for " ↳ " prefix - if text_width > 0 { - for q in &self.queued_messages { - let wrapped = textwrap::wrap(q, text_width); - let lines = wrapped.len().min(3) as u16; - total = total.saturating_add(lines); - if wrapped.len() > 3 { - total = total.saturating_add(1); // ellipsis line - } - } - if !self.queued_messages.is_empty() { - total = total.saturating_add(1); // keybind hint line - } - } else { - // At least one line per message if width is extremely narrow - total = total.saturating_add(self.queued_messages.len() as u16); - } - total.saturating_add(1) // spacer line - } - - pub(crate) fn interrupt(&self) { - self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); - } - - /// Update the animated header label (left of the brackets). - pub(crate) fn update_header(&mut self, header: String) { - if self.header != header { - self.header = header; - } - } - - /// Replace the queued messages displayed beneath the header. - pub(crate) fn set_queued_messages(&mut self, queued: Vec) { - self.queued_messages = queued; - // Ensure a redraw so changes are visible. - self.frame_requester.schedule_frame(); - } - - pub(crate) fn pause_timer(&mut self) { - self.pause_timer_at(Instant::now()); - } - - pub(crate) fn resume_timer(&mut self) { - self.resume_timer_at(Instant::now()); - } - - pub(crate) fn pause_timer_at(&mut self, now: Instant) { - if self.is_paused { - return; - } - self.elapsed_running += now.saturating_duration_since(self.last_resume_at); - self.is_paused = true; - } - - pub(crate) fn resume_timer_at(&mut self, now: Instant) { - if !self.is_paused { - return; - } - self.last_resume_at = now; - self.is_paused = false; - self.frame_requester.schedule_frame(); - } - - fn elapsed_duration_at(&self, now: Instant) -> Duration { - let mut elapsed = self.elapsed_running; - if !self.is_paused { - elapsed += now.saturating_duration_since(self.last_resume_at); - } - elapsed - } - - fn elapsed_seconds_at(&self, now: Instant) -> u64 { - self.elapsed_duration_at(now).as_secs() - } - - pub fn elapsed_seconds(&self) -> u64 { - self.elapsed_seconds_at(Instant::now()) - } -} - -impl WidgetRef for StatusIndicatorWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - if area.is_empty() { - return; - } - - // Schedule next animation frame. - self.frame_requester - .schedule_frame_in(Duration::from_millis(32)); - let now = Instant::now(); - let elapsed_duration = self.elapsed_duration_at(now); - let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); - - // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. - let mut spans = Vec::with_capacity(5); - spans.push(spinner(Some(self.last_resume_at))); - spans.push(" ".into()); - spans.extend(shimmer_spans(&self.header)); - spans.extend(vec![ - " ".into(), - format!("({pretty_elapsed} • ").dim(), - key_hint::plain(KeyCode::Esc).into(), - " to interrupt)".dim(), - ]); - - // Build lines: status, then queued messages, then spacer. - let mut lines: Vec> = Vec::new(); - lines.push(Line::from(spans)); - if !self.queued_messages.is_empty() { - lines.push(Line::from("")); - } - // Wrap queued messages using textwrap and show up to the first 3 lines per message. - let text_width = area.width.saturating_sub(3); // " ↳ " prefix - for q in &self.queued_messages { - let wrapped = textwrap::wrap(q, text_width as usize); - for (i, piece) in wrapped.iter().take(3).enumerate() { - let prefix = if i == 0 { " ↳ " } else { " " }; - let content = format!("{prefix}{piece}"); - lines.push(Line::from(content.dim().italic())); - } - if wrapped.len() > 3 { - lines.push(Line::from(" …".dim().italic())); - } - } - if !self.queued_messages.is_empty() { - lines.push( - Line::from(vec![ - " ".into(), - key_hint::alt(KeyCode::Up).into(), - " edit".into(), - ]) - .dim(), - ); - } - - let paragraph = Paragraph::new(lines); - paragraph.render_ref(area, buf); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_event::AppEvent; - use crate::app_event_sender::AppEventSender; - use ratatui::Terminal; - use ratatui::backend::TestBackend; - use std::time::Duration; - use std::time::Instant; - use tokio::sync::mpsc::unbounded_channel; - - use pretty_assertions::assert_eq; - - #[test] - fn fmt_elapsed_compact_formats_seconds_minutes_hours() { - assert_eq!(fmt_elapsed_compact(0), "0s"); - assert_eq!(fmt_elapsed_compact(1), "1s"); - assert_eq!(fmt_elapsed_compact(59), "59s"); - assert_eq!(fmt_elapsed_compact(60), "1m 00s"); - assert_eq!(fmt_elapsed_compact(61), "1m 01s"); - assert_eq!(fmt_elapsed_compact(3 * 60 + 5), "3m 05s"); - assert_eq!(fmt_elapsed_compact(59 * 60 + 59), "59m 59s"); - assert_eq!(fmt_elapsed_compact(3600), "1h 00m 00s"); - assert_eq!(fmt_elapsed_compact(3600 + 60 + 1), "1h 01m 01s"); - assert_eq!(fmt_elapsed_compact(25 * 3600 + 2 * 60 + 3), "25h 02m 03s"); - } - - #[test] - fn renders_with_working_header() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - - // Render into a fixed-size test terminal and snapshot the backend. - let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal"); - terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn renders_truncated() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - - // Render into a fixed-size test terminal and snapshot the backend. - let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal"); - terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn renders_with_queued_messages() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - w.set_queued_messages(vec!["first".to_string(), "second".to_string()]); - - // Render into a fixed-size test terminal and snapshot the backend. - let mut terminal = Terminal::new(TestBackend::new(80, 8)).expect("terminal"); - terminal - .draw(|f| w.render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - insta::assert_snapshot!(terminal.backend()); - } - - #[test] - fn timer_pauses_when_requested() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let mut widget = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); - - let baseline = Instant::now(); - widget.last_resume_at = baseline; - - let before_pause = widget.elapsed_seconds_at(baseline + Duration::from_secs(5)); - assert_eq!(before_pause, 5); - - widget.pause_timer_at(baseline + Duration::from_secs(5)); - let paused_elapsed = widget.elapsed_seconds_at(baseline + Duration::from_secs(10)); - assert_eq!(paused_elapsed, before_pause); - - widget.resume_timer_at(baseline + Duration::from_secs(10)); - let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13)); - assert_eq!(after_resume, before_pause + 3); - } -} diff --git a/codex-rs/tui/src/statusline/mod.rs b/codex-rs/tui/src/statusline/mod.rs index 583fe3b45e17..84ae421695c4 100644 --- a/codex-rs/tui/src/statusline/mod.rs +++ b/codex-rs/tui/src/statusline/mod.rs @@ -56,6 +56,13 @@ const PROGRESS_RIGHT_FULL: &str = ""; const MODEL_ICONS: &[char] = &['󰚩', '󱚝', '󱚟', '󱚡', '󱚣', '󱚥']; const DEVSPACE_ICONS: &[&str] = &["󰠖 ", "󰠶 ", "󰋩 ", "󰚌 "]; const CONTEXT_PADDING: usize = 4; +const DEFAULT_STATUS_MESSAGE: &str = "Waiting for input"; +pub(super) const STATUS_CAPSULE_WIDTH: usize = 32; +pub(super) const STATUS_CAPSULE_SPINNER_WIDTH: usize = 1; +pub(super) const STATUS_CAPSULE_GAP_WIDTH: usize = 1; +pub(super) const STATUS_CAPSULE_TEXT_WIDTH: usize = + STATUS_CAPSULE_WIDTH - STATUS_CAPSULE_SPINNER_WIDTH - STATUS_CAPSULE_GAP_WIDTH; +pub(super) const MARQUEE_STEP_MS: u64 = 450; fn span(text: S, style: Style) -> Span<'static> where @@ -114,6 +121,7 @@ pub(crate) struct StatusLineModelSnapshot { #[derive(Debug, Clone, Default)] pub(crate) struct StatusLineTokenSnapshot { pub total: TokenCountSnapshot, + #[allow(dead_code)] pub last: Option, } @@ -146,6 +154,7 @@ pub(crate) struct StatusLineContextSnapshot { } impl StatusLineContextSnapshot { + #[allow(dead_code)] fn percent_used(&self) -> u8 { 100u8.saturating_sub(self.percent_remaining) } @@ -164,13 +173,27 @@ pub(crate) struct StatusLineDevspaceSnapshot { pub name: String, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub(crate) struct StatusLineRunState { pub label: String, pub spinner_started_at: Option, pub timer: Option, pub queued_messages: Vec, pub show_interrupt_hint: bool, + pub status_changed_at: Instant, +} + +impl Default for StatusLineRunState { + fn default() -> Self { + Self { + label: String::new(), + spinner_started_at: None, + timer: None, + queued_messages: Vec::new(), + show_interrupt_hint: false, + status_changed_at: Instant::now(), + } + } } #[derive(Debug, Clone, Default)] @@ -535,9 +558,7 @@ impl<'a> RenderModel<'a> { fn collect_left_segments(&self) -> Vec { let mut segments: Vec = Vec::new(); - if let Some(state) = self.snapshot.run_state.as_ref() { - segments.extend(self.run_state_segments(state)); - } + segments.extend(self.run_state_segments(self.snapshot.run_state.as_ref())); if let Some(segment) = self.path_segment() { segments.push(segment); } @@ -628,36 +649,48 @@ impl<'a> RenderModel<'a> { } } - fn run_state_segments(&self, state: &StatusLineRunState) -> Vec { + fn run_state_segments(&self, state: Option<&StatusLineRunState>) -> Vec { + let fallback_state; + let state = if let Some(state) = state { + state + } else { + fallback_state = StatusLineRunState { + label: DEFAULT_STATUS_MESSAGE.to_string(), + spinner_started_at: None, + timer: Some(RunTimerSnapshot { + elapsed_running: Duration::ZERO, + last_resume_at: None, + is_paused: true, + }), + queued_messages: Vec::new(), + show_interrupt_hint: false, + status_changed_at: self.now, + }; + &fallback_state + }; + let mut segments: Vec = Vec::new(); + let spinner_span = spinner(state.spinner_started_at); - let spinner_text = spinner(state.spinner_started_at).content.to_string(); - let mut label_parts: Vec = vec![spinner_text]; if self.show_run_label { - let label = match self.run_label_variant { - RunLabelVariant::Full => state.label.clone(), - RunLabelVariant::Short => state - .label - .split_whitespace() - .next() - .unwrap_or("") - .to_string(), - }; - if !label.is_empty() { - label_parts.push(label); - } - } - let label_text = label_parts.join(" "); - if !label_text.is_empty() { - segments.push(PowerlineSegment::text(GREEN, label_text)); + let label = self.run_label_text(state); + segments.push(self.status_capsule_segment(spinner_span, &label, state)); + } else { + let accent = self.status_capsule_accent(state); + segments.push(PowerlineSegment::from_spans(accent, vec![spinner_span])); } - if self.show_run_timer - && let Some(timer) = state.timer.as_ref() - { - let elapsed = timer.elapsed_at(self.now).as_secs(); - let text = format!("󰔟 {}", format_elapsed_compact(elapsed)); - segments.push(PowerlineSegment::text(GREEN, text)); + if self.show_run_timer { + if let Some(timer) = state.timer.as_ref() { + let elapsed = timer.elapsed_at(self.now).as_secs(); + let text = format!("󰔟 {}", format_elapsed_compact(elapsed)); + segments.push(PowerlineSegment::text(PEACH, text)); + } else { + segments.push(PowerlineSegment::text( + MAUVE, + format!("󰔟 {}", format_elapsed_compact(0)), + )); + } } if self.include_queue_preview && !state.queued_messages.is_empty() { @@ -678,6 +711,61 @@ impl<'a> RenderModel<'a> { segments } + fn run_label_text(&self, state: &StatusLineRunState) -> String { + let mut label = match self.run_label_variant { + RunLabelVariant::Full => state.label.clone(), + RunLabelVariant::Short => state + .label + .split_whitespace() + .next() + .unwrap_or("") + .to_string(), + }; + if label.trim().is_empty() { + DEFAULT_STATUS_MESSAGE.to_string() + } else { + if label.starts_with(' ') || label.ends_with(' ') { + label = label.trim().to_string(); + } + label + } + } + + fn status_capsule_segment( + &self, + spinner_span: Span<'static>, + label: &str, + state: &StatusLineRunState, + ) -> PowerlineSegment { + debug_assert_eq!( + STATUS_CAPSULE_TEXT_WIDTH + STATUS_CAPSULE_SPINNER_WIDTH + STATUS_CAPSULE_GAP_WIDTH, + STATUS_CAPSULE_WIDTH + ); + let marquee = marquee_text(label, state, self.now); + let mut spans: Vec> = Vec::with_capacity(3); + spans.push(spinner_span); + match STATUS_CAPSULE_GAP_WIDTH { + 0 => {} + 1 => spans.push(" ".into()), + gap => spans.push(Span::raw(" ".repeat(gap))), + } + spans.push(Span::raw(marquee)); + let accent = self.status_capsule_accent(state); + PowerlineSegment::from_spans(accent, spans) + } + + fn status_capsule_accent(&self, state: &StatusLineRunState) -> Color { + if state + .timer + .as_ref() + .map(|timer| !timer.is_paused) + .unwrap_or(false) + { + GREEN + } else { + MAUVE + } + } fn render_right_segments(&self) -> Option>> { let segments = self.collect_right_segments(); @@ -889,6 +977,95 @@ fn truncate_graphemes(text: &str, max_graphemes: usize) -> String { truncated } +fn marquee_text(label: &str, state: &StatusLineRunState, now: Instant) -> String { + if STATUS_CAPSULE_TEXT_WIDTH == 0 { + return String::new(); + } + let label_width = UnicodeWidthStr::width(label); + let offset = if label_width > STATUS_CAPSULE_TEXT_WIDTH { + let elapsed = now.saturating_duration_since(state.status_changed_at); + let max_offset = label_width - STATUS_CAPSULE_TEXT_WIDTH; + marquee_offset(elapsed, max_offset) + } else { + 0 + }; + slice_text_segment(label, offset, STATUS_CAPSULE_TEXT_WIDTH) +} + +fn marquee_offset(elapsed: Duration, max_offset: usize) -> usize { + if max_offset == 0 { + return 0; + } + let step_millis = elapsed.as_millis() / u128::from(MARQUEE_STEP_MS); + let step = usize::try_from(step_millis).unwrap_or(usize::MAX); + let span = max_offset.saturating_mul(2); + if span == 0 { + return 0; + } + let position = step % span; + if position < max_offset { + position + } else { + span - position + } +} + +fn slice_text_segment(text: &str, start_cols: usize, width_cols: usize) -> String { + if width_cols == 0 { + return String::new(); + } + let graphemes: Vec<&str> = text.graphemes(true).collect(); + if graphemes.is_empty() { + return " ".repeat(width_cols); + } + let widths: Vec = graphemes + .iter() + .map(|g| UnicodeWidthStr::width(*g)) + .collect(); + + let mut consumed = 0; + let mut start_index = 0; + while start_index < widths.len() && consumed + widths[start_index] <= start_cols { + consumed += widths[start_index]; + start_index += 1; + } + if start_index >= widths.len() { + return " ".repeat(width_cols); + } + if consumed < start_cols { + start_index += 1; + if start_index >= widths.len() { + return " ".repeat(width_cols); + } + } + + let mut result = String::new(); + let mut used = 0; + for idx in start_index..graphemes.len() { + let g = graphemes[idx]; + let g_width = widths[idx]; + if g_width > width_cols && used == 0 { + return " ".repeat(width_cols); + } + if used + g_width > width_cols { + break; + } + result.push_str(g); + used += g_width; + if used == width_cols { + break; + } + } + if used < width_cols { + result.push_str(&" ".repeat(width_cols - used)); + } + if result.is_empty() { + " ".repeat(width_cols) + } else { + result + } +} + fn queue_preview(commands: &[String]) -> (String, usize) { if commands.is_empty() { return (String::new(), 0); @@ -981,28 +1158,32 @@ fn format_token_count(value: u64) -> String { } fn select_model_icon(model: &str) -> char { - if MODEL_ICONS.is_empty() { - return '󰚩'; - } - if model.is_empty() { - return MODEL_ICONS[0]; - } - let mut hash: u64 = 0; - for byte in model.as_bytes() { - hash = hash.wrapping_mul(131).wrapping_add(*byte as u64); + match MODEL_ICONS { + [] => '󰚩', + icons => { + if model.is_empty() { + return icons[0]; + } + let mut hash: u64 = 0; + for byte in model.as_bytes() { + hash = hash.wrapping_mul(131).wrapping_add(*byte as u64); + } + icons[(hash as usize) % icons.len()] + } } - MODEL_ICONS[(hash as usize) % MODEL_ICONS.len()] } fn devspace_icon(name: &str) -> &'static str { - if DEVSPACE_ICONS.is_empty() { - return "󰠖 "; - } - let mut hash: u64 = 0; - for byte in name.as_bytes() { - hash = hash.wrapping_mul(167).wrapping_add(*byte as u64); + match DEVSPACE_ICONS { + [] => "󰠖 ", + icons => { + let mut hash: u64 = 0; + for byte in name.as_bytes() { + hash = hash.wrapping_mul(167).wrapping_add(*byte as u64); + } + icons[(hash as usize) % icons.len()] + } } - DEVSPACE_ICONS[(hash as usize) % DEVSPACE_ICONS.len()] } fn context_bar_colors(percent_used: f64) -> (Color, Color) { @@ -1120,6 +1301,33 @@ mod tests { assert_snapshot!("statusline_narrow_40", snapshot_line_repr(&line)); } + #[test] + fn run_label_defaults_to_waiting_message() { + let now = Instant::now(); + let snapshot = StatusLineSnapshot { + context: Some(StatusLineContextSnapshot { + percent_remaining: 100, + tokens_in_context: 0, + window: 1, + }), + run_state: Some(StatusLineRunState { + status_changed_at: now, + ..StatusLineRunState::default() + }), + ..StatusLineSnapshot::default() + }; + let renderer = StatusLineRenderer; + let line = renderer.render(&snapshot, 120, now); + let has_default = line + .spans + .iter() + .any(|span| span.content.contains(DEFAULT_STATUS_MESSAGE)); + assert!( + has_default, + "status capsule should show default message when label empty" + ); + } + fn sample_snapshot() -> StatusLineSnapshot { StatusLineSnapshot { cwd_display: Some("~/workspace/codex".to_string()), @@ -1160,6 +1368,7 @@ mod tests { }), queued_messages: vec!["git status".to_string(), "cargo test --all".to_string()], show_interrupt_hint: true, + status_changed_at: Instant::now(), }), git: Some(StatusLineGitSnapshot { branch: Some("feature/fix-tests".to_string()), diff --git a/codex-rs/tui/src/statusline/palette.rs b/codex-rs/tui/src/statusline/palette.rs index 2841210c9e38..023e3095a32f 100644 --- a/codex-rs/tui/src/statusline/palette.rs +++ b/codex-rs/tui/src/statusline/palette.rs @@ -23,6 +23,7 @@ pub(crate) const ROSEWATER: Color = Color::Rgb(245, 224, 220); #[allow(clippy::disallowed_methods)] pub(crate) const TEAL: Color = Color::Rgb(148, 226, 213); #[allow(clippy::disallowed_methods)] +#[allow(dead_code)] pub(crate) const SURFACE0: Color = Color::Rgb(49, 50, 68); #[allow(clippy::disallowed_methods)] pub(crate) const SUBTEXT0: Color = Color::Rgb(166, 173, 200); diff --git a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap index 9085e428cd5f..255d7aa59711 100644 --- a/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap +++ b/codex-rs/tui/src/statusline/snapshots/codex_tui__statusline__tests__statusline_wide_80.snap @@ -1,13 +1,13 @@ --- source: tui/src/statusline/mod.rs -assertion_line: 1110 +assertion_line: 1292 expression: snapshot_line_repr(&line) --- -00: fg=Rgb(166, 227, 161) "\u{e0b6}" -01: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) " " -02: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) "◦" -03: fg=Rgb(30, 30, 46)|bg=Rgb(166, 227, 161) " " -04: fg=Rgb(166, 227, 161)|bg=Rgb(180, 190, 254) "\u{e0b0}" +00: fg=Rgb(203, 166, 247) "\u{e0b6}" +01: fg=Rgb(30, 30, 46)|bg=Rgb(203, 166, 247) " " +02: fg=Rgb(30, 30, 46)|bg=Rgb(203, 166, 247)|mod=DIM "◦" +03: fg=Rgb(30, 30, 46)|bg=Rgb(203, 166, 247) " " +04: fg=Rgb(203, 166, 247)|bg=Rgb(180, 190, 254) "\u{e0b0}" 05: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) " " 06: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) "codex" 07: fg=Rgb(30, 30, 46)|bg=Rgb(180, 190, 254) " " diff --git a/codex-rs/tui/src/statusline/state.rs b/codex-rs/tui/src/statusline/state.rs index 727aee457a0f..b6c2880fe3c9 100644 --- a/codex-rs/tui/src/statusline/state.rs +++ b/codex-rs/tui/src/statusline/state.rs @@ -7,11 +7,15 @@ use codex_core::config::Config; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol_config_types::ReasoningEffort; use ratatui::text::Line; +use unicode_width::UnicodeWidthStr; use crate::status::format_directory_display; use crate::tui::FrameRequester; +use super::DEFAULT_STATUS_MESSAGE; +use super::MARQUEE_STEP_MS; use super::RunTimerSnapshot; +use super::STATUS_CAPSULE_TEXT_WIDTH; use super::StatusLineContextSnapshot; use super::StatusLineDevspaceSnapshot; use super::StatusLineGitSnapshot; @@ -48,6 +52,7 @@ impl StatusLineState { context_window_hint: config.model_context_window, }; state.set_working_directory(&cwd); + state.set_idle_run_state(Instant::now()); state } @@ -133,6 +138,7 @@ impl StatusLineState { if let Some(run_state) = self.snapshot.run_state.as_mut() { if run_state.label != header { run_state.label = header.to_string(); + run_state.status_changed_at = Instant::now(); self.request_redraw(); } } else { @@ -140,11 +146,28 @@ impl StatusLineState { label: header.to_string(), show_interrupt_hint: self.esc_hint, queued_messages: self.queued_messages.clone(), + status_changed_at: Instant::now(), ..StatusLineRunState::default() }); self.request_redraw(); } } + fn set_idle_run_state(&mut self, now: Instant) { + let run_state = StatusLineRunState { + label: DEFAULT_STATUS_MESSAGE.to_string(), + spinner_started_at: None, + timer: Some(RunTimerSnapshot { + elapsed_running: Duration::ZERO, + last_resume_at: None, + is_paused: true, + }), + queued_messages: self.queued_messages.clone(), + show_interrupt_hint: false, + status_changed_at: now, + }; + self.snapshot.run_state = Some(run_state); + self.request_redraw(); + } pub(crate) fn start_task(&mut self, header: impl Into) { let header = header.into(); @@ -157,16 +180,18 @@ impl StatusLineState { run_state.label = header; run_state.show_interrupt_hint = self.esc_hint; run_state.queued_messages = self.queued_messages.clone(); + run_state.status_changed_at = now; self.snapshot.run_state = Some(run_state); self.request_redraw(); } pub(crate) fn complete_task(&mut self) { + let now = Instant::now(); if let Some(timer) = self.run_timer.as_mut() { - timer.pause(Instant::now()); + timer.pause(now); } self.run_timer = None; - self.snapshot.run_state = None; + self.set_idle_run_state(now); self.request_redraw(); } @@ -192,11 +217,19 @@ impl StatusLineState { run_state.queued_messages = self.queued_messages.clone(); run_state.show_interrupt_hint = self.esc_hint; } - if let Some(timer) = self.run_timer.as_ref() - && !timer.is_paused - { + let timer_active = self + .run_timer + .as_ref() + .map(|timer| !timer.is_paused) + .unwrap_or(false); + if timer_active { self.frame_requester .schedule_frame_in(Duration::from_millis(48)); + } else if let Some(run_state) = snapshot.run_state.as_ref() + && UnicodeWidthStr::width(run_state.label.as_str()) > STATUS_CAPSULE_TEXT_WIDTH + { + self.frame_requester + .schedule_frame_in(Duration::from_millis(MARQUEE_STEP_MS)); } snapshot } diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index 70ca2e46ca93..da79f036b4ad 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -2,7 +2,6 @@ use ratatui::text::Line; use ratatui::text::Span; use std::ops::Range; use textwrap::Options; -use textwrap::wrap_algorithms::Penalties; use crate::render::line_utils::push_owned_lines; @@ -91,11 +90,7 @@ impl<'a> RtOptions<'a> { subsequent_indent: Line::default(), break_words: true, word_separator: textwrap::WordSeparator::new(), - wrap_algorithm: textwrap::WrapAlgorithm::OptimalFit(Penalties { - // ~infinite overflow penalty, we never want to overflow a line. - overflow_penalty: usize::MAX / 4, - ..Default::default() - }), + wrap_algorithm: textwrap::WrapAlgorithm::new(), word_splitter: textwrap::WordSplitter::HyphenSplitter, } } From 9bd29ec64c88b2a67fa58456ed371fd880caa977 Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Wed, 8 Oct 2025 10:22:01 -0700 Subject: [PATCH 06/38] Fix flake and status bar --- codex-rs/tui/src/bottom_pane/footer.rs | 34 +++++++++----- ...__tests__footer_mode_ctrl_c_interrupt.snap | 2 - ...poser__tests__footer_mode_ctrl_c_quit.snap | 2 - ...sts__footer_mode_ctrl_c_then_esc_hint.snap | 2 - ...tests__footer_mode_esc_hint_backtrack.snap | 2 - ...ts__footer_mode_esc_hint_from_overlay.snap | 2 - ...r_mode_overlay_then_external_esc_hint.snap | 2 - ...__tests__footer_mode_shortcut_overlay.snap | 4 -- ...ooter__tests__footer_ctrl_c_quit_idle.snap | 3 +- ...er__tests__footer_ctrl_c_quit_running.snap | 3 +- ...__footer__tests__footer_esc_hint_idle.snap | 3 +- ...footer__tests__footer_esc_hint_primed.snap | 3 +- ...tests__footer_shortcuts_shift_and_esc.snap | 5 +-- codex-rs/tui/src/chatwidget.rs | 16 ++++++- codex-rs/tui/src/chatwidget/tests.rs | 15 +++++++ codex-rs/tui/src/statusline/mod.rs | 18 +++++--- codex-rs/tui/src/statusline/state.rs | 12 ++--- flake.nix | 45 +++++++++++++------ 18 files changed, 109 insertions(+), 64 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 1ce826fa7912..f51bfabc9e5f 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -11,6 +11,8 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +const FOOTER_VISIBLE: bool = false; + #[derive(Clone, Copy, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, @@ -58,10 +60,17 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { } pub(crate) fn footer_height(props: FooterProps) -> u16 { - footer_lines(props).len() as u16 + if FOOTER_VISIBLE { + footer_lines(props).len() as u16 + } else { + 0 + } } pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { + if !FOOTER_VISIBLE { + return; + } Paragraph::new(prefix_lines( footer_lines(props), " ".repeat(FOOTER_INDENT_COLS).into(), @@ -97,16 +106,21 @@ struct ShortcutsState { } fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { - let action = if state.is_task_running { - "interrupt" + if state.is_task_running { + Line::from(vec![ + key_hint::plain(KeyCode::Esc).into(), + " or ".into(), + key_hint::ctrl(KeyCode::Char('c')).into(), + " to interrupt".into(), + ]) + .dim() } else { - "quit" - }; - Line::from(vec![ - key_hint::ctrl(KeyCode::Char('c')).into(), - format!(" again to {action}").into(), - ]) - .dim() + Line::from(vec![ + key_hint::ctrl(KeyCode::Char('c')).into(), + " again to quit".into(), + ]) + .dim() + } } fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap index a805fbf9f4e6..4fd5f431acb9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " @@ -11,4 +10,3 @@ expression: terminal.backend() " " " " " " -" ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap index 750ba101924f..4fd5f431acb9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " @@ -11,4 +10,3 @@ expression: terminal.backend() " " " " " " -" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap index 8c2d2bfd3907..4fd5f431acb9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " @@ -11,4 +10,3 @@ expression: terminal.backend() " " " " " " -" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap index 5ddf39e31551..4fd5f431acb9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " @@ -11,4 +10,3 @@ expression: terminal.backend() " " " " " " -" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap index 8c2d2bfd3907..4fd5f431acb9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " @@ -11,4 +10,3 @@ expression: terminal.backend() " " " " " " -" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap index 5ddf39e31551..4fd5f431acb9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 1497 expression: terminal.backend() --- " " @@ -11,4 +10,3 @@ expression: terminal.backend() " " " " " " -" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 3b6782d06d62..4fd5f431acb9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -10,7 +10,3 @@ expression: terminal.backend() " " " " " " -" / for commands shift + enter for newline " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap index 817adb66d958..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- -" ctrl + c again to quit " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 50bf9b622d51..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- -" ctrl + c again to interrupt " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap index 172432a38e49..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- -" esc esc to edit previous message " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap index 69d79d5327e5..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 389 expression: terminal.backend() --- -" esc again to edit previous message " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 264515a6c2bc..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,7 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands shift + enter for newline " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 95b7514c98f0..ca67c00581ad 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1115,6 +1115,19 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } => { + if self.bottom_pane.is_task_running() { + self.bottom_pane.show_ctrl_c_quit_hint(); + self.submit_op(Op::Interrupt); + } else { + self.bottom_pane.clear_ctrl_c_quit_hint(); + } + } KeyEvent { code: KeyCode::Char('c'), modifiers: crossterm::event::KeyModifiers::CONTROL, @@ -1703,7 +1716,8 @@ impl ChatWidget { pub(crate) fn add_status_output(&mut self) { let default_usage = TokenUsage::default(); let (total_usage, context_usage) = if let Some(ti) = &self.token_info { - (&ti.total_token_usage, Some(&ti.last_token_usage)) + let usage = &ti.total_token_usage; + (usage, Some(usage)) } else { (&default_usage, Some(&default_usage)) }; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4f877db94fdb..1670c96921db 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -654,6 +654,21 @@ fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); } +#[test] +fn escape_interrupts_running_task() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + + chat.on_task_started(); + assert!(chat.bottom_pane.is_task_running()); + + chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + match op_rx.try_recv() { + Ok(Op::Interrupt) => {} + other => panic!("expected Op::Interrupt, got {other:?}"), + } +} + #[test] fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/statusline/mod.rs b/codex-rs/tui/src/statusline/mod.rs index 84ae421695c4..6ec07991f0b5 100644 --- a/codex-rs/tui/src/statusline/mod.rs +++ b/codex-rs/tui/src/statusline/mod.rs @@ -57,11 +57,13 @@ const MODEL_ICONS: &[char] = &['󰚩', '󱚝', '󱚟', '󱚡', '󱚣', '󱚥']; const DEVSPACE_ICONS: &[&str] = &["󰠖 ", "󰠶 ", "󰋩 ", "󰚌 "]; const CONTEXT_PADDING: usize = 4; const DEFAULT_STATUS_MESSAGE: &str = "Waiting for input"; -pub(super) const STATUS_CAPSULE_WIDTH: usize = 32; +pub(super) const STATUS_CAPSULE_WIDTH: usize = 42; pub(super) const STATUS_CAPSULE_SPINNER_WIDTH: usize = 1; pub(super) const STATUS_CAPSULE_GAP_WIDTH: usize = 1; pub(super) const STATUS_CAPSULE_TEXT_WIDTH: usize = STATUS_CAPSULE_WIDTH - STATUS_CAPSULE_SPINNER_WIDTH - STATUS_CAPSULE_GAP_WIDTH; +const STATUS_TIMER_TEXT_WIDTH: usize = 16; +const QUEUE_PREVIEW_ENABLED: bool = false; pub(super) const MARQUEE_STEP_MS: u64 = 450; fn span(text: S, style: Style) -> Span<'static> @@ -231,6 +233,11 @@ pub(crate) fn format_elapsed_compact(elapsed_secs: u64) -> String { format!("{hours}h {minutes:02}m {seconds:02}s") } +fn format_timer_segment(elapsed_secs: u64) -> String { + let raw = format!("󰔟 {}", format_elapsed_compact(elapsed_secs)); + slice_text_segment(raw.as_str(), 0, STATUS_TIMER_TEXT_WIDTH) +} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] enum PathVariant { Full, @@ -357,7 +364,7 @@ impl<'a> RenderModel<'a> { token_variant: TokenVariant::Hidden, context_variant: ContextVariant::Bar, git_variant: GitVariant::BranchWithStatus, - include_queue_preview: true, + include_queue_preview: QUEUE_PREVIEW_ENABLED, show_interrupt_hint: show_hint, show_run_timer: has_timer, show_run_label: run_state.is_some(), @@ -683,13 +690,10 @@ impl<'a> RenderModel<'a> { if self.show_run_timer { if let Some(timer) = state.timer.as_ref() { let elapsed = timer.elapsed_at(self.now).as_secs(); - let text = format!("󰔟 {}", format_elapsed_compact(elapsed)); + let text = format_timer_segment(elapsed); segments.push(PowerlineSegment::text(PEACH, text)); } else { - segments.push(PowerlineSegment::text( - MAUVE, - format!("󰔟 {}", format_elapsed_compact(0)), - )); + segments.push(PowerlineSegment::text(MAUVE, format_timer_segment(0))); } } diff --git a/codex-rs/tui/src/statusline/state.rs b/codex-rs/tui/src/statusline/state.rs index b6c2880fe3c9..0aedfb907991 100644 --- a/codex-rs/tui/src/statusline/state.rs +++ b/codex-rs/tui/src/statusline/state.rs @@ -282,14 +282,16 @@ impl RunTimer { fn snapshot(&self, now: Instant) -> RunTimerSnapshot { let mut elapsed = self.elapsed_running; - if !self.is_paused - && let Some(last) = self.last_resume_at - { - elapsed += now.saturating_duration_since(last); + let mut last_resume_at = self.last_resume_at; + if !self.is_paused { + if let Some(last) = self.last_resume_at { + elapsed = elapsed.saturating_add(now.saturating_duration_since(last)); + } + last_resume_at = Some(now); } RunTimerSnapshot { elapsed_running: elapsed, - last_resume_at: self.last_resume_at, + last_resume_at, is_paused: self.is_paused, } } diff --git a/flake.nix b/flake.nix index 5025664fbfb7..2606b29cba91 100644 --- a/flake.nix +++ b/flake.nix @@ -28,6 +28,13 @@ rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2"; hash = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; }; + cargoLock = { + lockFile = ./codex-rs/Cargo.lock; + outputHashes = { + "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; + }; + }; + cargoVendorSha = "sha256-NP94EW+XS1PrbFfMnGOCnwoNoT1S7txJ8bDD6xRb5hw="; cargoPatchConfig = pkgs.writeText "cargo-config.toml" '' [patch."https://github.com/modelcontextprotocol/rust-sdk"] rmcp = { path = "${rmcpSrc}/crates/rmcp" } @@ -36,18 +43,11 @@ [patch.crates-io] ratatui = { path = "${ratatuiSrc}" } ''; - codex-tui = rustPlatform.buildRustPackage { - pname = "codex-tui"; + commonRustPackageArgs = { version = "unstable"; src = ./codex-rs; - cargoLock = { - lockFile = ./codex-rs/Cargo.lock; - outputHashes = { - "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; - }; - }; - cargoSha256 = "sha256-NP94EW+XS1PrbFfMnGOCnwoNoT1S7txJ8bDD6xRb5hw="; - cargoBuildFlags = [ "--package" "codex-tui" "--bin" "codex-tui" ]; + inherit cargoLock; + cargoSha256 = cargoVendorSha; nativeBuildInputs = with pkgs; [ pkg-config ]; buildInputs = with pkgs; [ openssl libgit2 curl zlib ] @@ -58,6 +58,10 @@ cp ${cargoPatchConfig} "$CARGO_HOME/config.toml" ''; doCheck = false; + }; + codex-tui = rustPlatform.buildRustPackage (commonRustPackageArgs // { + pname = "codex-tui"; + cargoBuildFlags = [ "--package" "codex-tui" "--bin" "codex-tui" ]; meta = with pkgs.lib; { description = "Codex TUI built from codex-rs"; homepage = "https://github.com/sourcegraph/codex"; @@ -65,18 +69,33 @@ mainProgram = "codex-tui"; platforms = platforms.unix; }; - }; + }); + codex-cli = rustPlatform.buildRustPackage (commonRustPackageArgs // { + pname = "codex-cli"; + cargoBuildFlags = [ "--package" "codex-cli" "--bin" "codex" ]; + meta = with pkgs.lib; { + description = "Codex CLI built from codex-rs"; + homepage = "https://github.com/sourcegraph/codex"; + license = licenses.asl20; + mainProgram = "codex"; + platforms = platforms.unix; + }; + }); in { packages = { + codex-cli = codex-cli; codex-tui = codex-tui; - default = codex-tui; + default = codex-cli; }; apps = let + codexCliApp = flake-utils.lib.mkApp { drv = codex-cli; }; codexApp = flake-utils.lib.mkApp { drv = codex-tui; }; in { + codex = codexCliApp; + codex-cli = codexCliApp; codex-tui = codexApp; - default = codexApp; + default = codexCliApp; }; } ); From 7dca5942da0d93fec870e5da12c3af3113386b7d Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Wed, 8 Oct 2025 10:36:52 -0700 Subject: [PATCH 07/38] Fix build errors --- codex-rs/tui/src/diff_render.rs | 4 +++- codex-rs/tui/src/render/renderable.rs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 4c825d562d3a..ef9e73e84d88 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -13,7 +13,9 @@ use std::path::Path; use std::path::PathBuf; use crate::exec_command::relativize_to_home; -use crate::render::renderable::{ColumnRenderable, InsetRenderable, Renderable}; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::InsetRenderable; +use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; diff --git a/codex-rs/tui/src/render/renderable.rs b/codex-rs/tui/src/render/renderable.rs index c1705c3d3fed..fa2fdb6bf7f2 100644 --- a/codex-rs/tui/src/render/renderable.rs +++ b/codex-rs/tui/src/render/renderable.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use crate::render::Insets; +use crate::render::RectExt; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::text::Line; From e42a97fa5a720ac6b58af61b8a2ecf87aba233ef Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Wed, 8 Oct 2025 11:21:03 -0700 Subject: [PATCH 08/38] Fix more build errors --- codex-rs/tui/src/render/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/render/mod.rs b/codex-rs/tui/src/render/mod.rs index 12144732116f..1c25476244b0 100644 --- a/codex-rs/tui/src/render/mod.rs +++ b/codex-rs/tui/src/render/mod.rs @@ -4,6 +4,7 @@ pub mod highlight; pub mod line_utils; pub mod renderable; +#[derive(Clone, Copy, Debug, Default)] pub struct Insets { pub left: u16, pub top: u16, From 568b8db1d57418fce68b2839290fb0bfdeb05489 Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Wed, 8 Oct 2025 12:11:00 -0700 Subject: [PATCH 09/38] Fix more more build errors --- codex-rs/tui/src/diff_render.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index ef9e73e84d88..7c2890e4911d 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -13,9 +13,8 @@ use std::path::Path; use std::path::PathBuf; use crate::exec_command::relativize_to_home; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::InsetRenderable; -use crate::render::renderable::Renderable; +use crate::render::renderable::{ColumnRenderable, InsetRenderable, Renderable}; +use crate::render::Insets; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; From 3d97baa7219974e7404f63e27814cb4f4d6ecba4 Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Fri, 10 Oct 2025 09:19:22 -0700 Subject: [PATCH 10/38] Fix up mcp calls --- codex-rs/cli/src/mcp_cmd.rs | 186 +++++++++++++++--- codex-rs/cli/tests/mcp_add_remove.rs | 77 ++++++++ codex-rs/cli/tests/mcp_list.rs | 30 ++- codex-rs/core/src/codex.rs | 11 +- codex-rs/core/src/config.rs | 26 ++- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/mcp_registry.rs | 93 +++++++++ codex-rs/otel/src/otel_event_manager.rs | 6 +- codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app_event.rs | 4 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 83 +++++++- codex-rs/tui/src/chatwidget.rs | 100 +++++++++- ...atwidget__tests__approval_modal_patch.snap | 4 +- ...chatwidget__tests__chat_small_idle_h1.snap | 1 - ...chatwidget__tests__chat_small_idle_h2.snap | 1 - ...chatwidget__tests__chat_small_idle_h3.snap | 1 - ...exec_and_status_layout_vt100_snapshot.snap | 1 - codex-rs/tui/src/chatwidget/tests.rs | 2 +- codex-rs/tui/src/diff_render.rs | 4 +- codex-rs/tui/src/history_cell.rs | 61 +++++- codex-rs/tui/src/slash_command.rs | 10 + docs/config.md | 13 ++ 22 files changed, 656 insertions(+), 62 deletions(-) create mode 100644 codex-rs/core/src/mcp_registry.rs diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 0cf3c0e228dd..03d47dcb4e38 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashSet; use anyhow::Context; use anyhow::Result; @@ -13,6 +14,7 @@ use codex_core::config::load_global_mcp_servers; use codex_core::config::write_global_mcp_servers; use codex_core::config_types::McpServerConfig; use codex_core::config_types::McpServerTransportConfig; +use codex_core::mcp_registry::McpRegistry; use codex_rmcp_client::delete_oauth_tokens; use codex_rmcp_client::perform_oauth_login; @@ -54,6 +56,12 @@ pub enum McpSubcommand { /// [experimental] Remove stored OAuth credentials for a server. /// Requires experimental_use_rmcp_client = true in config.toml. Logout(LogoutArgs), + + /// Enable a configured MCP server without editing config.toml. + Enable(EnableArgs), + + /// Disable a configured MCP server without editing config.toml. + Disable(DisableArgs), } #[derive(Debug, clap::Parser)] @@ -153,6 +161,18 @@ pub struct LogoutArgs { pub name: String, } +#[derive(Debug, clap::Parser)] +pub struct EnableArgs { + /// Name of the MCP server to enable. + pub name: String, +} + +#[derive(Debug, clap::Parser)] +pub struct DisableArgs { + /// Name of the MCP server to disable. + pub name: String, +} + impl McpCli { pub async fn run(self) -> Result<()> { let McpCli { @@ -179,6 +199,12 @@ impl McpCli { McpSubcommand::Logout(args) => { run_logout(&config_overrides, args).await?; } + McpSubcommand::Enable(args) => { + run_enable(&config_overrides, args).await?; + } + McpSubcommand::Disable(args) => { + run_disable(&config_overrides, args).await?; + } } Ok(()) @@ -244,6 +270,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; println!("Added global MCP server '{name}'."); + println!("Use 'codex mcp enable {name}' to activate it when you're ready."); Ok(()) } @@ -265,6 +292,14 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr if removed { write_global_mcp_servers(&codex_home, &servers) .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; + let mut registry = McpRegistry::load(&codex_home).with_context(|| { + format!("failed to load MCP registry from {}", codex_home.display()) + })?; + if registry.set_enabled(&name, false) { + registry.save(&codex_home).with_context(|| { + format!("failed to persist MCP registry to {}", codex_home.display()) + })?; + } } if removed { @@ -290,7 +325,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) let LoginArgs { name } = login_args; - let Some(server) = config.mcp_servers.get(&name) else { + let Some(server) = config.available_mcp_servers.get(&name) else { bail!("No MCP server named '{name}' found."); }; @@ -313,7 +348,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr let LogoutArgs { name } = logout_args; let server = config - .mcp_servers + .available_mcp_servers .get(&name) .ok_or_else(|| anyhow!("No MCP server named '{name}' found in configuration."))?; @@ -337,13 +372,15 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .await .context("failed to load configuration")?; - let mut entries: Vec<_> = config.mcp_servers.iter().collect(); + let mut entries: Vec<_> = config.available_mcp_servers.iter().collect(); entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + let enabled_names: HashSet<&str> = config.mcp_servers.keys().map(String::as_str).collect(); if list_args.json { let json_entries: Vec<_> = entries .into_iter() .map(|(name, cfg)| { + let enabled = enabled_names.contains(name.as_str()); let transport = match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ "type": "stdio", @@ -365,6 +402,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> serde_json::json!({ "name": name, + "enabled": enabled, "transport": transport, "startup_timeout_sec": cfg .startup_timeout_sec @@ -385,10 +423,16 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> return Ok(()); } - let mut stdio_rows: Vec<[String; 4]> = Vec::new(); - let mut http_rows: Vec<[String; 3]> = Vec::new(); + let mut stdio_rows: Vec<[String; 5]> = Vec::new(); + let mut http_rows: Vec<[String; 4]> = Vec::new(); for (name, cfg) in entries { + let status = if enabled_names.contains(name.as_str()) { + "yes" + } else { + "no" + } + .to_string(); match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => { let args_display = if args.is_empty() { @@ -409,7 +453,13 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .join(", ") } }; - stdio_rows.push([name.clone(), command.clone(), args_display, env_display]); + stdio_rows.push([ + name.clone(), + status, + command.clone(), + args_display, + env_display, + ]); } McpServerTransportConfig::StreamableHttp { url, @@ -417,6 +467,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } => { http_rows.push([ name.clone(), + status, url.clone(), bearer_token_env_var.clone().unwrap_or("-".to_string()), ]); @@ -425,7 +476,13 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } if !stdio_rows.is_empty() { - let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()]; + let mut widths = [ + "Name".len(), + "Enabled".len(), + "Command".len(), + "Args".len(), + "Env".len(), + ]; for row in &stdio_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -433,28 +490,32 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{: } if !http_rows.is_empty() { - let mut widths = ["Name".len(), "Url".len(), "Bearer Token Env Var".len()]; + let mut widths = [ + "Name".len(), + "Enabled".len(), + "Url".len(), + "Bearer Token Env Var".len(), + ]; for row in &http_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -472,24 +538,28 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{: Re .await .context("failed to load configuration")?; - let Some(server) = config.mcp_servers.get(&get_args.name) else { + let Some(server) = config.available_mcp_servers.get(&get_args.name) else { bail!("No MCP server named '{name}' found.", name = get_args.name); }; + let enabled = config.mcp_servers.contains_key(&get_args.name); if get_args.json { let transport = match &server.transport { @@ -526,6 +597,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re }; let output = serde_json::to_string_pretty(&serde_json::json!({ "name": get_args.name, + "enabled": enabled, "transport": transport, "startup_timeout_sec": server .startup_timeout_sec @@ -539,6 +611,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re } println!("{}", get_args.name); + println!(" enabled: {}", if enabled { "yes" } else { "no" }); match &server.transport { McpServerTransportConfig::Stdio { command, args, env } => { println!(" transport: stdio"); @@ -581,6 +654,73 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re println!(" tool_timeout_sec: {}", timeout.as_secs_f64()); } println!(" remove: codex mcp remove {}", get_args.name); + if enabled { + println!(" disable: codex mcp disable {}", get_args.name); + } else { + println!(" enable: codex mcp enable {}", get_args.name); + } + + Ok(()) +} + +async fn run_enable(config_overrides: &CliConfigOverrides, enable_args: EnableArgs) -> Result<()> { + let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .await + .context("failed to load configuration")?; + + let EnableArgs { name } = enable_args; + validate_server_name(&name)?; + + if !config.available_mcp_servers.contains_key(&name) { + bail!("No MCP server named '{name}' found."); + } + + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let mut registry = McpRegistry::load(&codex_home) + .with_context(|| format!("failed to load MCP registry from {}", codex_home.display()))?; + + if registry.set_enabled(&name, true) { + registry.save(&codex_home).with_context(|| { + format!("failed to persist MCP registry to {}", codex_home.display()) + })?; + println!("Enabled MCP server '{name}'. Start a new session to connect."); + } else { + println!("MCP server '{name}' is already enabled."); + } + + Ok(()) +} + +async fn run_disable( + config_overrides: &CliConfigOverrides, + disable_args: DisableArgs, +) -> Result<()> { + let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) + .await + .context("failed to load configuration")?; + + let DisableArgs { name } = disable_args; + validate_server_name(&name)?; + + if !config.available_mcp_servers.contains_key(&name) && !config.mcp_servers.contains_key(&name) + { + bail!("No MCP server named '{name}' found."); + } + + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let mut registry = McpRegistry::load(&codex_home) + .with_context(|| format!("failed to load MCP registry from {}", codex_home.display()))?; + + if registry.set_enabled(&name, false) { + registry.save(&codex_home).with_context(|| { + format!("failed to persist MCP registry to {}", codex_home.display()) + })?; + println!("Disabled MCP server '{name}'."); + } else { + println!("MCP server '{name}' is already disabled."); + } Ok(()) } diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 9ccb9f73d0e1..97d28a6c06da 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -3,16 +3,32 @@ use std::path::Path; use anyhow::Result; use codex_core::config::load_global_mcp_servers; use codex_core::config_types::McpServerTransportConfig; +use codex_core::mcp_registry::McpRegistry; use predicates::str::contains; use pretty_assertions::assert_eq; +use serde_json::Value; use tempfile::TempDir; fn codex_command(codex_home: &Path) -> Result { let mut cmd = assert_cmd::Command::cargo_bin("codex")?; cmd.env("CODEX_HOME", codex_home); + cmd.env("CODEX_STATE_HOME", codex_home.join("state")); Ok(cmd) } +fn set_state_home>(path: P) { + // Tests run in a controlled environment; setting env vars requires unsafe on this target. + unsafe { + std::env::set_var("CODEX_STATE_HOME", path.as_ref()); + } +} + +fn clear_state_home() { + unsafe { + std::env::remove_var("CODEX_STATE_HOME"); + } +} + #[tokio::test] async fn add_and_remove_server_updates_global_config() -> Result<()> { let codex_home = TempDir::new()?; @@ -36,6 +52,11 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { other => panic!("unexpected transport: {other:?}"), } + set_state_home(codex_home.path().join("state")); + let registry = McpRegistry::load(codex_home.path())?; + assert!(!registry.enabled().contains("docs")); + clear_state_home(); + let mut remove_cmd = codex_command(codex_home.path())?; remove_cmd .args(["mcp", "remove", "docs"]) @@ -46,6 +67,11 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { let servers = load_global_mcp_servers(codex_home.path()).await?; assert!(servers.is_empty()); + set_state_home(codex_home.path().join("state")); + let registry = McpRegistry::load(codex_home.path())?; + assert!(!registry.enabled().contains("docs")); + clear_state_home(); + let mut remove_again_cmd = codex_command(codex_home.path())?; remove_again_cmd .args(["mcp", "remove", "docs"]) @@ -206,3 +232,54 @@ async fn add_cant_add_command_and_url() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn enable_disable_toggles_registry() -> Result<()> { + let codex_home = TempDir::new()?; + + codex_command(codex_home.path())? + .args(["mcp", "add", "docs", "--", "printf", "hello"]) + .assert() + .success(); + + let mut enable_cmd = codex_command(codex_home.path())?; + enable_cmd + .args(["mcp", "enable", "docs"]) + .assert() + .success() + .stdout(contains("Enabled MCP server 'docs'.")); + + set_state_home(codex_home.path().join("state")); + let registry = McpRegistry::load(codex_home.path())?; + assert!(registry.enabled().contains("docs")); + + let mut list_cmd = codex_command(codex_home.path())?; + let list_output = list_cmd.args(["mcp", "list", "--json"]).output()?; + assert!(list_output.status.success()); + let json: Value = serde_json::from_slice(&list_output.stdout)?; + let arr = json.as_array().expect("list output should be an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["enabled"], Value::Bool(true)); + + let mut disable_cmd = codex_command(codex_home.path())?; + disable_cmd + .args(["mcp", "disable", "docs"]) + .assert() + .success() + .stdout(contains("Disabled MCP server 'docs'.")); + + set_state_home(codex_home.path().join("state")); + let registry = McpRegistry::load(codex_home.path())?; + assert!(!registry.enabled().contains("docs")); + clear_state_home(); + + let mut list_cmd = codex_command(codex_home.path())?; + let list_output = list_cmd.args(["mcp", "list", "--json"]).output()?; + assert!(list_output.status.success()); + let json: Value = serde_json::from_slice(&list_output.stdout)?; + let arr = json.as_array().expect("list output should be an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["enabled"], Value::Bool(false)); + + Ok(()) +} diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 6c83de19fa31..ddf879773d18 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -1,7 +1,6 @@ use std::path::Path; use anyhow::Result; -use predicates::str::contains; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; use serde_json::json; @@ -10,6 +9,7 @@ use tempfile::TempDir; fn codex_command(codex_home: &Path) -> Result { let mut cmd = assert_cmd::Command::cargo_bin("codex")?; cmd.env("CODEX_HOME", codex_home); + cmd.env("CODEX_STATE_HOME", codex_home.join("state")); Ok(cmd) } @@ -50,6 +50,7 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(list_output.status.success()); let stdout = String::from_utf8(list_output.stdout)?; assert!(stdout.contains("Name")); + assert!(stdout.contains("Enabled")); assert!(stdout.contains("docs")); assert!(stdout.contains("docs-server")); assert!(stdout.contains("TOKEN=secret")); @@ -64,6 +65,7 @@ fn list_and_get_render_expected_output() -> Result<()> { json!([ { "name": "docs", + "enabled": false, "transport": { "type": "stdio", "command": "docs-server", @@ -94,11 +96,29 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("remove: codex mcp remove docs")); let mut get_json_cmd = codex_command(codex_home.path())?; - get_json_cmd + let get_json_output = get_json_cmd .args(["mcp", "get", "docs", "--json"]) - .assert() - .success() - .stdout(contains("\"name\": \"docs\"")); + .output()?; + assert!(get_json_output.status.success()); + let stdout = String::from_utf8(get_json_output.stdout)?; + let parsed: JsonValue = serde_json::from_str(&stdout)?; + assert_eq!( + parsed, + json!({ + "name": "docs", + "enabled": false, + "transport": { + "type": "stdio", + "command": "docs-server", + "args": ["--port", "4000"], + "env": { + "TOKEN": "secret" + } + }, + "startup_timeout_sec": null, + "tool_timeout_sec": null + }) + ); Ok(()) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 356e25ed3a6a..e9ed3f54f129 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -417,6 +417,14 @@ impl Session { terminal::user_agent(), ); + let enabled_mcp_servers: Vec<&str> = + config.mcp_servers.keys().map(String::as_str).collect(); + let available_mcp_servers: Vec<&str> = config + .available_mcp_servers + .keys() + .map(String::as_str) + .collect(); + otel_event_manager.conversation_starts( config.model_provider.name.as_str(), config.model_reasoning_effort, @@ -426,7 +434,8 @@ impl Session { config.model_auto_compact_token_limit, config.approval_policy, config.sandbox_policy.clone(), - config.mcp_servers.keys().map(String::as_str).collect(), + enabled_mcp_servers, + available_mcp_servers, config.active_profile.clone(), ); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index d0437163fc2a..1733ab7a98b7 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -18,6 +18,7 @@ use crate::config_types::ShellEnvironmentPolicyToml; use crate::config_types::Tui; use crate::config_types::UriBasedFileOpener; use crate::git_info::resolve_root_git_project_for_trust; +use crate::mcp_registry::McpRegistry; use crate::model_family::ModelFamily; use crate::model_family::derive_default_model_family; use crate::model_family::find_family_for_model; @@ -145,6 +146,9 @@ pub struct Config { /// Definition for MCP servers that Codex can reach out to for tool calls. pub mcp_servers: HashMap, + /// Full set of MCP servers discovered in configuration, regardless of enablement state. + pub available_mcp_servers: HashMap, + /// Preferred store for MCP OAuth credentials. /// keyring: Use an OS-specific keyring service. /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. @@ -1046,6 +1050,21 @@ impl Config { let history = cfg.history.unwrap_or_default(); + let mcp_registry = match McpRegistry::load(&codex_home) { + Ok(registry) => registry, + Err(err) => { + tracing::warn!("Failed to load MCP registry: {err}"); + McpRegistry::default() + } + }; + let available_mcp_servers = cfg.mcp_servers.clone(); + let mut enabled_mcp_servers = HashMap::new(); + for (name, server) in cfg.mcp_servers.iter() { + if mcp_registry.is_enabled(name) { + enabled_mcp_servers.insert(name.clone(), server.clone()); + } + } + let tools_web_search_request = override_tools_web_search_request .or(cfg.tools.as_ref().and_then(|t| t.web_search)) .unwrap_or(false); @@ -1119,7 +1138,8 @@ impl Config { notify: cfg.notify, user_instructions, base_instructions, - mcp_servers: cfg.mcp_servers, + mcp_servers: enabled_mcp_servers, + available_mcp_servers, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(), @@ -2055,6 +2075,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), + available_mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, @@ -2118,6 +2139,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), + available_mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, @@ -2196,6 +2218,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), + available_mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, @@ -2260,6 +2283,7 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), + available_mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 08baa2bdc6da..cebd9f6af315 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -33,6 +33,7 @@ mod flags; pub mod git_info; pub mod landlock; mod mcp_connection_manager; +pub mod mcp_registry; mod mcp_tool_call; mod message_history; mod model_provider_info; diff --git a/codex-rs/core/src/mcp_registry.rs b/codex-rs/core/src/mcp_registry.rs new file mode 100644 index 000000000000..62224ab60011 --- /dev/null +++ b/codex-rs/core/src/mcp_registry.rs @@ -0,0 +1,93 @@ +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; + +use serde::Deserialize; +use serde::Serialize; +use tracing::warn; + +/// File name for persisted MCP registry state. +const MCP_REGISTRY_FILE: &str = "mcp_registry.json"; + +/// Environment variable that overrides the directory used to persist MCP state. +const CODEX_STATE_HOME_ENV: &str = "CODEX_STATE_HOME"; + +/// Registry tracking user-managed MCP enablement state. +#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct McpRegistry { + enabled: BTreeSet, +} + +impl McpRegistry { + /// Load the MCP registry from disk. When the file is missing or cannot be + /// parsed, return an empty registry. + pub fn load(codex_home: &Path) -> io::Result { + let path = registry_path(codex_home)?; + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => { + return Ok(Self::default()); + } + Err(err) => { + return Err(err); + } + }; + + match serde_json::from_str::(&contents) { + Ok(registry) => Ok(registry), + Err(err) => { + warn!("Failed to parse MCP registry at {}: {err}", path.display()); + Ok(Self::default()) + } + } + } + + /// Persist the registry to disk, atomically replacing any existing file. + pub fn save(&self, codex_home: &Path) -> io::Result<()> { + let path = registry_path(codex_home)?; + let parent = path + .parent() + .ok_or_else(|| io::Error::other("missing parent dir"))?; + fs::create_dir_all(parent)?; + + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + serde_json::to_writer_pretty(tmp.as_file_mut(), self)?; + tmp.persist(path).map_err(|err| err.error).map(|_| ()) + } + + /// Return the names of all enabled servers. + pub fn enabled(&self) -> &BTreeSet { + &self.enabled + } + + /// Enable or disable the supplied server. Returns `true` when the registry + /// was changed. + pub fn set_enabled(&mut self, name: &str, enable: bool) -> bool { + if enable { + self.enabled.insert(name.to_string()) + } else { + self.enabled.remove(name) + } + } + + /// Returns `true` when the supplied server name is enabled. + pub fn is_enabled(&self, name: &str) -> bool { + self.enabled.contains(name) + } +} + +fn registry_path(codex_home: &Path) -> io::Result { + let base = if let Ok(path) = env::var(CODEX_STATE_HOME_ENV) { + PathBuf::from(path) + } else if let Some(dir) = dirs::state_dir() { + dir.join("codex") + } else { + codex_home.join("state") + }; + + Ok(base.join(MCP_REGISTRY_FILE)) +} diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 2d6f278a79ee..4f878c85cd17 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -87,7 +87,8 @@ impl OtelEventManager { auto_compact_token_limit: Option, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, - mcp_servers: Vec<&str>, + enabled_mcp_servers: Vec<&str>, + available_mcp_servers: Vec<&str>, active_profile: Option, ) { tracing::event!( @@ -109,7 +110,8 @@ impl OtelEventManager { auto_compact_token_limit = auto_compact_token_limit, approval_policy = %approval_policy, sandbox_policy = %sandbox_policy, - mcp_servers = mcp_servers.join(", "), + enabled_mcp_servers = enabled_mcp_servers.join(", "), + available_mcp_servers = available_mcp_servers.join(", "), active_profile = active_profile, ) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5b23ffd43c5e..43c76141f890 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -232,6 +232,9 @@ impl App { self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); } + AppEvent::UpdateConfig(config) => { + self.config = config; + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 2a5e042426df..4a432a0a8da0 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use codex_common::model_presets::ModelPreset; +use codex_core::config::Config; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_file_search::FileMatch; @@ -24,6 +25,9 @@ pub(crate) enum AppEvent { /// Request to exit the application gracefully. ExitRequest, + /// Replace the in-memory config used for future sessions. + UpdateConfig(Config), + /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. CodexOp(codex_core::protocol::Op), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7590b55a5c8a..2966cd5739f2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -66,6 +66,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; pub enum InputResult { Submitted(String), Command(SlashCommand), + CommandWithArgs(SlashCommand, String), None, } @@ -491,12 +492,18 @@ impl ChatComposer { // If the current line starts with a custom prompt name and includes // positional args for a numeric-style template, expand and submit // immediately regardless of the popup selection. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, _rest)) = parse_slash_name(first_line) + let first_line = self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .to_string(); + if let Some((name, _rest)) = parse_slash_name(first_line.as_str()) && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) && let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) + expand_if_numeric_with_positional_args(prompt, first_line.as_str()) { self.textarea.set_text(""); return (InputResult::Submitted(expanded), true); @@ -512,7 +519,7 @@ impl ChatComposer { if let Some(prompt) = popup.prompt(idx) { match prompt_selection_action( prompt, - first_line, + &first_line, PromptSelectionMode::Submit, ) { PromptSelectionAction::Submit { text } => { @@ -899,14 +906,26 @@ impl ChatComposer { // the '/name' token and our caret-based heuristic hides the popup, // but Enter should still dispatch the command rather than submit // literal text. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) - && rest.is_empty() + let first_line = self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .to_string(); + if let Some((name, rest)) = parse_slash_name(first_line.as_str()) && let Some((_n, cmd)) = built_in_slash_commands() .into_iter() .find(|(n, _)| *n == name) { self.textarea.set_text(""); + let trimmed = rest.trim(); + if !trimmed.is_empty() { + return (InputResult::CommandWithArgs(cmd, trimmed.to_string()), true); + } + if cmd.expects_argument() { + return (InputResult::CommandWithArgs(cmd, String::new()), true); + } return (InputResult::Command(cmd), true); } // If we're in a paste-like burst capture, treat Enter as part of the burst @@ -918,8 +937,8 @@ impl ChatComposer { .text() .lines() .next() - .unwrap_or("") - .starts_with('/'); + .map(|line| !line.is_empty() && line.starts_with('/')) + .unwrap_or(false); if self.paste_burst.is_active() && !in_slash_context { let now = Instant::now(); if self.paste_burst.append_newline_if_active(now) { @@ -2213,6 +2232,13 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "init"); } + InputResult::CommandWithArgs(cmd, args) => { + panic!( + "expected bare command dispatch for '/init', got {}/{}", + cmd.command(), + args + ); + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -2286,6 +2312,11 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), + InputResult::CommandWithArgs(cmd, args) => panic!( + "expected bare command dispatch for '/diff', got {}/{}", + cmd.command(), + args + ), InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -2294,6 +2325,35 @@ mod tests { assert!(composer.textarea.is_empty()); } + #[test] + fn slash_enable_returns_command_with_args() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike( + &mut composer, + &['/', 'e', 'n', 'a', 'b', 'l', 'e', ' ', 'd', 'o', 'c', 's'], + ); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match result { + InputResult::CommandWithArgs(cmd, args) => { + assert_eq!(cmd.command(), "enable"); + assert_eq!(args, "docs"); + } + other => panic!("expected CommandWithArgs for '/enable docs', got {other:?}"), + } + } + #[test] fn slash_mention_dispatches_command_and_inserts_at() { use crossterm::event::KeyCode; @@ -2319,6 +2379,11 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "mention"); } + InputResult::CommandWithArgs(cmd, args) => panic!( + "expected bare command dispatch for '/mention', got {}/{}", + cmd.command(), + args + ), InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ca67c00581ad..906738031210 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -117,6 +117,7 @@ use codex_common::model_presets::ModelPreset; use codex_common::model_presets::builtin_model_presets; use codex_core::AuthManager; use codex_core::ConversationManager; +use codex_core::mcp_registry::McpRegistry; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; @@ -1184,7 +1185,10 @@ impl ChatWidget { } } InputResult::Command(cmd) => { - self.dispatch_command(cmd); + self.dispatch_command(cmd, None); + } + InputResult::CommandWithArgs(cmd, args) => { + self.dispatch_command(cmd, Some(args)); } InputResult::None => {} } @@ -1207,7 +1211,7 @@ impl ChatWidget { self.request_redraw(); } - fn dispatch_command(&mut self, cmd: SlashCommand) { + fn dispatch_command(&mut self, cmd: SlashCommand, args: Option) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", @@ -1276,6 +1280,12 @@ impl ChatWidget { SlashCommand::Mcp => { self.add_mcp_output(); } + SlashCommand::Enable => { + self.handle_mcp_toggle(args, true); + } + SlashCommand::Disable => { + self.handle_mcp_toggle(args, false); + } #[cfg(debug_assertions)] SlashCommand::TestApproval => { use codex_core::protocol::EventMsg; @@ -1317,6 +1327,85 @@ impl ChatWidget { } } + fn handle_mcp_toggle(&mut self, args: Option, enable: bool) { + let action = if enable { "enable" } else { "disable" }; + let Some(raw_args) = args.map(|s| s.trim().to_string()) else { + let message = format!("Usage: /{action} "); + self.add_error_message(message); + self.request_redraw(); + return; + }; + + let server_name = raw_args.split_whitespace().next().unwrap_or(""); + if server_name.is_empty() { + let message = format!("Usage: /{action} "); + self.add_error_message(message); + self.request_redraw(); + return; + } + + if !self.config.available_mcp_servers.contains_key(server_name) { + let message = format!("No MCP server named '{server_name}' found."); + self.add_error_message(message); + self.request_redraw(); + return; + } + + let mut registry = match McpRegistry::load(&self.config.codex_home) { + Ok(registry) => registry, + Err(err) => { + let message = format!("Failed to load MCP registry: {err}"); + self.add_error_message(message); + self.request_redraw(); + return; + } + }; + + let changed = registry.set_enabled(server_name, enable); + + if changed + && let Err(err) = registry.save(&self.config.codex_home) { + let message = format!("Failed to update MCP registry: {err}"); + self.add_error_message(message); + self.request_redraw(); + return; + } + + self.sync_enabled_mcp_servers_from_registry(®istry); + + let mut hint = None; + let message = if enable { + hint = Some( + "Run /mcp to review available tools, then /new to start a session with them." + .to_string(), + ); + if changed { + format!("Enabled MCP server '{server_name}'.") + } else { + format!("MCP server '{server_name}' is already enabled.") + } + } else if changed { + format!("Disabled MCP server '{server_name}'.") + } else { + format!("MCP server '{server_name}' is already disabled.") + }; + + self.add_info_message(message, hint); + self.request_redraw(); + } + + fn sync_enabled_mcp_servers_from_registry(&mut self, registry: &McpRegistry) { + let mut enabled = HashMap::new(); + for (name, server) in &self.config.available_mcp_servers { + if registry.is_enabled(name) { + enabled.insert(name.clone(), server.clone()); + } + } + self.config.mcp_servers = enabled; + self.app_event_tx + .send(AppEvent::UpdateConfig(self.config.clone())); + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -1983,8 +2072,13 @@ impl ChatWidget { } pub(crate) fn add_mcp_output(&mut self) { - if self.config.mcp_servers.is_empty() { + if self.config.available_mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); + } else if self.config.mcp_servers.is_empty() { + self.add_to_history(history_cell::new_mcp_tools_output( + &self.config, + HashMap::new(), + )); } else { self.submit_op(Op::ListMcpTools); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index ab88ffaf7364..eebad2e13a34 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -6,8 +6,8 @@ expression: terminal.backend().vt100().screen().contents() README.md (+2 -0) - 1 +hello - 2 +world + 1 +hello + 2 +world The model wants to apply changes diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap index cf613cc0f99d..b51889408adf 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1479 expression: terminal.backend() --- " ◦  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap index a2663e27cb3d..c78158ee60d2 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1479 expression: terminal.backend() --- "› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index 189a72db2ecb..2284f3df8722 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1479 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 374a5a1ce538..6c7fb724f8d1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 2188 expression: term.backend().vt100().screen().contents() --- • I’m going to search the repo for where “Change Approved” is rendered to update diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index bedc507772fa..4b348d40f5d7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1142,7 +1142,7 @@ fn disabled_slash_command_while_task_running_snapshot() { chat.bottom_pane.set_task_running(true); // Dispatch a command that is unavailable while a task runs (e.g., /model) - chat.dispatch_command(SlashCommand::Model); + chat.dispatch_command(SlashCommand::Model, None); // Drain history and snapshot the rendered error line(s) let cells = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 7c2890e4911d..6a14f9ac561f 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -13,8 +13,10 @@ use std::path::Path; use std::path::PathBuf; use crate::exec_command::relativize_to_home; -use crate::render::renderable::{ColumnRenderable, InsetRenderable, Renderable}; use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::InsetRenderable; +use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 7c8bff7ce5ea..195db41997a2 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -838,7 +838,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { Line::from(vec![ " See the ".into(), "\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), - " to configure them.".into(), + " to configure them, then enable servers with /enable .".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), ]; @@ -858,13 +858,23 @@ pub(crate) fn new_mcp_tools_output( "".into(), ]; - if tools.is_empty() { - lines.push(" • No MCP tools available.".italic().into()); + if config.available_mcp_servers.is_empty() { + lines.push(" • No MCP servers configured.".italic().into()); lines.push("".into()); return PlainHistoryCell { lines }; } - for (server, cfg) in config.mcp_servers.iter() { + if config.mcp_servers.is_empty() { + lines.push( + " • All configured MCP servers are disabled. Use /enable to activate one." + .italic() + .into(), + ); + lines.push("".into()); + } + + for (server, cfg) in config.available_mcp_servers.iter() { + let enabled = config.mcp_servers.contains_key(server); let prefix = format!("{server}__"); let mut names: Vec = tools .keys() @@ -875,6 +885,13 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Server: ".into(), server.clone().into()].into()); + let status_span = if enabled { + "enabled".green() + } else { + "disabled".red() + }; + lines.push(vec![" • Status: ".into(), status_span].into()); + match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => { let args_suffix = if args.is_empty() { @@ -883,7 +900,12 @@ pub(crate) fn new_mcp_tools_output( format!(" {}", args.join(" ")) }; let cmd_display = format!("{command}{args_suffix}"); - lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); + let cmd_span = if enabled { + cmd_display.into() + } else { + cmd_display.dim() + }; + lines.push(vec![" • Command: ".into(), cmd_span].into()); if let Some(env) = env.as_ref() && !env.is_empty() @@ -891,18 +913,37 @@ pub(crate) fn new_mcp_tools_output( let mut env_pairs: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect(); env_pairs.sort(); - lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); + let env_span = if enabled { + env_pairs.join(" ").into() + } else { + env_pairs.join(" ").dim() + }; + lines.push(vec![" • Env: ".into(), env_span].into()); } } McpServerTransportConfig::StreamableHttp { url, .. } => { - lines.push(vec![" • URL: ".into(), url.clone().into()].into()); + let url_span = if enabled { + url.clone().into() + } else { + url.clone().dim() + }; + lines.push(vec![" • URL: ".into(), url_span].into()); } } - if names.is_empty() { - lines.push(" • Tools: (none)".into()); + if enabled { + if names.is_empty() { + lines.push(" • Tools: (none)".dim().italic().into()); + } else { + lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); + } } else { - lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); + lines.push( + " • Tools: enable this server to load tool definitions." + .dim() + .italic() + .into(), + ); } lines.push(Line::from("")); } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 14604a736d7f..4774bc7aa6cb 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -23,6 +23,8 @@ pub enum SlashCommand { Mention, Status, Mcp, + Enable, + Disable, Logout, Quit, #[cfg(debug_assertions)] @@ -45,6 +47,8 @@ impl SlashCommand { SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", + SlashCommand::Enable => "enable a configured MCP server", + SlashCommand::Disable => "disable a configured MCP server", SlashCommand::Logout => "log out of Codex", #[cfg(debug_assertions)] SlashCommand::TestApproval => "test approval request", @@ -72,12 +76,18 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Status | SlashCommand::Mcp + | SlashCommand::Enable + | SlashCommand::Disable | SlashCommand::Quit => true, #[cfg(debug_assertions)] SlashCommand::TestApproval => true, } } + + pub fn expects_argument(self) -> bool { + matches!(self, SlashCommand::Enable | SlashCommand::Disable) + } } /// Return all built-in commands in a Vec paired with their command string. diff --git a/docs/config.md b/docs/config.md index aa45acc8dd09..77aef466fe09 100644 --- a/docs/config.md +++ b/docs/config.md @@ -421,6 +421,10 @@ codex mcp get docs --json # Remove a server codex mcp remove docs +# Enable or disable a server without editing config.toml +codex mcp enable docs +codex mcp disable docs + # Log in to a streamable HTTP server that supports oauth codex mcp login SERVER_NAME @@ -428,6 +432,15 @@ codex mcp login SERVER_NAME codex mcp logout SERVER_NAME ``` +#### Enabling and disabling MCP servers + +Codex discovers all MCP definitions from `config.toml`, but they remain _disabled_ until you explicitly turn them on. Enabled state is stored in a writable registry (default `~/.local/state/codex/mcp_registry.json`; override with `CODEX_STATE_HOME`). This avoids churn to Nix- or Git-managed config files while still letting you opt into servers when you are ready. + +- **CLI** – run `codex mcp enable ` to activate a server, or `codex mcp disable ` to deactivate it. `codex mcp list` and `codex mcp get` now show an `enabled` flag so you can audit the current state. +- **TUI** – inside an active session, use `/enable ` or `/disable ` (and `/mcp` to review the current status). Disabled servers are rendered in the MCP panel with a red “disabled” badge so you can tell at a glance what is ready. + +Once a server is enabled, Codex starts it automatically for new sessions (`/new`) and surfaces its tools in the prompt. + ## Examples of useful MCPs There is an ever growing list of useful MCP servers that can be helpful while you are working with Codex. From 85d68985d19b239707a18fe01fb9710c5316ee11 Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Fri, 10 Oct 2025 09:30:15 -0700 Subject: [PATCH 11/38] Revert "Fix up mcp calls" This reverts commit 3d97baa7219974e7404f63e27814cb4f4d6ecba4. --- codex-rs/cli/src/mcp_cmd.rs | 186 +++--------------- codex-rs/cli/tests/mcp_add_remove.rs | 77 -------- codex-rs/cli/tests/mcp_list.rs | 30 +-- codex-rs/core/src/codex.rs | 11 +- codex-rs/core/src/config.rs | 26 +-- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/mcp_registry.rs | 93 --------- codex-rs/otel/src/otel_event_manager.rs | 6 +- codex-rs/tui/src/app.rs | 3 - codex-rs/tui/src/app_event.rs | 4 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 83 +------- codex-rs/tui/src/chatwidget.rs | 100 +--------- ...atwidget__tests__approval_modal_patch.snap | 4 +- ...chatwidget__tests__chat_small_idle_h1.snap | 1 + ...chatwidget__tests__chat_small_idle_h2.snap | 1 + ...chatwidget__tests__chat_small_idle_h3.snap | 1 + ...exec_and_status_layout_vt100_snapshot.snap | 1 + codex-rs/tui/src/chatwidget/tests.rs | 2 +- codex-rs/tui/src/diff_render.rs | 4 +- codex-rs/tui/src/history_cell.rs | 61 +----- codex-rs/tui/src/slash_command.rs | 10 - docs/config.md | 13 -- 22 files changed, 62 insertions(+), 656 deletions(-) delete mode 100644 codex-rs/core/src/mcp_registry.rs diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 03d47dcb4e38..0cf3c0e228dd 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::collections::HashSet; use anyhow::Context; use anyhow::Result; @@ -14,7 +13,6 @@ use codex_core::config::load_global_mcp_servers; use codex_core::config::write_global_mcp_servers; use codex_core::config_types::McpServerConfig; use codex_core::config_types::McpServerTransportConfig; -use codex_core::mcp_registry::McpRegistry; use codex_rmcp_client::delete_oauth_tokens; use codex_rmcp_client::perform_oauth_login; @@ -56,12 +54,6 @@ pub enum McpSubcommand { /// [experimental] Remove stored OAuth credentials for a server. /// Requires experimental_use_rmcp_client = true in config.toml. Logout(LogoutArgs), - - /// Enable a configured MCP server without editing config.toml. - Enable(EnableArgs), - - /// Disable a configured MCP server without editing config.toml. - Disable(DisableArgs), } #[derive(Debug, clap::Parser)] @@ -161,18 +153,6 @@ pub struct LogoutArgs { pub name: String, } -#[derive(Debug, clap::Parser)] -pub struct EnableArgs { - /// Name of the MCP server to enable. - pub name: String, -} - -#[derive(Debug, clap::Parser)] -pub struct DisableArgs { - /// Name of the MCP server to disable. - pub name: String, -} - impl McpCli { pub async fn run(self) -> Result<()> { let McpCli { @@ -199,12 +179,6 @@ impl McpCli { McpSubcommand::Logout(args) => { run_logout(&config_overrides, args).await?; } - McpSubcommand::Enable(args) => { - run_enable(&config_overrides, args).await?; - } - McpSubcommand::Disable(args) => { - run_disable(&config_overrides, args).await?; - } } Ok(()) @@ -270,7 +244,6 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; println!("Added global MCP server '{name}'."); - println!("Use 'codex mcp enable {name}' to activate it when you're ready."); Ok(()) } @@ -292,14 +265,6 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr if removed { write_global_mcp_servers(&codex_home, &servers) .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; - let mut registry = McpRegistry::load(&codex_home).with_context(|| { - format!("failed to load MCP registry from {}", codex_home.display()) - })?; - if registry.set_enabled(&name, false) { - registry.save(&codex_home).with_context(|| { - format!("failed to persist MCP registry to {}", codex_home.display()) - })?; - } } if removed { @@ -325,7 +290,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) let LoginArgs { name } = login_args; - let Some(server) = config.available_mcp_servers.get(&name) else { + let Some(server) = config.mcp_servers.get(&name) else { bail!("No MCP server named '{name}' found."); }; @@ -348,7 +313,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr let LogoutArgs { name } = logout_args; let server = config - .available_mcp_servers + .mcp_servers .get(&name) .ok_or_else(|| anyhow!("No MCP server named '{name}' found in configuration."))?; @@ -372,15 +337,13 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .await .context("failed to load configuration")?; - let mut entries: Vec<_> = config.available_mcp_servers.iter().collect(); + let mut entries: Vec<_> = config.mcp_servers.iter().collect(); entries.sort_by(|(a, _), (b, _)| a.cmp(b)); - let enabled_names: HashSet<&str> = config.mcp_servers.keys().map(String::as_str).collect(); if list_args.json { let json_entries: Vec<_> = entries .into_iter() .map(|(name, cfg)| { - let enabled = enabled_names.contains(name.as_str()); let transport = match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ "type": "stdio", @@ -402,7 +365,6 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> serde_json::json!({ "name": name, - "enabled": enabled, "transport": transport, "startup_timeout_sec": cfg .startup_timeout_sec @@ -423,16 +385,10 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> return Ok(()); } - let mut stdio_rows: Vec<[String; 5]> = Vec::new(); - let mut http_rows: Vec<[String; 4]> = Vec::new(); + let mut stdio_rows: Vec<[String; 4]> = Vec::new(); + let mut http_rows: Vec<[String; 3]> = Vec::new(); for (name, cfg) in entries { - let status = if enabled_names.contains(name.as_str()) { - "yes" - } else { - "no" - } - .to_string(); match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => { let args_display = if args.is_empty() { @@ -453,13 +409,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .join(", ") } }; - stdio_rows.push([ - name.clone(), - status, - command.clone(), - args_display, - env_display, - ]); + stdio_rows.push([name.clone(), command.clone(), args_display, env_display]); } McpServerTransportConfig::StreamableHttp { url, @@ -467,7 +417,6 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } => { http_rows.push([ name.clone(), - status, url.clone(), bearer_token_env_var.clone().unwrap_or("-".to_string()), ]); @@ -476,13 +425,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } if !stdio_rows.is_empty() { - let mut widths = [ - "Name".len(), - "Enabled".len(), - "Command".len(), - "Args".len(), - "Env".len(), - ]; + let mut widths = ["Name".len(), "Command".len(), "Args".len(), "Env".len()]; for row in &stdio_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -490,32 +433,28 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{: } if !http_rows.is_empty() { - let mut widths = [ - "Name".len(), - "Enabled".len(), - "Url".len(), - "Bearer Token Env Var".len(), - ]; + let mut widths = ["Name".len(), "Url".len(), "Bearer Token Env Var".len()]; for row in &http_rows { for (i, cell) in row.iter().enumerate() { widths[i] = widths[i].max(cell.len()); @@ -538,28 +472,24 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{: Re .await .context("failed to load configuration")?; - let Some(server) = config.available_mcp_servers.get(&get_args.name) else { + let Some(server) = config.mcp_servers.get(&get_args.name) else { bail!("No MCP server named '{name}' found.", name = get_args.name); }; - let enabled = config.mcp_servers.contains_key(&get_args.name); if get_args.json { let transport = match &server.transport { @@ -597,7 +526,6 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re }; let output = serde_json::to_string_pretty(&serde_json::json!({ "name": get_args.name, - "enabled": enabled, "transport": transport, "startup_timeout_sec": server .startup_timeout_sec @@ -611,7 +539,6 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re } println!("{}", get_args.name); - println!(" enabled: {}", if enabled { "yes" } else { "no" }); match &server.transport { McpServerTransportConfig::Stdio { command, args, env } => { println!(" transport: stdio"); @@ -654,73 +581,6 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re println!(" tool_timeout_sec: {}", timeout.as_secs_f64()); } println!(" remove: codex mcp remove {}", get_args.name); - if enabled { - println!(" disable: codex mcp disable {}", get_args.name); - } else { - println!(" enable: codex mcp enable {}", get_args.name); - } - - Ok(()) -} - -async fn run_enable(config_overrides: &CliConfigOverrides, enable_args: EnableArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; - let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) - .await - .context("failed to load configuration")?; - - let EnableArgs { name } = enable_args; - validate_server_name(&name)?; - - if !config.available_mcp_servers.contains_key(&name) { - bail!("No MCP server named '{name}' found."); - } - - let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; - let mut registry = McpRegistry::load(&codex_home) - .with_context(|| format!("failed to load MCP registry from {}", codex_home.display()))?; - - if registry.set_enabled(&name, true) { - registry.save(&codex_home).with_context(|| { - format!("failed to persist MCP registry to {}", codex_home.display()) - })?; - println!("Enabled MCP server '{name}'. Start a new session to connect."); - } else { - println!("MCP server '{name}' is already enabled."); - } - - Ok(()) -} - -async fn run_disable( - config_overrides: &CliConfigOverrides, - disable_args: DisableArgs, -) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; - let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) - .await - .context("failed to load configuration")?; - - let DisableArgs { name } = disable_args; - validate_server_name(&name)?; - - if !config.available_mcp_servers.contains_key(&name) && !config.mcp_servers.contains_key(&name) - { - bail!("No MCP server named '{name}' found."); - } - - let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; - let mut registry = McpRegistry::load(&codex_home) - .with_context(|| format!("failed to load MCP registry from {}", codex_home.display()))?; - - if registry.set_enabled(&name, false) { - registry.save(&codex_home).with_context(|| { - format!("failed to persist MCP registry to {}", codex_home.display()) - })?; - println!("Disabled MCP server '{name}'."); - } else { - println!("MCP server '{name}' is already disabled."); - } Ok(()) } diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 97d28a6c06da..9ccb9f73d0e1 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -3,32 +3,16 @@ use std::path::Path; use anyhow::Result; use codex_core::config::load_global_mcp_servers; use codex_core::config_types::McpServerTransportConfig; -use codex_core::mcp_registry::McpRegistry; use predicates::str::contains; use pretty_assertions::assert_eq; -use serde_json::Value; use tempfile::TempDir; fn codex_command(codex_home: &Path) -> Result { let mut cmd = assert_cmd::Command::cargo_bin("codex")?; cmd.env("CODEX_HOME", codex_home); - cmd.env("CODEX_STATE_HOME", codex_home.join("state")); Ok(cmd) } -fn set_state_home>(path: P) { - // Tests run in a controlled environment; setting env vars requires unsafe on this target. - unsafe { - std::env::set_var("CODEX_STATE_HOME", path.as_ref()); - } -} - -fn clear_state_home() { - unsafe { - std::env::remove_var("CODEX_STATE_HOME"); - } -} - #[tokio::test] async fn add_and_remove_server_updates_global_config() -> Result<()> { let codex_home = TempDir::new()?; @@ -52,11 +36,6 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { other => panic!("unexpected transport: {other:?}"), } - set_state_home(codex_home.path().join("state")); - let registry = McpRegistry::load(codex_home.path())?; - assert!(!registry.enabled().contains("docs")); - clear_state_home(); - let mut remove_cmd = codex_command(codex_home.path())?; remove_cmd .args(["mcp", "remove", "docs"]) @@ -67,11 +46,6 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { let servers = load_global_mcp_servers(codex_home.path()).await?; assert!(servers.is_empty()); - set_state_home(codex_home.path().join("state")); - let registry = McpRegistry::load(codex_home.path())?; - assert!(!registry.enabled().contains("docs")); - clear_state_home(); - let mut remove_again_cmd = codex_command(codex_home.path())?; remove_again_cmd .args(["mcp", "remove", "docs"]) @@ -232,54 +206,3 @@ async fn add_cant_add_command_and_url() -> Result<()> { Ok(()) } - -#[tokio::test] -async fn enable_disable_toggles_registry() -> Result<()> { - let codex_home = TempDir::new()?; - - codex_command(codex_home.path())? - .args(["mcp", "add", "docs", "--", "printf", "hello"]) - .assert() - .success(); - - let mut enable_cmd = codex_command(codex_home.path())?; - enable_cmd - .args(["mcp", "enable", "docs"]) - .assert() - .success() - .stdout(contains("Enabled MCP server 'docs'.")); - - set_state_home(codex_home.path().join("state")); - let registry = McpRegistry::load(codex_home.path())?; - assert!(registry.enabled().contains("docs")); - - let mut list_cmd = codex_command(codex_home.path())?; - let list_output = list_cmd.args(["mcp", "list", "--json"]).output()?; - assert!(list_output.status.success()); - let json: Value = serde_json::from_slice(&list_output.stdout)?; - let arr = json.as_array().expect("list output should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["enabled"], Value::Bool(true)); - - let mut disable_cmd = codex_command(codex_home.path())?; - disable_cmd - .args(["mcp", "disable", "docs"]) - .assert() - .success() - .stdout(contains("Disabled MCP server 'docs'.")); - - set_state_home(codex_home.path().join("state")); - let registry = McpRegistry::load(codex_home.path())?; - assert!(!registry.enabled().contains("docs")); - clear_state_home(); - - let mut list_cmd = codex_command(codex_home.path())?; - let list_output = list_cmd.args(["mcp", "list", "--json"]).output()?; - assert!(list_output.status.success()); - let json: Value = serde_json::from_slice(&list_output.stdout)?; - let arr = json.as_array().expect("list output should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["enabled"], Value::Bool(false)); - - Ok(()) -} diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index ddf879773d18..6c83de19fa31 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -1,6 +1,7 @@ use std::path::Path; use anyhow::Result; +use predicates::str::contains; use pretty_assertions::assert_eq; use serde_json::Value as JsonValue; use serde_json::json; @@ -9,7 +10,6 @@ use tempfile::TempDir; fn codex_command(codex_home: &Path) -> Result { let mut cmd = assert_cmd::Command::cargo_bin("codex")?; cmd.env("CODEX_HOME", codex_home); - cmd.env("CODEX_STATE_HOME", codex_home.join("state")); Ok(cmd) } @@ -50,7 +50,6 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(list_output.status.success()); let stdout = String::from_utf8(list_output.stdout)?; assert!(stdout.contains("Name")); - assert!(stdout.contains("Enabled")); assert!(stdout.contains("docs")); assert!(stdout.contains("docs-server")); assert!(stdout.contains("TOKEN=secret")); @@ -65,7 +64,6 @@ fn list_and_get_render_expected_output() -> Result<()> { json!([ { "name": "docs", - "enabled": false, "transport": { "type": "stdio", "command": "docs-server", @@ -96,29 +94,11 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("remove: codex mcp remove docs")); let mut get_json_cmd = codex_command(codex_home.path())?; - let get_json_output = get_json_cmd + get_json_cmd .args(["mcp", "get", "docs", "--json"]) - .output()?; - assert!(get_json_output.status.success()); - let stdout = String::from_utf8(get_json_output.stdout)?; - let parsed: JsonValue = serde_json::from_str(&stdout)?; - assert_eq!( - parsed, - json!({ - "name": "docs", - "enabled": false, - "transport": { - "type": "stdio", - "command": "docs-server", - "args": ["--port", "4000"], - "env": { - "TOKEN": "secret" - } - }, - "startup_timeout_sec": null, - "tool_timeout_sec": null - }) - ); + .assert() + .success() + .stdout(contains("\"name\": \"docs\"")); Ok(()) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e9ed3f54f129..356e25ed3a6a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -417,14 +417,6 @@ impl Session { terminal::user_agent(), ); - let enabled_mcp_servers: Vec<&str> = - config.mcp_servers.keys().map(String::as_str).collect(); - let available_mcp_servers: Vec<&str> = config - .available_mcp_servers - .keys() - .map(String::as_str) - .collect(); - otel_event_manager.conversation_starts( config.model_provider.name.as_str(), config.model_reasoning_effort, @@ -434,8 +426,7 @@ impl Session { config.model_auto_compact_token_limit, config.approval_policy, config.sandbox_policy.clone(), - enabled_mcp_servers, - available_mcp_servers, + config.mcp_servers.keys().map(String::as_str).collect(), config.active_profile.clone(), ); diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 1733ab7a98b7..d0437163fc2a 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -18,7 +18,6 @@ use crate::config_types::ShellEnvironmentPolicyToml; use crate::config_types::Tui; use crate::config_types::UriBasedFileOpener; use crate::git_info::resolve_root_git_project_for_trust; -use crate::mcp_registry::McpRegistry; use crate::model_family::ModelFamily; use crate::model_family::derive_default_model_family; use crate::model_family::find_family_for_model; @@ -146,9 +145,6 @@ pub struct Config { /// Definition for MCP servers that Codex can reach out to for tool calls. pub mcp_servers: HashMap, - /// Full set of MCP servers discovered in configuration, regardless of enablement state. - pub available_mcp_servers: HashMap, - /// Preferred store for MCP OAuth credentials. /// keyring: Use an OS-specific keyring service. /// Credentials stored in the keyring will only be readable by Codex unless the user explicitly grants access via OS-level keyring access. @@ -1050,21 +1046,6 @@ impl Config { let history = cfg.history.unwrap_or_default(); - let mcp_registry = match McpRegistry::load(&codex_home) { - Ok(registry) => registry, - Err(err) => { - tracing::warn!("Failed to load MCP registry: {err}"); - McpRegistry::default() - } - }; - let available_mcp_servers = cfg.mcp_servers.clone(); - let mut enabled_mcp_servers = HashMap::new(); - for (name, server) in cfg.mcp_servers.iter() { - if mcp_registry.is_enabled(name) { - enabled_mcp_servers.insert(name.clone(), server.clone()); - } - } - let tools_web_search_request = override_tools_web_search_request .or(cfg.tools.as_ref().and_then(|t| t.web_search)) .unwrap_or(false); @@ -1138,8 +1119,7 @@ impl Config { notify: cfg.notify, user_instructions, base_instructions, - mcp_servers: enabled_mcp_servers, - available_mcp_servers, + mcp_servers: cfg.mcp_servers, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(), @@ -2075,7 +2055,6 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), - available_mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, @@ -2139,7 +2118,6 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), - available_mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, @@ -2218,7 +2196,6 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), - available_mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, @@ -2283,7 +2260,6 @@ model_verbosity = "high" notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), - available_mcp_servers: HashMap::new(), mcp_oauth_credentials_store_mode: Default::default(), model_providers: fixture.model_provider_map.clone(), project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index cebd9f6af315..08baa2bdc6da 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -33,7 +33,6 @@ mod flags; pub mod git_info; pub mod landlock; mod mcp_connection_manager; -pub mod mcp_registry; mod mcp_tool_call; mod message_history; mod model_provider_info; diff --git a/codex-rs/core/src/mcp_registry.rs b/codex-rs/core/src/mcp_registry.rs deleted file mode 100644 index 62224ab60011..000000000000 --- a/codex-rs/core/src/mcp_registry.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::collections::BTreeSet; -use std::env; -use std::fs; -use std::io; -use std::path::Path; -use std::path::PathBuf; - -use serde::Deserialize; -use serde::Serialize; -use tracing::warn; - -/// File name for persisted MCP registry state. -const MCP_REGISTRY_FILE: &str = "mcp_registry.json"; - -/// Environment variable that overrides the directory used to persist MCP state. -const CODEX_STATE_HOME_ENV: &str = "CODEX_STATE_HOME"; - -/// Registry tracking user-managed MCP enablement state. -#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(default)] -pub struct McpRegistry { - enabled: BTreeSet, -} - -impl McpRegistry { - /// Load the MCP registry from disk. When the file is missing or cannot be - /// parsed, return an empty registry. - pub fn load(codex_home: &Path) -> io::Result { - let path = registry_path(codex_home)?; - let contents = match fs::read_to_string(&path) { - Ok(contents) => contents, - Err(err) if err.kind() == io::ErrorKind::NotFound => { - return Ok(Self::default()); - } - Err(err) => { - return Err(err); - } - }; - - match serde_json::from_str::(&contents) { - Ok(registry) => Ok(registry), - Err(err) => { - warn!("Failed to parse MCP registry at {}: {err}", path.display()); - Ok(Self::default()) - } - } - } - - /// Persist the registry to disk, atomically replacing any existing file. - pub fn save(&self, codex_home: &Path) -> io::Result<()> { - let path = registry_path(codex_home)?; - let parent = path - .parent() - .ok_or_else(|| io::Error::other("missing parent dir"))?; - fs::create_dir_all(parent)?; - - let mut tmp = tempfile::NamedTempFile::new_in(parent)?; - serde_json::to_writer_pretty(tmp.as_file_mut(), self)?; - tmp.persist(path).map_err(|err| err.error).map(|_| ()) - } - - /// Return the names of all enabled servers. - pub fn enabled(&self) -> &BTreeSet { - &self.enabled - } - - /// Enable or disable the supplied server. Returns `true` when the registry - /// was changed. - pub fn set_enabled(&mut self, name: &str, enable: bool) -> bool { - if enable { - self.enabled.insert(name.to_string()) - } else { - self.enabled.remove(name) - } - } - - /// Returns `true` when the supplied server name is enabled. - pub fn is_enabled(&self, name: &str) -> bool { - self.enabled.contains(name) - } -} - -fn registry_path(codex_home: &Path) -> io::Result { - let base = if let Ok(path) = env::var(CODEX_STATE_HOME_ENV) { - PathBuf::from(path) - } else if let Some(dir) = dirs::state_dir() { - dir.join("codex") - } else { - codex_home.join("state") - }; - - Ok(base.join(MCP_REGISTRY_FILE)) -} diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 4f878c85cd17..2d6f278a79ee 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -87,8 +87,7 @@ impl OtelEventManager { auto_compact_token_limit: Option, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, - enabled_mcp_servers: Vec<&str>, - available_mcp_servers: Vec<&str>, + mcp_servers: Vec<&str>, active_profile: Option, ) { tracing::event!( @@ -110,8 +109,7 @@ impl OtelEventManager { auto_compact_token_limit = auto_compact_token_limit, approval_policy = %approval_policy, sandbox_policy = %sandbox_policy, - enabled_mcp_servers = enabled_mcp_servers.join(", "), - available_mcp_servers = available_mcp_servers.join(", "), + mcp_servers = mcp_servers.join(", "), active_profile = active_profile, ) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 43c76141f890..5b23ffd43c5e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -232,9 +232,6 @@ impl App { self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); } - AppEvent::UpdateConfig(config) => { - self.config = config; - } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4a432a0a8da0..2a5e042426df 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,7 +1,6 @@ use std::path::PathBuf; use codex_common::model_presets::ModelPreset; -use codex_core::config::Config; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_file_search::FileMatch; @@ -25,9 +24,6 @@ pub(crate) enum AppEvent { /// Request to exit the application gracefully. ExitRequest, - /// Replace the in-memory config used for future sessions. - UpdateConfig(Config), - /// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids /// bubbling channels through layers of widgets. CodexOp(codex_core::protocol::Op), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2966cd5739f2..7590b55a5c8a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -66,7 +66,6 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; pub enum InputResult { Submitted(String), Command(SlashCommand), - CommandWithArgs(SlashCommand, String), None, } @@ -492,18 +491,12 @@ impl ChatComposer { // If the current line starts with a custom prompt name and includes // positional args for a numeric-style template, expand and submit // immediately regardless of the popup selection. - let first_line = self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .to_string(); - if let Some((name, _rest)) = parse_slash_name(first_line.as_str()) + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, _rest)) = parse_slash_name(first_line) && let Some(prompt_name) = name.strip_prefix(&format!("{PROMPTS_CMD_PREFIX}:")) && let Some(prompt) = self.custom_prompts.iter().find(|p| p.name == prompt_name) && let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line.as_str()) + expand_if_numeric_with_positional_args(prompt, first_line) { self.textarea.set_text(""); return (InputResult::Submitted(expanded), true); @@ -519,7 +512,7 @@ impl ChatComposer { if let Some(prompt) = popup.prompt(idx) { match prompt_selection_action( prompt, - &first_line, + first_line, PromptSelectionMode::Submit, ) { PromptSelectionAction::Submit { text } => { @@ -906,26 +899,14 @@ impl ChatComposer { // the '/name' token and our caret-based heuristic hides the popup, // but Enter should still dispatch the command rather than submit // literal text. - let first_line = self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .to_string(); - if let Some((name, rest)) = parse_slash_name(first_line.as_str()) + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, rest)) = parse_slash_name(first_line) + && rest.is_empty() && let Some((_n, cmd)) = built_in_slash_commands() .into_iter() .find(|(n, _)| *n == name) { self.textarea.set_text(""); - let trimmed = rest.trim(); - if !trimmed.is_empty() { - return (InputResult::CommandWithArgs(cmd, trimmed.to_string()), true); - } - if cmd.expects_argument() { - return (InputResult::CommandWithArgs(cmd, String::new()), true); - } return (InputResult::Command(cmd), true); } // If we're in a paste-like burst capture, treat Enter as part of the burst @@ -937,8 +918,8 @@ impl ChatComposer { .text() .lines() .next() - .map(|line| !line.is_empty() && line.starts_with('/')) - .unwrap_or(false); + .unwrap_or("") + .starts_with('/'); if self.paste_burst.is_active() && !in_slash_context { let now = Instant::now(); if self.paste_burst.append_newline_if_active(now) { @@ -2232,13 +2213,6 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "init"); } - InputResult::CommandWithArgs(cmd, args) => { - panic!( - "expected bare command dispatch for '/init', got {}/{}", - cmd.command(), - args - ); - } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -2312,11 +2286,6 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), - InputResult::CommandWithArgs(cmd, args) => panic!( - "expected bare command dispatch for '/diff', got {}/{}", - cmd.command(), - args - ), InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -2325,35 +2294,6 @@ mod tests { assert!(composer.textarea.is_empty()); } - #[test] - fn slash_enable_returns_command_with_args() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - type_chars_humanlike( - &mut composer, - &['/', 'e', 'n', 'a', 'b', 'l', 'e', ' ', 'd', 'o', 'c', 's'], - ); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - match result { - InputResult::CommandWithArgs(cmd, args) => { - assert_eq!(cmd.command(), "enable"); - assert_eq!(args, "docs"); - } - other => panic!("expected CommandWithArgs for '/enable docs', got {other:?}"), - } - } - #[test] fn slash_mention_dispatches_command_and_inserts_at() { use crossterm::event::KeyCode; @@ -2379,11 +2319,6 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "mention"); } - InputResult::CommandWithArgs(cmd, args) => panic!( - "expected bare command dispatch for '/mention', got {}/{}", - cmd.command(), - args - ), InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 906738031210..ca67c00581ad 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -117,7 +117,6 @@ use codex_common::model_presets::ModelPreset; use codex_common::model_presets::builtin_model_presets; use codex_core::AuthManager; use codex_core::ConversationManager; -use codex_core::mcp_registry::McpRegistry; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; @@ -1185,10 +1184,7 @@ impl ChatWidget { } } InputResult::Command(cmd) => { - self.dispatch_command(cmd, None); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command(cmd, Some(args)); + self.dispatch_command(cmd); } InputResult::None => {} } @@ -1211,7 +1207,7 @@ impl ChatWidget { self.request_redraw(); } - fn dispatch_command(&mut self, cmd: SlashCommand, args: Option) { + fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( "'/{}' is disabled while a task is in progress.", @@ -1280,12 +1276,6 @@ impl ChatWidget { SlashCommand::Mcp => { self.add_mcp_output(); } - SlashCommand::Enable => { - self.handle_mcp_toggle(args, true); - } - SlashCommand::Disable => { - self.handle_mcp_toggle(args, false); - } #[cfg(debug_assertions)] SlashCommand::TestApproval => { use codex_core::protocol::EventMsg; @@ -1327,85 +1317,6 @@ impl ChatWidget { } } - fn handle_mcp_toggle(&mut self, args: Option, enable: bool) { - let action = if enable { "enable" } else { "disable" }; - let Some(raw_args) = args.map(|s| s.trim().to_string()) else { - let message = format!("Usage: /{action} "); - self.add_error_message(message); - self.request_redraw(); - return; - }; - - let server_name = raw_args.split_whitespace().next().unwrap_or(""); - if server_name.is_empty() { - let message = format!("Usage: /{action} "); - self.add_error_message(message); - self.request_redraw(); - return; - } - - if !self.config.available_mcp_servers.contains_key(server_name) { - let message = format!("No MCP server named '{server_name}' found."); - self.add_error_message(message); - self.request_redraw(); - return; - } - - let mut registry = match McpRegistry::load(&self.config.codex_home) { - Ok(registry) => registry, - Err(err) => { - let message = format!("Failed to load MCP registry: {err}"); - self.add_error_message(message); - self.request_redraw(); - return; - } - }; - - let changed = registry.set_enabled(server_name, enable); - - if changed - && let Err(err) = registry.save(&self.config.codex_home) { - let message = format!("Failed to update MCP registry: {err}"); - self.add_error_message(message); - self.request_redraw(); - return; - } - - self.sync_enabled_mcp_servers_from_registry(®istry); - - let mut hint = None; - let message = if enable { - hint = Some( - "Run /mcp to review available tools, then /new to start a session with them." - .to_string(), - ); - if changed { - format!("Enabled MCP server '{server_name}'.") - } else { - format!("MCP server '{server_name}' is already enabled.") - } - } else if changed { - format!("Disabled MCP server '{server_name}'.") - } else { - format!("MCP server '{server_name}' is already disabled.") - }; - - self.add_info_message(message, hint); - self.request_redraw(); - } - - fn sync_enabled_mcp_servers_from_registry(&mut self, registry: &McpRegistry) { - let mut enabled = HashMap::new(); - for (name, server) in &self.config.available_mcp_servers { - if registry.is_enabled(name) { - enabled.insert(name.clone(), server.clone()); - } - } - self.config.mcp_servers = enabled; - self.app_event_tx - .send(AppEvent::UpdateConfig(self.config.clone())); - } - pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -2072,13 +1983,8 @@ impl ChatWidget { } pub(crate) fn add_mcp_output(&mut self) { - if self.config.available_mcp_servers.is_empty() { + if self.config.mcp_servers.is_empty() { self.add_to_history(history_cell::empty_mcp_output()); - } else if self.config.mcp_servers.is_empty() { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - HashMap::new(), - )); } else { self.submit_op(Op::ListMcpTools); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index eebad2e13a34..ab88ffaf7364 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -6,8 +6,8 @@ expression: terminal.backend().vt100().screen().contents() README.md (+2 -0) - 1 +hello - 2 +world + 1 +hello + 2 +world The model wants to apply changes diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap index b51889408adf..cf613cc0f99d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h1.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1479 expression: terminal.backend() --- " ◦  tui  󱚥 gpt-5-codex  " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap index c78158ee60d2..a2663e27cb3d 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h2.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1479 expression: terminal.backend() --- "› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap index 2284f3df8722..189a72db2ecb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_idle_h3.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1479 expression: terminal.backend() --- " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 6c7fb724f8d1..374a5a1ce538 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 2188 expression: term.backend().vt100().screen().contents() --- • I’m going to search the repo for where “Change Approved” is rendered to update diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4b348d40f5d7..bedc507772fa 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1142,7 +1142,7 @@ fn disabled_slash_command_while_task_running_snapshot() { chat.bottom_pane.set_task_running(true); // Dispatch a command that is unavailable while a task runs (e.g., /model) - chat.dispatch_command(SlashCommand::Model, None); + chat.dispatch_command(SlashCommand::Model); // Drain history and snapshot the rendered error line(s) let cells = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 6a14f9ac561f..7c2890e4911d 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -13,10 +13,8 @@ use std::path::Path; use std::path::PathBuf; use crate::exec_command::relativize_to_home; +use crate::render::renderable::{ColumnRenderable, InsetRenderable, Renderable}; use crate::render::Insets; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::InsetRenderable; -use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 195db41997a2..7c8bff7ce5ea 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -838,7 +838,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { Line::from(vec![ " See the ".into(), "\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), - " to configure them, then enable servers with /enable .".into(), + " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), ]; @@ -858,23 +858,13 @@ pub(crate) fn new_mcp_tools_output( "".into(), ]; - if config.available_mcp_servers.is_empty() { - lines.push(" • No MCP servers configured.".italic().into()); + if tools.is_empty() { + lines.push(" • No MCP tools available.".italic().into()); lines.push("".into()); return PlainHistoryCell { lines }; } - if config.mcp_servers.is_empty() { - lines.push( - " • All configured MCP servers are disabled. Use /enable to activate one." - .italic() - .into(), - ); - lines.push("".into()); - } - - for (server, cfg) in config.available_mcp_servers.iter() { - let enabled = config.mcp_servers.contains_key(server); + for (server, cfg) in config.mcp_servers.iter() { let prefix = format!("{server}__"); let mut names: Vec = tools .keys() @@ -885,13 +875,6 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Server: ".into(), server.clone().into()].into()); - let status_span = if enabled { - "enabled".green() - } else { - "disabled".red() - }; - lines.push(vec![" • Status: ".into(), status_span].into()); - match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => { let args_suffix = if args.is_empty() { @@ -900,12 +883,7 @@ pub(crate) fn new_mcp_tools_output( format!(" {}", args.join(" ")) }; let cmd_display = format!("{command}{args_suffix}"); - let cmd_span = if enabled { - cmd_display.into() - } else { - cmd_display.dim() - }; - lines.push(vec![" • Command: ".into(), cmd_span].into()); + lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); if let Some(env) = env.as_ref() && !env.is_empty() @@ -913,37 +891,18 @@ pub(crate) fn new_mcp_tools_output( let mut env_pairs: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect(); env_pairs.sort(); - let env_span = if enabled { - env_pairs.join(" ").into() - } else { - env_pairs.join(" ").dim() - }; - lines.push(vec![" • Env: ".into(), env_span].into()); + lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); } } McpServerTransportConfig::StreamableHttp { url, .. } => { - let url_span = if enabled { - url.clone().into() - } else { - url.clone().dim() - }; - lines.push(vec![" • URL: ".into(), url_span].into()); + lines.push(vec![" • URL: ".into(), url.clone().into()].into()); } } - if enabled { - if names.is_empty() { - lines.push(" • Tools: (none)".dim().italic().into()); - } else { - lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); - } + if names.is_empty() { + lines.push(" • Tools: (none)".into()); } else { - lines.push( - " • Tools: enable this server to load tool definitions." - .dim() - .italic() - .into(), - ); + lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); } lines.push(Line::from("")); } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4774bc7aa6cb..14604a736d7f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -23,8 +23,6 @@ pub enum SlashCommand { Mention, Status, Mcp, - Enable, - Disable, Logout, Quit, #[cfg(debug_assertions)] @@ -47,8 +45,6 @@ impl SlashCommand { SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", - SlashCommand::Enable => "enable a configured MCP server", - SlashCommand::Disable => "disable a configured MCP server", SlashCommand::Logout => "log out of Codex", #[cfg(debug_assertions)] SlashCommand::TestApproval => "test approval request", @@ -76,18 +72,12 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Status | SlashCommand::Mcp - | SlashCommand::Enable - | SlashCommand::Disable | SlashCommand::Quit => true, #[cfg(debug_assertions)] SlashCommand::TestApproval => true, } } - - pub fn expects_argument(self) -> bool { - matches!(self, SlashCommand::Enable | SlashCommand::Disable) - } } /// Return all built-in commands in a Vec paired with their command string. diff --git a/docs/config.md b/docs/config.md index 77aef466fe09..aa45acc8dd09 100644 --- a/docs/config.md +++ b/docs/config.md @@ -421,10 +421,6 @@ codex mcp get docs --json # Remove a server codex mcp remove docs -# Enable or disable a server without editing config.toml -codex mcp enable docs -codex mcp disable docs - # Log in to a streamable HTTP server that supports oauth codex mcp login SERVER_NAME @@ -432,15 +428,6 @@ codex mcp login SERVER_NAME codex mcp logout SERVER_NAME ``` -#### Enabling and disabling MCP servers - -Codex discovers all MCP definitions from `config.toml`, but they remain _disabled_ until you explicitly turn them on. Enabled state is stored in a writable registry (default `~/.local/state/codex/mcp_registry.json`; override with `CODEX_STATE_HOME`). This avoids churn to Nix- or Git-managed config files while still letting you opt into servers when you are ready. - -- **CLI** – run `codex mcp enable ` to activate a server, or `codex mcp disable ` to deactivate it. `codex mcp list` and `codex mcp get` now show an `enabled` flag so you can audit the current state. -- **TUI** – inside an active session, use `/enable ` or `/disable ` (and `/mcp` to review the current status). Disabled servers are rendered in the MCP panel with a red “disabled” badge so you can tell at a glance what is ready. - -Once a server is enabled, Codex starts it automatically for new sessions (`/new`) and surfaces its tools in the prompt. - ## Examples of useful MCPs There is an ever growing list of useful MCP servers that can be helpful while you are working with Codex. From c556184622ae8ab82aeb107ec8ac7e0ef1b7f463 Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Wed, 8 Oct 2025 10:22:01 -0700 Subject: [PATCH 12/38] Fix flake and status bar --- codex-rs/tui/src/bottom_pane/footer.rs | 34 ++++++++++++++++++-------- codex-rs/tui/src/chatwidget.rs | 16 +++++++++++- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 6e92a0ce6852..58dae537dbee 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -11,6 +11,8 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +const FOOTER_VISIBLE: bool = false; + #[derive(Clone, Copy, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, @@ -59,10 +61,17 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { } pub(crate) fn footer_height(props: FooterProps) -> u16 { - footer_lines(props).len() as u16 + if FOOTER_VISIBLE { + footer_lines(props).len() as u16 + } else { + 0 + } } pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { + if !FOOTER_VISIBLE { + return; + } Paragraph::new(prefix_lines( footer_lines(props), " ".repeat(FOOTER_INDENT_COLS).into(), @@ -110,16 +119,21 @@ struct ShortcutsState { } fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { - let action = if state.is_task_running { - "interrupt" + if state.is_task_running { + Line::from(vec![ + key_hint::plain(KeyCode::Esc).into(), + " or ".into(), + key_hint::ctrl(KeyCode::Char('c')).into(), + " to interrupt".into(), + ]) + .dim() } else { - "quit" - }; - Line::from(vec![ - key_hint::ctrl(KeyCode::Char('c')).into(), - format!(" again to {action}").into(), - ]) - .dim() + Line::from(vec![ + key_hint::ctrl(KeyCode::Char('c')).into(), + " again to quit".into(), + ]) + .dim() + } } fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 787e09809386..3769141303ea 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1033,6 +1033,19 @@ impl ChatWidget { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } => { + if self.bottom_pane.is_task_running() { + self.bottom_pane.show_ctrl_c_quit_hint(); + self.submit_op(Op::Interrupt); + } else { + self.bottom_pane.clear_ctrl_c_quit_hint(); + } + } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -1597,7 +1610,8 @@ impl ChatWidget { pub(crate) fn add_status_output(&mut self) { let default_usage = TokenUsage::default(); let (total_usage, context_usage) = if let Some(ti) = &self.token_info { - (&ti.total_token_usage, Some(&ti.last_token_usage)) + let usage = &ti.total_token_usage; + (usage, Some(usage)) } else { (&default_usage, Some(&default_usage)) }; From d368e348ca9f0723ab38c9fbe9b5ed5f57bbf92b Mon Sep 17 00:00:00 2001 From: Josh Symonds Date: Fri, 10 Oct 2025 09:52:42 -0700 Subject: [PATCH 13/38] Restore status module --- codex-rs/Cargo.lock | 14 - codex-rs/tui/Cargo.toml | 2 - codex-rs/tui/src/app_backtrack.rs | 3 +- .../tui/src/bottom_pane/approval_overlay.rs | 126 +++-- codex-rs/tui/src/bottom_pane/chat_composer.rs | 17 +- .../src/bottom_pane/chat_composer_history.rs | 5 +- codex-rs/tui/src/bottom_pane/footer.rs | 75 +-- .../src/bottom_pane/list_selection_view.rs | 5 +- ...mposer__tests__backspace_after_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__empty.snap | 2 +- ...__tests__footer_mode_ctrl_c_interrupt.snap | 2 + ...poser__tests__footer_mode_ctrl_c_quit.snap | 2 + ...sts__footer_mode_ctrl_c_then_esc_hint.snap | 2 + ...tests__footer_mode_esc_hint_backtrack.snap | 2 + ...ts__footer_mode_esc_hint_from_overlay.snap | 2 + ...ests__footer_mode_hidden_while_typing.snap | 1 - ...r_mode_overlay_then_external_esc_hint.snap | 2 + ...__tests__footer_mode_shortcut_overlay.snap | 4 + ...tom_pane__chat_composer__tests__large.snap | 2 +- ...chat_composer__tests__multiple_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__small.snap | 2 +- ...ooter__tests__footer_ctrl_c_quit_idle.snap | 3 +- ...er__tests__footer_ctrl_c_quit_running.snap | 3 +- ...__footer__tests__footer_esc_hint_idle.snap | 3 +- ...footer__tests__footer_esc_hint_primed.snap | 3 +- ...sts__footer_shortcuts_context_running.snap | 2 +- ...oter__tests__footer_shortcuts_default.snap | 2 +- ...tests__footer_shortcuts_shift_and_esc.snap | 5 +- codex-rs/tui/src/bottom_pane/textarea.rs | 35 +- codex-rs/tui/src/chatwidget.rs | 429 ++++++++++---- ...exec_and_status_layout_vt100_snapshot.snap | 5 +- ...pproval_history_decision_aborted_long.snap | 4 +- codex-rs/tui/src/chatwidget/tests.rs | 157 +----- codex-rs/tui/src/custom_terminal.rs | 37 +- codex-rs/tui/src/diff_render.rs | 11 +- codex-rs/tui/src/exec_cell/render.rs | 28 +- codex-rs/tui/src/history_cell.rs | 342 ++++-------- .../tui/src/onboarding/trust_directory.rs | 169 ++---- codex-rs/tui/src/pager_overlay.rs | 137 ++--- codex-rs/tui/src/render/highlight.rs | 297 ++++------ codex-rs/tui/src/render/mod.rs | 19 +- codex-rs/tui/src/render/renderable.rs | 123 +--- codex-rs/tui/src/session_log.rs | 2 +- codex-rs/tui/src/shimmer.rs | 15 +- codex-rs/tui/src/status_indicator_widget.rs | 310 ----------- codex-rs/tui/src/statusline/mod.rs | 18 +- codex-rs/tui/src/statusline/state.rs | 12 +- codex-rs/tui/src/streaming/controller.rs | 22 +- codex-rs/tui/src/style.rs | 32 +- codex-rs/tui/src/terminal_palette.rs | 527 ++++++++---------- codex-rs/tui/src/test_backend.rs | 3 +- codex-rs/tui/src/tui.rs | 1 + codex-rs/tui/src/wrapping.rs | 1 - 53 files changed, 1170 insertions(+), 1861 deletions(-) delete mode 100644 codex-rs/tui/src/status_indicator_widget.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 655a338e0569..264bfccb0fa1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1427,8 +1427,6 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "tree-sitter-bash", - "tree-sitter-highlight", "unicode-segmentation", "unicode-width 0.2.1", "url", @@ -6309,18 +6307,6 @@ dependencies = [ "tree-sitter-language", ] -[[package]] -name = "tree-sitter-highlight" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3" -dependencies = [ - "regex", - "streaming-iterator", - "thiserror 2.0.16", - "tree-sitter", -] - [[package]] name = "tree-sitter-language" version = "0.1.5" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c5bf9c04298a..dffa5b4bd863 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -69,8 +69,6 @@ strum_macros = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } -tree-sitter-highlight = { workspace = true } -tree-sitter-bash = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 5528fa7bc465..b5c1300b4556 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -134,9 +134,8 @@ impl App { /// Useful when switching sessions to ensure prior history remains visible. pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { if !self.transcript_cells.is_empty() { - let width = tui.terminal.last_known_screen_size.width; for cell in &self.transcript_cells { - tui.insert_history_lines(cell.display_lines(width)); + tui.insert_history_lines(cell.transcript_lines()); } } } diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index b15027c030fe..7f52b11bfaf8 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -16,6 +16,7 @@ use crate::key_hint::KeyBinding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; +use crate::text_formatting::truncate_text; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; @@ -104,9 +105,9 @@ impl ApprovalOverlay { ), }; - let header = Box::new(ColumnRenderable::with([ - Line::from(title.bold()).into(), - Line::from("").into(), + let header = Box::new(ColumnRenderable::new([ + Box::new(Line::from(title.bold())), + Box::new(Line::from("")), header, ])); @@ -159,8 +160,11 @@ impl ApprovalOverlay { } fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { - let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision); - self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + if let Some(lines) = build_exec_history_lines(command.to_vec(), decision) { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_user_approval_decision(lines), + ))); + } self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { id: id.to_string(), decision, @@ -322,7 +326,7 @@ impl From for ApprovalRequestState { } Self { variant: ApprovalVariant::ApplyPatch { id }, - header: Box::new(ColumnRenderable::with(header)), + header: Box::new(ColumnRenderable::new(header)), } } } @@ -391,11 +395,91 @@ fn patch_options() -> Vec { ] } +fn build_exec_history_lines( + command: Vec, + decision: ReviewDecision, +) -> Option>> { + use ReviewDecision::*; + + let (symbol, summary): (Span<'static>, Vec>) = match decision { + Approved => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " this time".bold(), + ], + ) + } + ApprovedForSession => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " every time this session".bold(), + ], + ) + } + Denied => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✗ ".red(), + vec![ + "You ".into(), + "did not approve".bold(), + " codex to run ".into(), + snippet, + ], + ) + } + Abort => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✗ ".red(), + vec![ + "You ".into(), + "canceled".bold(), + " the request to run ".into(), + snippet, + ], + ) + } + }; + + let mut lines = Vec::new(); + let mut spans = Vec::new(); + spans.push(symbol); + spans.extend(summary); + lines.push(Line::from(spans)); + Some(lines) +} + +fn truncate_exec_snippet(full_cmd: &str) -> String { + let mut snippet = match full_cmd.split_once('\n') { + Some((first, _)) => format!("{first} ..."), + None => full_cmd.to_string(), + }; + snippet = truncate_text(&snippet, 80); + snippet +} + +fn exec_snippet(command: &[String]) -> String { + let full_cmd = strip_bash_lc_and_escape(command); + truncate_exec_snippet(&full_cmd) +} + #[cfg(test)] mod tests { use super::*; use crate::app_event::AppEvent; - use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; fn make_exec_request() -> ApprovalRequest { @@ -465,34 +549,6 @@ mod tests { ); } - #[test] - fn exec_history_cell_wraps_with_two_space_indent() { - let command = vec![ - "/bin/zsh".into(), - "-lc".into(), - "git add tui/src/render/mod.rs tui/src/render/renderable.rs".into(), - ]; - let cell = history_cell::new_approval_decision_cell(command, ReviewDecision::Approved); - let lines = cell.display_lines(28); - let rendered: Vec = lines - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect(); - let expected = vec![ - "✔ You approved codex to".to_string(), - " run /bin/zsh -lc 'git add".to_string(), - " tui/src/render/mod.rs tui/".to_string(), - " src/render/renderable.rs'".to_string(), - " this time".to_string(), - ]; - assert_eq!(rendered, expected); - } - #[test] fn enter_sets_last_selected_index_without_dismissing() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ff07e3456f9f..afc1037d222c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -38,6 +38,7 @@ use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::slash_command::SlashCommand; use crate::slash_command::built_in_slash_commands; use crate::style::user_message_style; +use crate::terminal_palette; use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; @@ -148,7 +149,7 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), - footer_mode: FooterMode::ShortcutSummary, + footer_mode: FooterMode::ShortcutPrompt, footer_hint_override: None, }; // Apply configuration via the setter to keep side-effects centralized. @@ -1342,8 +1343,8 @@ impl ChatComposer { FooterMode::EscHint => FooterMode::EscHint, FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay, FooterMode::CtrlCReminder => FooterMode::CtrlCReminder, - FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, - FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly, + FooterMode::ShortcutPrompt if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder, + FooterMode::ShortcutPrompt if !self.is_empty() => FooterMode::Empty, other => other, } } @@ -1523,7 +1524,7 @@ impl WidgetRef for ChatComposer { } } } - let style = user_message_style(); + let style = user_message_style(terminal_palette::default_bg()); let mut block_rect = composer_rect; block_rect.y = composer_rect.y.saturating_sub(1); block_rect.height = composer_rect.height.saturating_add(1); @@ -1747,11 +1748,11 @@ mod tests { // Toggle back to prompt mode so subsequent typing captures characters. let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert_eq!(composer.footer_mode, FooterMode::ShortcutPrompt); type_chars_humanlike(&mut composer, &['h']); assert_eq!(composer.textarea.text(), "h"); - assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); + assert_eq!(composer.footer_mode(), FooterMode::Empty); let (result, needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); @@ -1760,8 +1761,8 @@ mod tests { std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); let _ = composer.flush_paste_burst_if_due(); assert_eq!(composer.textarea.text(), "h?"); - assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); - assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); + assert_eq!(composer.footer_mode, FooterMode::ShortcutPrompt); + assert_eq!(composer.footer_mode(), FooterMode::Empty); } #[test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 07e3df7cd76a..87bcc438e9d1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -59,15 +59,14 @@ impl ChatComposerHistory { return; } - self.history_cursor = None; - self.last_history_text = None; - // Avoid inserting a duplicate if identical to the previous entry. if self.local_history.last().is_some_and(|prev| prev == text) { return; } self.local_history.push(text.to_string()); + self.history_cursor = None; + self.last_history_text = None; } /// Should Up/Down key presses be interpreted as history navigation given diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 58dae537dbee..1ce826fa7912 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -11,24 +11,21 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; -const FOOTER_VISIBLE: bool = false; - #[derive(Clone, Copy, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, - pub(crate) context_window_percent: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum FooterMode { CtrlCReminder, - ShortcutSummary, + ShortcutPrompt, ShortcutOverlay, EscHint, - ContextOnly, + Empty, } pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { @@ -37,7 +34,7 @@ pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> Fo } match current { - FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary, + FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt, _ => FooterMode::ShortcutOverlay, } } @@ -55,23 +52,16 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { FooterMode::EscHint | FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder - | FooterMode::ContextOnly => FooterMode::ShortcutSummary, + | FooterMode::Empty => FooterMode::ShortcutPrompt, other => other, } } pub(crate) fn footer_height(props: FooterProps) -> u16 { - if FOOTER_VISIBLE { - footer_lines(props).len() as u16 - } else { - 0 - } + footer_lines(props).len() as u16 } pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { - if !FOOTER_VISIBLE { - return; - } Paragraph::new(prefix_lines( footer_lines(props), " ".repeat(FOOTER_INDENT_COLS).into(), @@ -81,29 +71,17 @@ pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { } fn footer_lines(props: FooterProps) -> Vec> { - // Show the context indicator on the left, appended after the primary hint - // (e.g., "? for shortcuts"). Keep it visible even when typing (i.e., when - // the shortcut hint is hidden). Hide it only for the multi-line - // ShortcutOverlay. match props.mode { FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState { is_task_running: props.is_task_running, })], - FooterMode::ShortcutSummary => { - let mut line = context_window_line(props.context_window_percent); - line.push_span(" · ".dim()); - line.extend(vec![ - key_hint::plain(KeyCode::Char('?')).into(), - " for shortcuts".dim(), - ]); - vec![line] - } + FooterMode::ShortcutPrompt => Vec::new(), FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { use_shift_enter_hint: props.use_shift_enter_hint, esc_backtrack_hint: props.esc_backtrack_hint, }), FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], - FooterMode::ContextOnly => vec![context_window_line(props.context_window_percent)], + FooterMode::Empty => Vec::new(), } } @@ -119,21 +97,16 @@ struct ShortcutsState { } fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { - if state.is_task_running { - Line::from(vec![ - key_hint::plain(KeyCode::Esc).into(), - " or ".into(), - key_hint::ctrl(KeyCode::Char('c')).into(), - " to interrupt".into(), - ]) - .dim() + let action = if state.is_task_running { + "interrupt" } else { - Line::from(vec![ - key_hint::ctrl(KeyCode::Char('c')).into(), - " again to quit".into(), - ]) - .dim() - } + "quit" + }; + Line::from(vec![ + key_hint::ctrl(KeyCode::Char('c')).into(), + format!(" again to {action}").into(), + ]) + .dim() } fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> { @@ -235,11 +208,6 @@ fn build_columns(entries: Vec>) -> Vec> { .collect() } -fn context_window_line(percent: Option) -> Line<'static> { - let percent = percent.unwrap_or(100); - Line::from(vec![Span::from(format!("{percent}% context left")).dim()]) -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShortcutId { Commands, @@ -409,11 +377,10 @@ mod tests { snapshot_footer( "footer_shortcuts_default", FooterProps { - mode: FooterMode::ShortcutSummary, + mode: FooterMode::ShortcutPrompt, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, - context_window_percent: None, }, ); @@ -424,7 +391,6 @@ mod tests { esc_backtrack_hint: true, use_shift_enter_hint: true, is_task_running: false, - context_window_percent: None, }, ); @@ -435,7 +401,6 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, - context_window_percent: None, }, ); @@ -446,7 +411,6 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, - context_window_percent: None, }, ); @@ -457,7 +421,6 @@ mod tests { esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: false, - context_window_percent: None, }, ); @@ -468,18 +431,16 @@ mod tests { esc_backtrack_hint: true, use_shift_enter_hint: false, is_task_running: false, - context_window_percent: None, }, ); snapshot_footer( "footer_shortcuts_context_running", FooterProps { - mode: FooterMode::ShortcutSummary, + mode: FooterMode::ShortcutPrompt, esc_backtrack_hint: false, use_shift_enter_hint: false, is_task_running: true, - context_window_percent: Some(72), }, ); } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 1e8d1983b0e7..9f0ce3df24e1 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -20,6 +20,7 @@ use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; +use crate::terminal_palette; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -87,7 +88,7 @@ impl ListSelectionView { if params.title.is_some() || params.subtitle.is_some() { let title = params.title.map(|title| Line::from(title.bold())); let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim())); - header = Box::new(ColumnRenderable::with([ + header = Box::new(ColumnRenderable::new([ header, Box::new(title), Box::new(subtitle), @@ -349,7 +350,7 @@ impl Renderable for ListSelectionView { .areas(area); Block::default() - .style(user_message_style()) + .style(user_message_style(terminal_palette::default_bg())) .render(content_area, buf); let header_height = self diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap index e4cc9ffefd57..adb764b6c4b2 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__backspace_after_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap index 53e0aee4cf90..2ae146291b4e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__empty.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left · ? for shortcuts " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap index 4fd5f431acb9..a805fbf9f4e6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 expression: terminal.backend() --- " " @@ -10,3 +11,4 @@ expression: terminal.backend() " " " " " " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap index 4fd5f431acb9..750ba101924f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 expression: terminal.backend() --- " " @@ -10,3 +11,4 @@ expression: terminal.backend() " " " " " " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap index 4fd5f431acb9..8c2d2bfd3907 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 expression: terminal.backend() --- " " @@ -10,3 +11,4 @@ expression: terminal.backend() " " " " " " +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap index 4fd5f431acb9..5ddf39e31551 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 expression: terminal.backend() --- " " @@ -10,3 +11,4 @@ expression: terminal.backend() " " " " " " +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap index 4fd5f431acb9..8c2d2bfd3907 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 expression: terminal.backend() --- " " @@ -10,3 +11,4 @@ expression: terminal.backend() " " " " " " +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap index 67e616e917fc..dfeb98d61952 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_hidden_while_typing.snap @@ -10,4 +10,3 @@ expression: terminal.backend() " " " " " " -" 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap index 4fd5f431acb9..5ddf39e31551 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1497 expression: terminal.backend() --- " " @@ -10,3 +11,4 @@ expression: terminal.backend() " " " " " " +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 4fd5f431acb9..3b6782d06d62 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -10,3 +10,7 @@ expression: terminal.backend() " " " " " " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap index 6b018021ecec..4237a17ae00b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__large.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap index 40098faee016..3edfc2ce226f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__multiple_pastes.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap index 498ed7693660..402740b8fa07 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__small.snap @@ -11,4 +11,4 @@ expression: terminal.backend() " " " " " " -" 100% context left " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap index 1bb3b01df0a0..817adb66d958 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_idle.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 389 expression: terminal.backend() --- -" " +" ctrl + c again to quit " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap index 1bb3b01df0a0..50bf9b622d51 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 389 expression: terminal.backend() --- -" " +" ctrl + c again to interrupt " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap index 1bb3b01df0a0..172432a38e49 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 389 expression: terminal.backend() --- -" " +" esc esc to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap index 1bb3b01df0a0..69d79d5327e5 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/footer.rs +assertion_line: 389 expression: terminal.backend() --- -" " +" esc again to edit previous message " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap index d05ac90a9113..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_context_running.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" 72% context left · ? for shortcuts " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index c95a5dc0b3d6..1bb3b01df0a0 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap @@ -2,4 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" 100% context left · ? for shortcuts " +" " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 1bb3b01df0a0..264515a6c2bc 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,4 +2,7 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 6687793979ec..269fa345e015 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -799,22 +799,16 @@ impl TextArea { } pub(crate) fn beginning_of_previous_word(&self) -> usize { - let prefix = &self.text[..self.cursor_pos]; - let Some((first_non_ws_idx, _)) = prefix - .char_indices() - .rev() - .find(|&(_, ch)| !ch.is_whitespace()) - else { - return 0; - }; - let before = &prefix[..first_non_ws_idx]; - let candidate = before - .char_indices() - .rev() - .find(|&(_, ch)| ch.is_whitespace()) - .map(|(idx, ch)| idx + ch.len_utf8()) - .unwrap_or(0); - self.adjust_pos_out_of_elements(candidate, true) + if let Some(first_non_ws) = self.text[..self.cursor_pos].rfind(|c: char| !c.is_whitespace()) + { + let candidate = self.text[..first_non_ws] + .rfind(|c: char| c.is_whitespace()) + .map(|i| i + 1) + .unwrap_or(0); + self.adjust_pos_out_of_elements(candidate, true) + } else { + 0 + } } pub(crate) fn end_of_next_word(&self) -> usize { @@ -1268,15 +1262,6 @@ mod tests { assert_eq!(t.cursor(), 6); } - #[test] - fn delete_backward_word_handles_narrow_no_break_space() { - let mut t = ta_with("32\u{202F}AM"); - t.set_cursor(t.text().len()); - t.input(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT)); - pretty_assertions::assert_eq!(t.text(), "32\u{202F}"); - pretty_assertions::assert_eq!(t.cursor(), t.text().len()); - } - #[test] fn delete_forward_word_with_without_alt_modifier() { let mut t = ta_with("hello world"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3769141303ea..f8f9bfbf447f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2,9 +2,18 @@ use std::collections::HashMap; use std::collections::VecDeque; use std::path::PathBuf; use std::sync::Arc; +#[cfg(test)] +use std::sync::Mutex; + +use std::env; +use std::fs; + +#[cfg(test)] +use lazy_static::lazy_static; use codex_core::config::Config; use codex_core::config_types::Notifications; +use codex_core::git_info::collect_git_info; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::protocol::AgentMessageDeltaEvent; @@ -25,6 +34,7 @@ use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::InputItem; use codex_core::protocol::InputMessageKind; use codex_core::protocol::ListCustomPromptsResponseEvent; +use codex_core::protocol::McpInvocation; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; @@ -53,9 +63,12 @@ use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use tokio::process::Command; use tokio::sync::mpsc::UnboundedSender; +use tokio::task::spawn_blocking; use tracing::debug; use crate::app_event::AppEvent; @@ -83,6 +96,8 @@ use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; +use crate::statusline::StatusLineGitSnapshot; +use crate::statusline::StatusLineState; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; @@ -112,7 +127,9 @@ use codex_git_tooling::GitToolingError; use codex_git_tooling::create_ghost_commit; use codex_git_tooling::restore_ghost_commit; use codex_protocol::plan_tool::UpdatePlanArgs; +use hostname::get as get_hostname; use strum::IntoEnumIterator; +use tokio::runtime::Handle; const MAX_TRACKED_GHOST_COMMITS: usize = 20; @@ -222,6 +239,7 @@ pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, bottom_pane: BottomPane, + status_line: StatusLineState, active_cell: Option>, config: Config, auth_manager: Arc, @@ -240,10 +258,6 @@ pub(crate) struct ChatWidget { reasoning_buffer: String, // Accumulates full reasoning content for transcript-only recording full_reasoning_buffer: String, - // Current status header shown in the status indicator. - current_status_header: String, - // Previous status header to restore after a transient stream retry. - retry_status_header: Option, conversation_id: Option, frame_requester: FrameRequester, // Whether to include the initial welcome banner on session configured @@ -307,12 +321,58 @@ impl ChatWidget { } } - fn set_status_header(&mut self, header: String) { - if self.current_status_header == header { + fn bootstrap_status_line(&mut self) { + self.sync_status_line_model(); + let initial_tokens = self.token_info.clone(); + self.status_line.update_tokens(initial_tokens); + self.status_line.set_devspace(detect_devspace()); + self.status_line.set_hostname(detect_hostname()); + self.status_line.set_aws_profile(detect_aws_profile()); + self.refresh_queued_user_messages(); + self.spawn_status_line_background_tasks(); + } + + fn sync_status_line_model(&mut self) { + self.status_line.update_model( + self.config.model.clone(), + self.config.model_reasoning_effort, + ); + } + + fn spawn_status_line_background_tasks(&self) { + self.spawn_git_refresh(); + self.spawn_kube_refresh(); + } + + fn spawn_git_refresh(&self) { + let Ok(handle) = Handle::try_current() else { return; - } - self.current_status_header = header.clone(); - self.bottom_pane.update_status_header(header); + }; + let cwd = self.config.cwd.clone(); + let tx = self.app_event_tx.clone(); + handle.spawn(async move { + let snapshot = collect_status_line_git_snapshot(cwd).await; + tx.send(AppEvent::StatusLineGit(snapshot)); + }); + } + + fn spawn_kube_refresh(&self) { + let Ok(handle) = Handle::try_current() else { + return; + }; + let tx = self.app_event_tx.clone(); + handle.spawn(async move { + let context = detect_kube_context_async().await; + tx.send(AppEvent::StatusLineKubeContext(context)); + }); + } + + pub(crate) fn update_statusline_git(&mut self, git: Option) { + self.status_line.set_git_info(git); + } + + pub(crate) fn update_statusline_kube_context(&mut self, context: Option) { + self.status_line.set_kubernetes_context(context); } // --- Small event handlers --- @@ -320,9 +380,13 @@ impl ChatWidget { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.conversation_id = Some(event.session_id); + self.status_line + .set_session_id(Some(event.session_id.to_string())); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); + self.sync_status_line_model(); + self.spawn_status_line_background_tasks(); self.add_to_history(history_cell::new_session_info( &self.config, event, @@ -363,10 +427,7 @@ impl ChatWidget { self.reasoning_buffer.push_str(&delta); if let Some(header) = extract_first_bold(&self.reasoning_buffer) { - // Update the shimmer header to the extracted reasoning chunk header. - self.set_status_header(header); - } else { - // Fallback while we don't yet have a bold header: leave existing header as-is. + self.status_line.update_run_header(&header); } self.request_redraw(); } @@ -398,8 +459,7 @@ impl ChatWidget { fn on_task_started(&mut self) { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); - self.retry_status_header = None; - self.set_status_header(String::from("Working")); + self.status_line.start_task("Working"); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.request_redraw(); @@ -411,6 +471,8 @@ impl ChatWidget { // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); self.running_commands.clear(); + self.status_line.complete_task(); + self.spawn_git_refresh(); self.request_redraw(); // If there is a queued user message, send exactly one now to begin the next turn. @@ -422,17 +484,8 @@ impl ChatWidget { } pub(crate) fn set_token_info(&mut self, info: Option) { - if let Some(info) = info { - let context_window = info - .model_context_window - .or(self.config.model_context_window); - let percent = context_window.map(|window| { - info.last_token_usage - .percent_of_context_window_remaining(window) - }); - self.bottom_pane.set_context_window_percent(percent); - self.token_info = Some(info); - } + self.token_info = info.clone(); + self.status_line.update_tokens(info); } fn on_rate_limit_snapshot(&mut self, snapshot: Option) { @@ -474,6 +527,8 @@ impl ChatWidget { self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.stream_controller = None; + self.status_line.complete_task(); + self.spawn_git_refresh(); } fn on_error(&mut self, message: String) { @@ -515,7 +570,7 @@ impl ChatWidget { format!("{queued_text}\n{existing_text}") }; self.bottom_pane.set_composer_text(combined); - // Clear the queue and update the status indicator list. + // Clear the queue and update the status line queue preview. self.queued_user_messages.clear(); self.refresh_queued_user_messages(); } @@ -559,6 +614,8 @@ impl ChatWidget { } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { + self.status_line.resume_timer(); + self.status_line.update_run_header("Applying patch"); self.add_to_history(history_cell::new_patch_event( event.changes, &self.config.cwd, @@ -635,10 +692,9 @@ impl ChatWidget { } fn on_stream_error(&mut self, message: String) { - if self.retry_status_header.is_none() { - self.retry_status_header = Some(self.current_status_header.clone()); - } - self.set_status_header(message); + // Show stream errors in the transcript so users see retry/backoff info. + self.add_to_history(history_cell::new_stream_error_event(message)); + self.request_redraw(); } /// Periodic tick to commit at most one queued line to history with a small delay, @@ -647,7 +703,6 @@ impl ChatWidget { if let Some(controller) = self.stream_controller.as_mut() { let (cell, is_idle) = controller.on_commit_tick(); if let Some(cell) = cell { - self.bottom_pane.hide_status_indicator(); self.add_boxed_history(cell); } if is_idle { @@ -680,7 +735,6 @@ impl ChatWidget { fn handle_stream_finished(&mut self) { if self.task_complete_pending { - self.bottom_pane.hide_status_indicator(); self.task_complete_pending = false; } // A completed stream indicates non-exec content was just inserted. @@ -694,10 +748,7 @@ impl ChatWidget { if self.stream_controller.is_none() { if self.needs_final_message_separator { - let elapsed_seconds = self - .bottom_pane - .status_widget() - .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); + let elapsed_seconds = self.status_line.elapsed_seconds(); self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); self.needs_final_message_separator = false; } @@ -754,17 +805,21 @@ impl ChatWidget { self.flush_active_cell(); } } + if self.running_commands.is_empty() { + self.status_line.update_run_header("Working"); + self.spawn_git_refresh(); + } } pub(crate) fn handle_patch_apply_end_now( &mut self, event: codex_core::protocol::PatchApplyEndEvent, ) { - // If the patch was successful, just let the "Edited" block stand. - // Otherwise, add a failure block. if !event.success { self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } + self.status_line.update_run_header("Working"); + self.spawn_git_refresh(); } pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { @@ -778,6 +833,8 @@ impl ChatWidget { command: ev.command, reason: ev.reason, }; + self.status_line + .update_run_header(&Self::approval_status_label("command")); self.bottom_pane.push_approval_request(request); self.request_redraw(); } @@ -795,6 +852,8 @@ impl ChatWidget { changes: ev.changes.clone(), cwd: self.config.cwd.clone(), }; + self.status_line + .update_run_header(&Self::approval_status_label("patch")); self.bottom_pane.push_approval_request(request); self.request_redraw(); self.notify(Notification::EditApprovalRequested { @@ -804,7 +863,9 @@ impl ChatWidget { } pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { - // Ensure the status indicator is visible while the command runs. + self.status_line.resume_timer(); + self.status_line + .update_run_header(&Self::exec_status_label(&ev.command)); self.running_commands.insert( ev.call_id.clone(), RunningCommand { @@ -838,7 +899,10 @@ impl ChatWidget { pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); + self.status_line.resume_timer(); self.flush_active_cell(); + self.status_line + .update_run_header(&Self::tool_status_label(&ev.invocation)); self.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call( ev.call_id, ev.invocation, @@ -874,25 +938,28 @@ impl ChatWidget { if let Some(extra) = extra_cell { self.add_boxed_history(extra); } + self.status_line.update_run_header("Working"); } - fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let bottom_min = self.bottom_pane.desired_height(area.width).min(area.height); - let remaining = area.height.saturating_sub(bottom_min); + fn layout_areas(&self, area: Rect) -> [Rect; 4] { + let status_height = if area.height > 0 { 1 } else { 0 }; + let available = area.height.saturating_sub(status_height); + let bottom_min = self.bottom_pane.desired_height(area.width).min(available); + let remaining = available.saturating_sub(bottom_min); let active_desired = self .active_cell .as_ref() .map_or(0, |c| c.desired_height(area.width) + 1); let active_height = active_desired.min(remaining); - // Note: no header area; remaining is not used beyond computing active height. let header_height = 0u16; Layout::vertical([ Constraint::Length(header_height), Constraint::Length(active_height), - Constraint::Min(bottom_min), + Constraint::Length(bottom_min), + Constraint::Length(status_height), ]) .areas(area) } @@ -914,18 +981,24 @@ impl ChatWidget { let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); - Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), + let frame_requester_clone = frame_requester.clone(); + let app_event_tx_clone = app_event_tx.clone(); + let status_line = StatusLineState::new(&config, frame_requester_clone.clone()); + let bottom_pane = BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + }); + + let mut widget = Self { + app_event_tx: app_event_tx_clone, + frame_requester: frame_requester_clone, codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - }), + bottom_pane, + status_line, active_cell: None, config: config.clone(), auth_manager, @@ -943,8 +1016,6 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, @@ -955,7 +1026,10 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), - } + }; + + widget.bootstrap_status_line(); + widget } /// Create a ChatWidget attached to an existing conversation (e.g., a fork). @@ -979,18 +1053,24 @@ impl ChatWidget { let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); - Self { - app_event_tx: app_event_tx.clone(), - frame_requester: frame_requester.clone(), + let frame_requester_clone = frame_requester.clone(); + let app_event_tx_clone = app_event_tx.clone(); + let status_line = StatusLineState::new(&config, frame_requester_clone.clone()); + let bottom_pane = BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + }); + + let mut widget = Self { + app_event_tx: app_event_tx_clone, + frame_requester: frame_requester_clone, codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - frame_requester, - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - placeholder_text: placeholder, - disable_paste_burst: config.disable_paste_burst, - }), + bottom_pane, + status_line, active_cell: None, config: config.clone(), auth_manager, @@ -1008,19 +1088,20 @@ impl ChatWidget { interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), show_welcome_banner: true, - suppress_session_configured_redraw: true, + suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), - } + }; + + widget.bootstrap_status_line(); + widget } pub fn desired_height(&self, width: u16) -> u16 { @@ -1029,23 +1110,11 @@ impl ChatWidget { .active_cell .as_ref() .map_or(0, |c| c.desired_height(width) + 1) + + 1 } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { - KeyEvent { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - kind: KeyEventKind::Press, - .. - } => { - if self.bottom_pane.is_task_running() { - self.bottom_pane.show_ctrl_c_quit_hint(); - self.submit_op(Op::Interrupt); - } else { - self.bottom_pane.clear_ctrl_c_quit_hint(); - } - } KeyEvent { code: KeyCode::Char(c), modifiers, @@ -1590,13 +1659,37 @@ impl ChatWidget { } /// Rebuild and update the queued user messages from the current queue. + fn exec_status_label(command: &[String]) -> String { + if command.is_empty() { + return "Running command".to_string(); + } + let joined = command.join(" "); + let summary = truncate_text(&joined, 40); + format!("Running {summary}") + } + + fn tool_status_label(invocation: &McpInvocation) -> String { + let label = if invocation.server.is_empty() { + invocation.tool.clone() + } else { + format!("{}::{}", invocation.server, invocation.tool) + }; + let summary = truncate_text(&label, 36); + format!("Calling {summary}") + } + + fn approval_status_label(subject: &str) -> String { + format!("Awaiting {subject} approval") + } + fn refresh_queued_user_messages(&mut self) { let messages: Vec = self .queued_user_messages .iter() .map(|m| m.text.clone()) .collect(); - self.bottom_pane.set_queued_user_messages(messages); + self.status_line.set_queued_messages(messages); + self.request_redraw(); } pub(crate) fn add_diff_in_progress(&mut self) { @@ -1610,8 +1703,7 @@ impl ChatWidget { pub(crate) fn add_status_output(&mut self) { let default_usage = TokenUsage::default(); let (total_usage, context_usage) = if let Some(ti) = &self.token_info { - let usage = &ti.total_token_usage; - (usage, Some(usage)) + (&ti.total_token_usage, Some(&ti.last_token_usage)) } else { (&default_usage, Some(&default_usage)) }; @@ -1856,12 +1948,14 @@ impl ChatWidget { /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.config.model_reasoning_effort = effort; + self.sync_status_line_model(); } /// Set the model in the widget's config copy. pub(crate) fn set_model(&mut self, model: &str) { self.session_header.set_model(model); self.config.model = model.to_string(); + self.sync_status_line_model(); } pub(crate) fn add_info_message(&mut self, message: String, hint: Option) { @@ -1907,7 +2001,7 @@ impl ChatWidget { } /// True when the UI is in the regular composer state with no running task, - /// no modal overlay (e.g. approvals or status indicator), and no composer popups. + /// no modal overlay (e.g. approvals), and no composer popups. /// In this state Esc-Esc backtracking is enabled. pub(crate) fn is_normal_backtrack_mode(&self) -> bool { self.bottom_pane.is_normal_backtrack_mode() @@ -1939,11 +2033,7 @@ impl ChatWidget { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(history_cell::new_mcp_tools_output( - &self.config, - ev.tools, - &ev.auth_statuses, - )); + self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools)); } fn on_list_custom_prompts(&mut self, ev: ListCustomPromptsResponseEvent) { @@ -2143,17 +2233,18 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; + self.status_line.update_tokens(None); } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - let [_, _, bottom_pane_area] = self.layout_areas(area); + let [_, _, bottom_pane_area, _] = self.layout_areas(area); self.bottom_pane.cursor_pos(bottom_pane_area) } } impl WidgetRef for &ChatWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let [_, active_cell_area, bottom_pane_area] = self.layout_areas(area); + let [_, active_cell_area, bottom_pane_area, status_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); if !active_cell_area.is_empty() && let Some(cell) = &self.active_cell @@ -2167,10 +2258,152 @@ impl WidgetRef for &ChatWidget { tool.render_ref(area, buf); } } + if !status_area.is_empty() { + if self.bottom_pane.has_active_view() { + Paragraph::new("").render(status_area, buf); + } else { + let line = self.status_line.render_line(status_area.width); + Paragraph::new(line).render(status_area, buf); + } + } self.last_rendered_width.set(Some(area.width as usize)); } } +async fn collect_status_line_git_snapshot(cwd: PathBuf) -> Option { + let info = collect_git_info(&cwd).await?; + let (dirty, ahead, behind) = git_status_porcelain(&cwd) + .await + .unwrap_or((false, None, None)); + Some(StatusLineGitSnapshot { + branch: info.branch, + dirty, + ahead, + behind, + }) +} + +async fn git_status_porcelain(cwd: &Path) -> Option<(bool, Option, Option)> { + let output = Command::new("git") + .args(["status", "--porcelain=2", "--branch"]) + .current_dir(cwd) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let mut dirty = false; + let mut ahead = None; + let mut behind = None; + for line in text.lines() { + if !line.starts_with('#') { + dirty = true; + continue; + } + if let Some(rest) = line.strip_prefix("# branch.ab ") { + let mut parts = rest.split_whitespace(); + if let Some(ahead_part) = parts.next() { + ahead = ahead_part.strip_prefix('+').and_then(|s| s.parse().ok()); + } + if let Some(behind_part) = parts.next() { + behind = behind_part.strip_prefix('-').and_then(|s| s.parse().ok()); + } + } + } + Some((dirty, ahead, behind)) +} + +async fn detect_kube_context_async() -> Option { + spawn_blocking(detect_kube_context_sync) + .await + .ok() + .flatten() +} + +fn detect_kube_context_sync() -> Option { + for path in kube_config_paths() { + if let Ok(contents) = fs::read_to_string(&path) { + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') { + continue; + } + if let Some(value) = trimmed.strip_prefix("current-context:") { + let context = value.trim(); + if !context.is_empty() { + return Some(trim_kube_context(context)); + } + } + } + } + } + None +} + +fn kube_config_paths() -> Vec { + if let Some(paths) = env::var_os("KUBECONFIG") { + env::split_paths(&paths).collect() + } else if let Some(home) = env::var_os("HOME") { + vec![PathBuf::from(home).join(".kube/config")] + } else { + Vec::new() + } +} + +fn trim_kube_context(context: &str) -> String { + context.rsplit('/').next().unwrap_or(context).to_string() +} + +#[cfg(test)] +lazy_static! { + static ref DEVSPACE_OVERRIDE: Mutex>> = Mutex::new(None); +} + +#[cfg(test)] +pub(crate) fn set_devspace_override_for_tests(value: Option) { + *DEVSPACE_OVERRIDE.lock().unwrap() = Some(value); +} + +#[cfg(test)] +pub(crate) fn clear_devspace_override_for_tests() { + *DEVSPACE_OVERRIDE.lock().unwrap() = None; +} + +fn detect_devspace() -> Option { + #[cfg(test)] + if let Some(override_value) = DEVSPACE_OVERRIDE.lock().unwrap().clone() { + return override_value; + } + + env::var("TMUX_DEVSPACE") + .ok() + .filter(|s| !s.trim().is_empty()) +} + +fn detect_aws_profile() -> Option { + env::var("AWS_PROFILE") + .or_else(|_| env::var("AWS_VAULT")) + .ok() + .map(|profile| { + profile + .trim() + .trim_start_matches("export AWS_PROFILE=") + .to_string() + }) + .filter(|s| !s.is_empty()) +} + +fn detect_hostname() -> Option { + if let Ok(host) = env::var("HOSTNAME") + && !host.trim().is_empty() + { + return Some(host); + } + get_hostname().ok().and_then(|os| os.into_string().ok()) +} + enum Notification { AgentTurnComplete { response: String }, ExecApprovalRequested { command: String }, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index c3bdf60bd2cf..374a5a1ce538 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 2188 expression: term.backend().vt100().screen().contents() --- • I’m going to search the repo for where “Change Approved” is rendered to update @@ -9,9 +10,7 @@ expression: term.backend().vt100().screen().contents() └ Search Change Approved Read diff_render.rs -• Investigating rendering code (0s • esc to interrupt) - › Summarize recent commits - 100% context left + •  tui  󱚥 gpt-5-codex   󰋩 earth   vermissian   ck-kubero-admin  diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap index f04e1f078a89..524e096824f0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_history_decision_aborted_long.snap @@ -1,7 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 495 expression: lines_to_single_string(&aborted_long) --- -✗ You canceled the request to run echo - aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... +✗ You canceled the request to run echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa... diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3bfdf243bc90..4f877db94fdb 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1,6 +1,9 @@ use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::chatwidget::clear_devspace_override_for_tests; +use crate::chatwidget::set_devspace_override_for_tests; +use crate::statusline::StatusLineState; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; @@ -249,19 +252,23 @@ fn make_chatwidget_manual() -> ( let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let cfg = test_config(); + let frame_requester = FrameRequester::test_dummy(); let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), - frame_requester: FrameRequester::test_dummy(), + frame_requester: frame_requester.clone(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - let widget = ChatWidget { + let status_line = StatusLineState::new(&cfg, frame_requester.clone()); + let mut widget = ChatWidget { app_event_tx, + frame_requester, codex_op_tx: op_tx, bottom_pane: bottom, + status_line, active_cell: None, config: cfg.clone(), auth_manager, @@ -276,20 +283,21 @@ fn make_chatwidget_manual() -> ( interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), - current_status_header: String::from("Working"), - retry_status_header: None, conversation_id: None, - frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, ghost_snapshots: Vec::new(), - ghost_snapshots_disabled: false, + ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), }; + // Force a deterministic devspace so status line snapshots stay stable. + set_devspace_override_for_tests(Some("earth".to_string())); + widget.bootstrap_status_line(); + clear_devspace_override_for_tests(); (widget, rx, op_rx) } @@ -617,35 +625,6 @@ fn alt_up_edits_most_recent_queued_message() { ); } -/// Pressing Up to recall the most recent history entry and immediately queuing -/// it while a task is running should always enqueue the same text, even when it -/// is queued repeatedly. -#[test] -fn enqueueing_history_prompt_multiple_times_is_stable() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - - // Submit an initial prompt to seed history. - chat.bottom_pane.set_composer_text("repeat me".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - // Simulate an active task so further submissions are queued. - chat.bottom_pane.set_task_running(true); - - for _ in 0..3 { - // Recall the prompt from history and ensure it is what we expect. - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); - - // Queue the prompt while the task is running. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - } - - assert_eq!(chat.queued_user_messages.len(), 3); - for message in chat.queued_user_messages.iter() { - assert_eq!(message.text, "repeat me"); - } -} - #[test] fn streaming_final_answer_keeps_task_running_state() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); @@ -655,7 +634,6 @@ fn streaming_final_answer_keeps_task_running_state() { chat.on_commit_tick(); assert!(chat.bottom_pane.is_task_running()); - assert!(chat.bottom_pane.status_widget().is_none()); chat.bottom_pane .set_composer_text("queued submission".to_string()); @@ -676,18 +654,6 @@ fn streaming_final_answer_keeps_task_running_state() { assert!(chat.bottom_pane.ctrl_c_quit_hint_visible()); } -#[test] -fn ctrl_c_shutdown_ignores_caps_lock() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); - - chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)); - - match op_rx.try_recv() { - Ok(Op::Shutdown) => {} - other => panic!("expected Op::Shutdown, got {other:?}"), - } -} - #[test] fn exec_history_cell_shows_working_then_completed() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); @@ -1544,82 +1510,6 @@ fn ui_snapshots_small_heights_task_running() { } } -// Snapshot test: status widget + approval modal active together -// The modal takes precedence visually; this captures the layout with a running -// task (status indicator active) while an approval request is shown. -#[test] -fn status_widget_and_approval_modal_snapshot() { - use codex_core::protocol::ExecApprovalRequestEvent; - - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - // Begin a running task so the status indicator would be active. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TaskStarted(TaskStartedEvent { - model_context_window: None, - }), - }); - // Provide a deterministic header for the status line. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); - - // Now show an approval modal (e.g. exec approval). - let ev = ExecApprovalRequestEvent { - call_id: "call-approve-exec".into(), - command: vec!["echo".into(), "hello world".into()], - cwd: std::path::PathBuf::from("/tmp"), - reason: Some( - "this is a test reason such as one that would be produced by the model".into(), - ), - }; - chat.handle_codex_event(Event { - id: "sub-approve-exec".into(), - msg: EventMsg::ExecApprovalRequest(ev), - }); - - // Render at the widget's desired height and snapshot. - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) - .expect("create terminal"); - terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) - .expect("draw status + approval modal"); - assert_snapshot!("status_widget_and_approval_modal", terminal.backend()); -} - -// Snapshot test: status widget active (StatusIndicatorView) -// Ensures the VT100 rendering of the status indicator is stable when active. -#[test] -fn status_widget_active_snapshot() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); - // Activate the status indicator by simulating a task start. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::TaskStarted(TaskStartedEvent { - model_context_window: None, - }), - }); - // Provide a deterministic header via a bold reasoning chunk. - chat.handle_codex_event(Event { - id: "task-1".into(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { - delta: "**Analyzing**".into(), - }), - }); - // Render and snapshot. - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) - .expect("create terminal"); - terminal - .draw(|f| f.render_widget_ref(&chat, f.area())) - .expect("draw status widget"); - assert_snapshot!("status_widget_active", terminal.backend()); -} - #[test] fn apply_patch_events_emit_history_cells() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); @@ -2058,10 +1948,9 @@ fn plan_update_renders_history_cell() { } #[test] -fn stream_error_updates_status_indicator() { +fn stream_error_is_rendered_to_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); - chat.bottom_pane.set_task_running(true); - let msg = "Re-connecting... 2/5"; + let msg = "stream error: stream disconnected before completion: idle timeout waiting for SSE; retrying 1/5 in 211ms…"; chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::StreamError(StreamErrorEvent { @@ -2070,15 +1959,11 @@ fn stream_error_updates_status_indicator() { }); let cells = drain_insert_history(&mut rx); - assert!( - cells.is_empty(), - "expected no history cell for StreamError event" - ); - let status = chat - .bottom_pane - .status_widget() - .expect("status indicator should be visible"); - assert_eq!(status.header(), msg); + assert!(!cells.is_empty(), "expected a history cell for StreamError"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!(blob.contains('⚠')); + assert!(blob.contains("stream error:")); + assert!(blob.contains("idle timeout waiting for SSE")); } #[test] diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index bbd8900632ba..21d216aa1893 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -120,8 +120,6 @@ where /// Last known position of the cursor. Used to find the new area when the viewport is inlined /// and the terminal resized. pub last_known_cursor_pos: Position, - - use_custom_flush: bool, } impl Drop for Terminal @@ -160,7 +158,6 @@ where viewport_area: Rect::new(0, cursor_pos.y, 0, 0), last_known_screen_size: screen_size, last_known_cursor_pos: cursor_pos, - use_custom_flush: true, }) } @@ -193,24 +190,15 @@ where pub fn flush(&mut self) -> io::Result<()> { let previous_buffer = &self.buffers[1 - self.current]; let current_buffer = &self.buffers[self.current]; - - if self.use_custom_flush { - let updates = diff_buffers(previous_buffer, current_buffer); - if let Some(DrawCommand::Put { x, y, .. }) = updates - .iter() - .rev() - .find(|cmd| matches!(cmd, DrawCommand::Put { .. })) - { - self.last_known_cursor_pos = Position { x: *x, y: *y }; - } - draw(&mut self.backend, updates.into_iter()) - } else { - let updates = previous_buffer.diff(current_buffer); - if let Some((x, y, _)) = updates.last() { - self.last_known_cursor_pos = Position { x: *x, y: *y }; - } - self.backend.draw(updates.into_iter()) + let updates = diff_buffers(previous_buffer, current_buffer); + if let Some(DrawCommand::Put { x, y, .. }) = updates + .iter() + .rev() + .find(|cmd| matches!(cmd, DrawCommand::Put { .. })) + { + self.last_known_cursor_pos = Position { x: *x, y: *y }; } + draw(&mut self.backend, updates.into_iter()) } /// Updates the Terminal so that internal buffers match the requested area. @@ -420,13 +408,12 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { let x = row .iter() - .rposition(|cell| { - cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() - }) + .rposition(|cell| cell.symbol() != " " || cell.bg != bg) .unwrap_or(0); last_nonblank_column[y as usize] = x as u16; - if x < (a.area.width as usize).saturating_sub(1) { - let (x_abs, y_abs) = a.pos_of(row_start + x + 1); + let clear_start = row_start + x + 1; + if x < (a.area.width as usize).saturating_sub(1) && clear_start < previous_buffer.len() { + let (x_abs, y_abs) = a.pos_of(clear_start); updates.push(DrawCommand::ClearToEnd { x: x_abs, y: y_abs, diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index cfd26a3f6932..6f5f7e7d8ffb 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -13,8 +13,8 @@ use std::path::Path; use std::path::PathBuf; use crate::exec_command::relativize_to_home; -use crate::render::renderable::{ColumnRenderable, InsetRenderable, Renderable}; -use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; use codex_core::git_info::get_git_repo_root; use codex_core::protocol::FileChange; @@ -65,13 +65,10 @@ impl From for Box { path.extend(render_line_count_summary(row.added, row.removed)); rows.push(Box::new(path)); rows.push(Box::new(RtLine::from(""))); - rows.push(Box::new(InsetRenderable::new( - row.change, - Insets::tlbr(0, 2, 0, 0), - ))); + rows.push(Box::new(row.change)); } - Box::new(ColumnRenderable::with(rows)) + Box::new(ColumnRenderable::new(rows)) } } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 83c49e843ad5..9c1231f0cfa6 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -10,7 +10,6 @@ use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; -use crate::wrapping::word_wrap_lines; use codex_ansi_escape::ansi_escape_line; use codex_common::elapsed::format_duration; use codex_protocol::parse_command::ParsedCommand; @@ -132,25 +131,17 @@ impl HistoryCell for ExecCell { } } - fn desired_transcript_height(&self, width: u16) -> u16 { - self.transcript_lines(width).len() as u16 - } - - fn transcript_lines(&self, width: u16) -> Vec> { + fn transcript_lines(&self) -> Vec> { let mut lines: Vec> = vec![]; - for (i, call) in self.iter_calls().enumerate() { - if i > 0 { - lines.push("".into()); + for call in self.iter_calls() { + let cmd_display = strip_bash_lc_and_escape(&call.command); + for (i, part) in cmd_display.lines().enumerate() { + if i == 0 { + lines.push(vec!["$ ".magenta(), part.to_string().into()].into()); + } else { + lines.push(vec![" ".into(), part.to_string().into()].into()); + } } - let script = strip_bash_lc_and_escape(&call.command); - let highlighted_script = highlight_bash_to_lines(&script); - let cmd_display = word_wrap_lines( - &highlighted_script, - RtOptions::new(width as usize) - .initial_indent("$ ".magenta().into()) - .subsequent_indent(" ".into()), - ); - lines.extend(cmd_display); if let Some(output) = call.output.as_ref() { lines.extend(output.formatted_output.lines().map(ansi_escape_line)); @@ -169,6 +160,7 @@ impl HistoryCell for ExecCell { result.push_span(format!(" • {duration}").dim()); lines.push(result); } + lines.push("".into()); } lines } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index aa0ae35d925a..54d081c5f112 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -6,15 +6,14 @@ use crate::exec_cell::TOOL_CALL_MAX_LINES; use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; -use crate::exec_command::strip_bash_lc_and_escape; use crate::markdown::MarkdownCitationContext; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; -use crate::render::line_utils::push_owned_lines; +use crate::statusline::format_elapsed_compact; use crate::style::user_message_style; +use crate::terminal_palette::default_bg; use crate::text_formatting::format_and_truncate_tool_result; -use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; @@ -24,7 +23,6 @@ use codex_core::config::Config; use codex_core::config_types::McpServerTransportConfig; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::protocol::FileChange; -use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; @@ -59,31 +57,12 @@ use unicode_width::UnicodeWidthStr; pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { fn display_lines(&self, width: u16) -> Vec>; - fn desired_height(&self, width: u16) -> u16 { - Paragraph::new(Text::from(self.display_lines(width))) - .wrap(Wrap { trim: false }) - .line_count(width) - .try_into() - .unwrap_or(0) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.display_lines(width) + fn transcript_lines(&self) -> Vec> { + self.display_lines(u16::MAX) } - fn desired_transcript_height(&self, width: u16) -> u16 { - let lines = self.transcript_lines(width); - // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. - if let [line] = &lines[..] - && line - .spans - .iter() - .all(|s| s.content.chars().all(char::is_whitespace)) - { - return 1; - } - - Paragraph::new(Text::from(lines)) + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(Text::from(self.display_lines(width))) .wrap(Wrap { trim: false }) .line_count(width) .try_into() @@ -114,18 +93,19 @@ impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); - let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); + // Use ratatui-aware word wrapping and prefixing to avoid lifetime issues. + let wrap_width = width.saturating_sub(LIVE_PREFIX_COLS); // account for the ▌ prefix and trailing space - let style = user_message_style(); + let style = user_message_style(default_bg()); + // Use our ratatui wrapping helpers for correct styling and lifetimes. let wrapped = word_wrap_lines( &self .message .lines() .map(|l| Line::from(l).style(style)) .collect::>(), - // Wrap algorithm matches textarea.rs. - RtOptions::new(wrap_width as usize).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + RtOptions::new(wrap_width as usize), ); lines.push(Line::from("").style(style)); @@ -133,6 +113,13 @@ impl HistoryCell for UserHistoryCell { lines.push(Line::from("").style(style)); lines } + + fn transcript_lines(&self) -> Vec> { + let mut lines: Vec> = Vec::new(); + lines.push("user".cyan().bold().into()); + lines.extend(self.message.lines().map(|l| l.to_string().into())); + lines + } } #[derive(Debug)] @@ -140,7 +127,6 @@ pub(crate) struct ReasoningSummaryCell { _header: String, content: String, citation_context: MarkdownCitationContext, - transcript_only: bool, } impl ReasoningSummaryCell { @@ -148,17 +134,17 @@ impl ReasoningSummaryCell { header: String, content: String, citation_context: MarkdownCitationContext, - transcript_only: bool, ) -> Self { Self { _header: header, content, citation_context, - transcript_only, } } +} - fn lines(&self, width: u16) -> Vec> { +impl HistoryCell for ReasoningSummaryCell { + fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); append_markdown( &self.content, @@ -166,7 +152,7 @@ impl ReasoningSummaryCell { &mut lines, self.citation_context.clone(), ); - let summary_style = Style::default().dim().italic(); + let summary_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); let summary_lines = lines .into_iter() .map(|mut line| { @@ -186,31 +172,19 @@ impl ReasoningSummaryCell { .subsequent_indent(" ".into()), ) } -} - -impl HistoryCell for ReasoningSummaryCell { - fn display_lines(&self, width: u16) -> Vec> { - if self.transcript_only { - Vec::new() - } else { - self.lines(width) - } - } - - fn desired_height(&self, width: u16) -> u16 { - if self.transcript_only { - 0 - } else { - self.lines(width).len() as u16 - } - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.lines(width) - } - fn desired_transcript_height(&self, width: u16) -> u16 { - self.lines(width).len() as u16 + fn transcript_lines(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + out.push("thinking".magenta().bold().into()); + let mut lines = Vec::new(); + append_markdown( + &self.content, + None, + &mut lines, + self.citation_context.clone(), + ); + out.extend(lines); + out } } @@ -243,6 +217,15 @@ impl HistoryCell for AgentMessageCell { ) } + fn transcript_lines(&self) -> Vec> { + let mut out: Vec> = Vec::new(); + if self.is_first_line { + out.push("codex".magenta().bold().into()); + } + out.extend(self.lines.clone()); + out + } + fn is_stream_continuation(&self) -> bool { !self.is_first_line } @@ -266,125 +249,20 @@ impl HistoryCell for PlainHistoryCell { } #[derive(Debug)] -pub(crate) struct PrefixedWrappedHistoryCell { - text: Text<'static>, - initial_prefix: Line<'static>, - subsequent_prefix: Line<'static>, -} - -impl PrefixedWrappedHistoryCell { - pub(crate) fn new( - text: impl Into>, - initial_prefix: impl Into>, - subsequent_prefix: impl Into>, - ) -> Self { - Self { - text: text.into(), - initial_prefix: initial_prefix.into(), - subsequent_prefix: subsequent_prefix.into(), - } - } +pub(crate) struct TranscriptOnlyHistoryCell { + lines: Vec>, } -impl HistoryCell for PrefixedWrappedHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - if width == 0 { - return Vec::new(); - } - let opts = RtOptions::new(width.max(1) as usize) - .initial_indent(self.initial_prefix.clone()) - .subsequent_indent(self.subsequent_prefix.clone()); - let wrapped = word_wrap_lines(&self.text, opts); - let mut out = Vec::new(); - push_owned_lines(&wrapped, &mut out); - out +impl HistoryCell for TranscriptOnlyHistoryCell { + fn display_lines(&self, _width: u16) -> Vec> { + Vec::new() } - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 + fn transcript_lines(&self) -> Vec> { + self.lines.clone() } } -fn truncate_exec_snippet(full_cmd: &str) -> String { - let mut snippet = match full_cmd.split_once('\n') { - Some((first, _)) => format!("{first} ..."), - None => full_cmd.to_string(), - }; - snippet = truncate_text(&snippet, 80); - snippet -} - -fn exec_snippet(command: &[String]) -> String { - let full_cmd = strip_bash_lc_and_escape(command); - truncate_exec_snippet(&full_cmd) -} - -pub fn new_approval_decision_cell( - command: Vec, - decision: codex_core::protocol::ReviewDecision, -) -> Box { - use codex_core::protocol::ReviewDecision::*; - - let (symbol, summary): (Span<'static>, Vec>) = match decision { - Approved => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " this time".bold(), - ], - ) - } - ApprovedForSession => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✔ ".green(), - vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - snippet, - " every time this session".bold(), - ], - ) - } - Denied => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✗ ".red(), - vec![ - "You ".into(), - "did not approve".bold(), - " codex to run ".into(), - snippet, - ], - ) - } - Abort => { - let snippet = Span::from(exec_snippet(&command)).dim(); - ( - "✗ ".red(), - vec![ - "You ".into(), - "canceled".bold(), - " the request to run ".into(), - snippet, - ], - ) - } - }; - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - symbol, - " ", - )) -} - /// Cyan history cell line showing the current review status. pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { PlainHistoryCell { @@ -570,6 +448,10 @@ pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { UserHistoryCell { message } } +pub(crate) fn new_user_approval_decision(lines: Vec>) -> PlainHistoryCell { + PlainHistoryCell { lines } +} + #[derive(Debug)] struct SessionHeaderHistoryCell { version: &'static str, @@ -969,8 +851,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { /// Render MCP tools grouped by connection using the fully-qualified tool names. pub(crate) fn new_mcp_tools_output( config: &Config, - tools: HashMap, - auth_statuses: &HashMap, + tools: std::collections::HashMap, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ "/mcp".magenta().into(), @@ -994,18 +875,7 @@ pub(crate) fn new_mcp_tools_output( .collect(); names.sort(); - let status = auth_statuses - .get(server.as_str()) - .copied() - .unwrap_or(McpAuthStatus::Unsupported); lines.push(vec![" • Server: ".into(), server.clone().into()].into()); - let status_line = if cfg.enabled { - vec![" • Status: ".into(), "enabled".green()].into() - } else { - vec![" • Status: ".into(), "disabled".red()].into() - }; - lines.push(status_line); - lines.push(vec![" • Auth: ".into(), status.to_string().into()].into()); match &cfg.transport { McpServerTransportConfig::Stdio { command, args, env } => { @@ -1031,9 +901,7 @@ pub(crate) fn new_mcp_tools_output( } } - if !cfg.enabled { - lines.push(vec![" • Tools: ".into(), "(disabled)".red()].into()); - } else if names.is_empty() { + if names.is_empty() { lines.push(" • Tools: (none)".into()); } else { lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); @@ -1062,6 +930,11 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +pub(crate) fn new_stream_error_event(message: String) -> PlainHistoryCell { + let lines: Vec> = vec![vec![padded_emoji("⚠️").into(), message.dim()].into()]; + PlainHistoryCell { lines } +} + /// Render a user‑friendly plan update styled like a checkbox todo list. pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell { let UpdatePlanArgs { explanation, plan } = update; @@ -1177,6 +1050,16 @@ pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistor PlainHistoryCell { lines } } +pub(crate) fn new_reasoning_block( + full_reasoning_buffer: String, + config: &Config, +) -> TranscriptOnlyHistoryCell { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("thinking".magenta().italic())); + append_markdown(&full_reasoning_buffer, None, &mut lines, config); + TranscriptOnlyHistoryCell { lines } +} + pub(crate) fn new_reasoning_summary_block( full_reasoning_buffer: String, config: &Config, @@ -1202,18 +1085,12 @@ pub(crate) fn new_reasoning_summary_block( header_buffer, summary_buffer, config.into(), - false, )); } } } } - Box::new(ReasoningSummaryCell::new( - "".to_string(), - full_reasoning_buffer, - config.into(), - true, - )) + Box::new(new_reasoning_block(full_reasoning_buffer, config)) } #[derive(Debug)] @@ -1227,9 +1104,7 @@ impl FinalMessageSeparator { } impl HistoryCell for FinalMessageSeparator { fn display_lines(&self, width: u16) -> Vec> { - let elapsed_seconds = self - .elapsed_seconds - .map(super::status_indicator_widget::fmt_elapsed_compact); + let elapsed_seconds = self.elapsed_seconds.map(format_elapsed_compact); if let Some(elapsed_seconds) = elapsed_seconds { let worked_for = format!("─ Worked for {elapsed_seconds} ─"); let worked_for_width = worked_for.width(); @@ -1244,6 +1119,10 @@ impl HistoryCell for FinalMessageSeparator { vec![Line::from_iter(["─".repeat(width as usize).dim()])] } } + + fn transcript_lines(&self) -> Vec> { + vec![] + } } fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { @@ -1307,37 +1186,7 @@ mod tests { } fn render_transcript(cell: &dyn HistoryCell) -> Vec { - render_lines(&cell.transcript_lines(u16::MAX)) - } - - #[test] - fn empty_agent_message_cell_transcript() { - let cell = AgentMessageCell::new(vec![Line::default()], false); - assert_eq!(cell.transcript_lines(80), vec![Line::from(" ")]); - assert_eq!(cell.desired_transcript_height(80), 1); - } - - #[test] - fn prefixed_wrapped_history_cell_indents_wrapped_lines() { - let summary = Line::from(vec![ - "You ".into(), - "approved".bold(), - " codex to run ".into(), - "echo something really long to ensure wrapping happens".dim(), - " this time".bold(), - ]); - let cell = PrefixedWrappedHistoryCell::new(summary, "✔ ".green(), " "); - let rendered = render_lines(&cell.display_lines(24)); - assert_eq!( - rendered, - vec![ - "✔ You approved codex".to_string(), - " to run echo something".to_string(), - " really long to ensure".to_string(), - " wrapping happens this".to_string(), - " time".to_string(), - ] - ); + render_lines(&cell.transcript_lines()) } #[test] @@ -1587,8 +1436,8 @@ mod tests { cmd: "cat shimmer.rs".into(), }, ParsedCommand::Read { - name: "status_indicator_widget.rs".into(), - cmd: "cat status_indicator_widget.rs".into(), + name: "statusline/mod.rs".into(), + cmd: "cat statusline/mod.rs".into(), }, ], output: None, @@ -1664,8 +1513,8 @@ mod tests { "c3".into(), vec!["bash".into(), "-lc".into(), "echo".into()], vec![ParsedCommand::Read { - name: "status_indicator_widget.rs".into(), - cmd: "cat status_indicator_widget.rs".into(), + name: "statusline/mod.rs".into(), + cmd: "cat statusline/mod.rs".into(), }], ) .unwrap(); @@ -2032,7 +1881,10 @@ mod tests { assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]); let rendered_transcript = render_transcript(cell.as_ref()); - assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]); + assert_eq!( + rendered_transcript, + vec!["thinking", "Detailed reasoning goes here."] + ); } #[test] @@ -2044,7 +1896,7 @@ mod tests { new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config); let rendered = render_transcript(cell.as_ref()); - assert_eq!(rendered, vec!["• Detailed reasoning goes here."]); + assert_eq!(rendered, vec!["thinking", "Detailed reasoning goes here."]); } #[test] @@ -2058,7 +1910,10 @@ mod tests { ); let rendered = render_transcript(cell.as_ref()); - assert_eq!(rendered, vec!["• **High level reasoning without closing"]); + assert_eq!( + rendered, + vec!["thinking", "**High level reasoning without closing"] + ); } #[test] @@ -2072,7 +1927,10 @@ mod tests { ); let rendered = render_transcript(cell.as_ref()); - assert_eq!(rendered, vec!["• High level reasoning without closing"]); + assert_eq!( + rendered, + vec!["thinking", "High level reasoning without closing"] + ); let cell = new_reasoning_summary_block( "**High level reasoning without closing**\n\n ".to_string(), @@ -2080,7 +1938,10 @@ mod tests { ); let rendered = render_transcript(cell.as_ref()); - assert_eq!(rendered, vec!["• High level reasoning without closing"]); + assert_eq!( + rendered, + vec!["thinking", "High level reasoning without closing"] + ); } #[test] @@ -2097,6 +1958,9 @@ mod tests { assert_eq!(rendered_display, vec!["• We should fix the bug next."]); let rendered_transcript = render_transcript(cell.as_ref()); - assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]); + assert_eq!( + rendered_transcript, + vec!["thinking", "We should fix the bug next."] + ); } } diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 3324c3d86664..1058208bee90 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -7,25 +7,19 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Style; -use ratatui::style::Styled as _; +use ratatui::prelude::Widget; +use ratatui::style::Color; +use ratatui::style::Modifier; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use crate::key_hint; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; -use crate::render::Insets; -use crate::render::renderable::ColumnRenderable; -use crate::render::renderable::Renderable; -use crate::render::renderable::RenderableExt as _; -use crate::render::renderable::RowRenderable; use super::onboarding_screen::StepState; -use unicode_width::UnicodeWidthStr; pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, @@ -44,104 +38,74 @@ pub enum TrustDirectorySelection { impl WidgetRef for &TrustDirectoryWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let mut column = ColumnRenderable::new(); - - column.push(Line::from(vec![ - "> ".into(), - "You are running Codex in ".bold(), - self.cwd.to_string_lossy().to_string().into(), - ])); - column.push(""); + let mut lines: Vec = vec![ + Line::from(vec![ + "> ".into(), + "You are running Codex in ".bold(), + self.cwd.to_string_lossy().to_string().into(), + ]), + "".into(), + ]; - let guidance = if self.is_git_repo { - "Since this folder is version controlled, you may wish to allow Codex to work in this folder without asking for approval." + if self.is_git_repo { + lines.push( + " Since this folder is version controlled, you may wish to allow Codex".into(), + ); + lines.push(" to work in this folder without asking for approval.".into()); } else { - "Since this folder is not version controlled, we recommend requiring approval of all edits and commands." - }; - - column.push( - Paragraph::new(guidance.to_string()) - .wrap(Wrap { trim: true }) - .inset(Insets::tlbr(0, 2, 0, 0)), - ); - column.push(""); + lines.push( + " Since this folder is not version controlled, we recommend requiring".into(), + ); + lines.push(" approval of all edits and commands.".into()); + } + lines.push("".into()); + + let create_option = + |idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> { + let is_selected = self.highlighted == option; + if is_selected { + Line::from(format!("> {}. {text}", idx + 1)).cyan() + } else { + Line::from(format!(" {}. {}", idx + 1, text)) + } + }; - let mut options: Vec<(&str, TrustDirectorySelection)> = Vec::new(); if self.is_git_repo { - options.push(( - "Yes, allow Codex to work in this folder without asking for approval", + lines.push(create_option( + 0, TrustDirectorySelection::Trust, + "Yes, allow Codex to work in this folder without asking for approval", )); - options.push(( - "No, ask me to approve edits and commands", + lines.push(create_option( + 1, TrustDirectorySelection::DontTrust, + "No, ask me to approve edits and commands", )); } else { - options.push(( - "Allow Codex to work in this folder without asking for approval", + lines.push(create_option( + 0, TrustDirectorySelection::Trust, + "Allow Codex to work in this folder without asking for approval", )); - options.push(( - "Require approval of edits and commands", + lines.push(create_option( + 1, TrustDirectorySelection::DontTrust, + "Require approval of edits and commands", )); } - - for (idx, (text, selection)) in options.iter().enumerate() { - column.push(new_option_row( - idx, - text.to_string(), - self.highlighted == *selection, - )); - } - - column.push(""); - + lines.push("".into()); if let Some(error) = &self.error { - column.push( - Paragraph::new(error.to_string()) - .red() - .wrap(Wrap { trim: true }) - .inset(Insets::tlbr(0, 2, 0, 0)), - ); - column.push(""); + lines.push(Line::from(format!(" {error}")).fg(Color::Red)); + lines.push("".into()); } + // AE: Following styles.md, this should probably be Cyan because it's a user input tip. + // But leaving this for a future cleanup. + lines.push(Line::from(" Press Enter to continue").add_modifier(Modifier::DIM)); - column.push( - Line::from(vec![ - "Press ".dim(), - key_hint::plain(KeyCode::Enter).into(), - " to continue".dim(), - ]) - .inset(Insets::tlbr(0, 2, 0, 0)), - ); - - column.render(area, buf); - } -} - -fn new_option_row(index: usize, label: String, is_selected: bool) -> Box { - let prefix = if is_selected { - format!("› {}. ", index + 1) - } else { - format!(" {}. ", index + 1) - }; - - let mut style = Style::default(); - if is_selected { - style = style.cyan(); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); } - - let mut row = RowRenderable::new(); - row.push(prefix.width() as u16, prefix.set_style(style)); - row.push( - u16::MAX, - Paragraph::new(label) - .style(style) - .wrap(Wrap { trim: false }), - ); - - row.into() } impl KeyboardHandler for TrustDirectoryWidget { @@ -157,8 +121,8 @@ impl KeyboardHandler for TrustDirectoryWidget { KeyCode::Down | KeyCode::Char('j') => { self.highlighted = TrustDirectorySelection::DontTrust; } - KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(), - KeyCode::Char('2') | KeyCode::Char('n') => self.handle_dont_trust(), + KeyCode::Char('1') => self.handle_trust(), + KeyCode::Char('2') => self.handle_dont_trust(), KeyCode::Enter => match self.highlighted { TrustDirectorySelection::Trust => self.handle_trust(), TrustDirectorySelection::DontTrust => self.handle_dont_trust(), @@ -197,16 +161,12 @@ impl TrustDirectoryWidget { #[cfg(test)] mod tests { - use crate::test_backend::VT100Backend; - use super::*; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use pretty_assertions::assert_eq; - use ratatui::Terminal; - use std::path::PathBuf; #[test] @@ -231,23 +191,4 @@ mod tests { widget.handle_key_event(press); assert_eq!(widget.selection, Some(TrustDirectorySelection::DontTrust)); } - - #[test] - fn renders_snapshot_for_git_repo() { - let widget = TrustDirectoryWidget { - codex_home: PathBuf::from("."), - cwd: PathBuf::from("/workspace/project"), - is_git_repo: true, - selection: None, - highlighted: TrustDirectorySelection::Trust, - error: None, - }; - - let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal"); - terminal - .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) - .expect("draw"); - - insta::assert_snapshot!(terminal.backend()); - } } diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 84c325b6cbdf..7997625aae9a 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -3,13 +3,9 @@ use std::sync::Arc; use std::time::Duration; use crate::history_cell::HistoryCell; -use crate::history_cell::UserHistoryCell; use crate::key_hint; use crate::key_hint::KeyBinding; -use crate::render::Insets; -use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; -use crate::style::user_message_style; use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; @@ -17,7 +13,6 @@ use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; -use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; @@ -322,30 +317,29 @@ impl PagerView { } } -/// A renderable that caches its desired height. -struct CachedRenderable { - renderable: Box, +struct CachedParagraph { + paragraph: Paragraph<'static>, height: std::cell::Cell>, last_width: std::cell::Cell>, } -impl CachedRenderable { - fn new(renderable: impl Into>) -> Self { +impl CachedParagraph { + fn new(paragraph: Paragraph<'static>) -> Self { Self { - renderable: renderable.into(), + paragraph, height: std::cell::Cell::new(None), last_width: std::cell::Cell::new(None), } } } -impl Renderable for CachedRenderable { +impl Renderable for CachedParagraph { fn render(&self, area: Rect, buf: &mut Buffer) { - self.renderable.render(area, buf); + self.paragraph.render_ref(area, buf); } fn desired_height(&self, width: u16) -> u16 { if self.last_width.get() != Some(width) { - let height = self.renderable.desired_height(width); + let height = self.paragraph.line_count(width) as u16; self.height.set(Some(height)); self.last_width.set(Some(width)); } @@ -353,23 +347,6 @@ impl Renderable for CachedRenderable { } } -struct CellRenderable { - cell: Arc, - style: Style, -} - -impl Renderable for CellRenderable { - fn render(&self, area: Rect, buf: &mut Buffer) { - let p = - Paragraph::new(Text::from(self.cell.transcript_lines(area.width))).style(self.style); - p.render(area, buf); - } - - fn desired_height(&self, width: u16) -> u16 { - self.cell.desired_transcript_height(width) - } -} - pub(crate) struct TranscriptOverlay { view: PagerView, cells: Vec>, @@ -381,7 +358,7 @@ impl TranscriptOverlay { pub(crate) fn new(transcript_cells: Vec>) -> Self { Self { view: PagerView::new( - Self::render_cells(&transcript_cells, None), + Self::render_cells_to_texts(&transcript_cells, None), "T R A N S C R I P T".to_string(), usize::MAX, ), @@ -391,46 +368,46 @@ impl TranscriptOverlay { } } - fn render_cells( + fn render_cells_to_texts( cells: &[Arc], highlight_cell: Option, ) -> Vec> { - cells - .iter() - .enumerate() - .flat_map(|(i, c)| { - let mut v: Vec> = Vec::new(); - let mut cell_renderable = if c.as_any().is::() { - Box::new(CachedRenderable::new(CellRenderable { - cell: c.clone(), - style: if highlight_cell == Some(i) { - user_message_style().reversed() - } else { - user_message_style() - }, - })) as Box - } else { - Box::new(CachedRenderable::new(CellRenderable { - cell: c.clone(), - style: Style::default(), - })) as Box - }; - if !c.is_stream_continuation() && i > 0 { - cell_renderable = Box::new(InsetRenderable::new( - cell_renderable, - Insets::tlbr(1, 0, 0, 0), - )); - } - v.push(cell_renderable); - v - }) - .collect() + let mut texts: Vec> = Vec::new(); + let mut first = true; + for (idx, cell) in cells.iter().enumerate() { + let mut lines: Vec> = Vec::new(); + if !cell.is_stream_continuation() && !first { + lines.push(Line::from("")); + } + let cell_lines = if Some(idx) == highlight_cell { + cell.transcript_lines() + .into_iter() + .map(Stylize::reversed) + .collect() + } else { + cell.transcript_lines() + }; + lines.extend(cell_lines); + texts.push(Box::new(CachedParagraph::new( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + ))); + first = false; + } + texts } pub(crate) fn insert_cell(&mut self, cell: Arc) { let follow_bottom = self.view.is_scrolled_to_bottom(); + // Append as a new Text chunk (with a separating blank if needed) + let mut lines: Vec> = Vec::new(); + if !cell.is_stream_continuation() && !self.cells.is_empty() { + lines.push(Line::from("")); + } + lines.extend(cell.transcript_lines()); + self.view.renderables.push(Box::new(CachedParagraph::new( + Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }), + ))); self.cells.push(cell); - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); if follow_bottom { self.view.scroll_offset = usize::MAX; } @@ -438,7 +415,7 @@ impl TranscriptOverlay { pub(crate) fn set_highlight_cell(&mut self, cell: Option) { self.highlight_cell = cell; - self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell); + self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell); if let Some(idx) = self.highlight_cell { self.view.scroll_chunk_into_view(idx); } @@ -497,8 +474,12 @@ pub(crate) struct StaticOverlay { impl StaticOverlay { pub(crate) fn with_title(lines: Vec>, title: String) -> Self { - let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); - Self::with_renderables(vec![Box::new(CachedRenderable::new(paragraph))], title) + Self::with_renderables( + vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from( + lines, + ))))], + title, + ) } pub(crate) fn with_renderables(renderables: Vec>, title: String) -> Self { @@ -579,7 +560,6 @@ fn render_offset_content( #[cfg(test)] mod tests { use super::*; - use codex_core::protocol::ReviewDecision; use insta::assert_snapshot; use std::collections::HashMap; use std::path::PathBuf; @@ -587,7 +567,6 @@ mod tests { use std::time::Duration; use crate::exec_cell::CommandOutput; - use crate::history_cell; use crate::history_cell::HistoryCell; use crate::history_cell::new_patch_event; use codex_core::protocol::FileChange; @@ -606,7 +585,7 @@ mod tests { self.lines.clone() } - fn transcript_lines(&self, _width: u16) -> Vec> { + fn transcript_lines(&self) -> Vec> { self.lines.clone() } } @@ -711,8 +690,10 @@ mod tests { cells.push(apply_begin_cell); let apply_end_cell: Arc = - history_cell::new_approval_decision_cell(vec!["ls".into()], ReviewDecision::Approved) - .into(); + Arc::new(crate::history_cell::new_user_approval_decision(vec![ + "✓ Patch applied".green().bold().into(), + "src/foo.txt".dim().into(), + ])); cells.push(apply_end_cell); let mut exec_cell = crate::exec_cell::new_active_exec_command( @@ -809,18 +790,6 @@ mod tests { assert_snapshot!(term.backend()); } - #[test] - fn static_overlay_wraps_long_lines() { - let mut overlay = StaticOverlay::with_title( - vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()], - "S T A T I C".to_string(), - ); - let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term"); - term.draw(|f| overlay.render(f.area(), f.buffer_mut())) - .expect("draw"); - assert_snapshot!(term.backend()); - } - #[test] fn pager_view_content_height_counts_renderables() { let pv = PagerView::new( diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index e6d200cc3d6d..393aa3370598 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -1,146 +1,81 @@ -use ratatui::style::Style; +use codex_core::bash::try_parse_bash; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use std::sync::OnceLock; -use tree_sitter_highlight::Highlight; -use tree_sitter_highlight::HighlightConfiguration; -use tree_sitter_highlight::HighlightEvent; -use tree_sitter_highlight::Highlighter; -// Ref: https://github.com/tree-sitter/tree-sitter-bash/blob/master/queries/highlights.scm -#[derive(Copy, Clone)] -enum BashHighlight { - Comment, - Constant, - Embedded, - Function, - Keyword, - Number, - Operator, - Property, - String, -} - -impl BashHighlight { - const ALL: [Self; 9] = [ - Self::Comment, - Self::Constant, - Self::Embedded, - Self::Function, - Self::Keyword, - Self::Number, - Self::Operator, - Self::Property, - Self::String, - ]; - - const fn as_str(self) -> &'static str { - match self { - Self::Comment => "comment", - Self::Constant => "constant", - Self::Embedded => "embedded", - Self::Function => "function", - Self::Keyword => "keyword", - Self::Number => "number", - Self::Operator => "operator", - Self::Property => "property", - Self::String => "string", - } - } - - fn style(self) -> Style { - match self { - Self::Comment | Self::Operator | Self::String => Style::default().dim(), - _ => Style::default(), +/// Convert the full bash script into per-line styled content by first +/// computing operator-dimmed spans across the entire script, then splitting +/// by newlines and dimming heredoc body lines. Performs a single parse and +/// reuses it for both highlighting and heredoc detection. +pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec> { + // Parse once; use the tree for both highlighting and heredoc body detection. + let spans: Vec> = if let Some(tree) = try_parse_bash(script) { + // Single walk: collect operator ranges and heredoc rows. + let root = tree.root_node(); + let mut cursor = root.walk(); + let mut stack = vec![root]; + let mut ranges: Vec<(usize, usize)> = Vec::new(); + while let Some(node) = stack.pop() { + if !node.is_named() && !node.is_extra() { + let kind = node.kind(); + let is_quote = matches!(kind, "\"" | "'" | "`"); + let is_whitespace = kind.trim().is_empty(); + if !is_quote && !is_whitespace { + ranges.push((node.start_byte(), node.end_byte())); + } + } else if node.kind() == "heredoc_body" { + ranges.push((node.start_byte(), node.end_byte())); + } + for child in node.children(&mut cursor) { + stack.push(child); + } } - } -} - -static HIGHLIGHT_CONFIG: OnceLock = OnceLock::new(); - -fn highlight_names() -> &'static [&'static str] { - static NAMES: OnceLock<[&'static str; BashHighlight::ALL.len()]> = OnceLock::new(); - NAMES - .get_or_init(|| BashHighlight::ALL.map(BashHighlight::as_str)) - .as_slice() -} - -fn highlight_config() -> &'static HighlightConfiguration { - HIGHLIGHT_CONFIG.get_or_init(|| { - let language = tree_sitter_bash::LANGUAGE.into(); - #[expect(clippy::expect_used)] - let mut config = HighlightConfiguration::new( - language, - "bash", - tree_sitter_bash::HIGHLIGHT_QUERY, - "", - "", - ) - .expect("load bash highlight query"); - config.configure(highlight_names()); - config - }) -} - -fn highlight_for(highlight: Highlight) -> BashHighlight { - BashHighlight::ALL[highlight.0] -} - -fn push_segment(lines: &mut Vec>, segment: &str, style: Option