From 53d8a161854b08f3b39a3d44e89dd2f04eeaf009 Mon Sep 17 00:00:00 2001 From: Fouad Matin Date: Sat, 27 Sep 2025 12:07:19 -0700 Subject: [PATCH 1/2] Revert "Edit the spacing in shortcuts (#4330)" This reverts commit 5c67dc3af12ab5426ba5002a90a50b24f1dcf8f9. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 28 +++---- codex-rs/tui/src/bottom_pane/footer.rs | 76 +++++++++---------- ...__tests__footer_mode_shortcut_overlay.snap | 6 +- ...tests__footer_shortcuts_shift_and_esc.snap | 6 +- 4 files changed, 53 insertions(+), 63 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 541f3817b4..3b02c59334 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1204,25 +1204,17 @@ impl ChatComposer { } fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { - if key_event.kind != KeyEventKind::Press { - return false; - } - - let toggles = match key_event.code { - KeyCode::Char('?') if key_event.modifiers.is_empty() => true, - KeyCode::BackTab => true, - KeyCode::Tab if key_event.modifiers.contains(KeyModifiers::SHIFT) => true, - _ => false, - }; - - if !toggles { - return false; + if key_event.kind == KeyEventKind::Press + && key_event.modifiers.is_empty() + && matches!(key_event.code, KeyCode::Char('?')) + { + let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); + let changed = next != self.footer_mode; + self.footer_mode = next; + changed + } else { + false } - - let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); - let changed = next != self.footer_mode; - self.footer_mode = next; - changed } fn footer_props(&self) -> FooterProps { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index aa0659ed9f..2e63493e31 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -140,7 +140,6 @@ fn build_columns(entries: Vec) -> Vec> { const COLUMNS: usize = 3; const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28]; - const MIN_PADDED_WIDTHS: [usize; COLUMNS - 1] = [22, 0]; let rows = entries.len().div_ceil(COLUMNS); let mut column_widths = [0usize; COLUMNS]; @@ -161,8 +160,7 @@ fn build_columns(entries: Vec) -> Vec> { let entry = &entries[idx]; if col < COLUMNS - 1 { let max_width = MAX_PADDED_WIDTHS[col]; - let mut target_width = column_widths[col]; - target_width = target_width.max(MIN_PADDED_WIDTHS[col]).min(max_width); + let target_width = column_widths[col].min(max_width); let pad_width = target_width + 2; line.push_str(&format!("{entry:) -> Vec> { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ShortcutId { Commands, - InsertNewline, - ChangeMode, FilePaths, + InsertNewline, PasteImage, - EditPrevious, Quit, ShowTranscript, + ToggleOverlay, + EditPrevious, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -274,6 +272,17 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for commands", }, + ShortcutDescriptor { + id: ShortcutId::FilePaths, + bindings: &[ShortcutBinding { + code: KeyCode::Char('@'), + modifiers: KeyModifiers::NONE, + overlay_text: "@", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for file paths", + }, ShortcutDescriptor { id: ShortcutId::InsertNewline, bindings: &[ @@ -293,28 +302,6 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, - ShortcutDescriptor { - id: ShortcutId::ChangeMode, - bindings: &[ShortcutBinding { - code: KeyCode::BackTab, - modifiers: KeyModifiers::SHIFT, - overlay_text: "shift + tab", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to change mode", - }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - code: KeyCode::Char('@'), - modifiers: KeyModifiers::NONE, - overlay_text: "@", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", - }, ShortcutDescriptor { id: ShortcutId::PasteImage, bindings: &[ShortcutBinding { @@ -326,17 +313,6 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to paste images", }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - overlay_text: "esc", - condition: DisplayCondition::Always, - }], - prefix: "", - label: "", - }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { @@ -359,6 +335,28 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to view transcript", }, + ShortcutDescriptor { + id: ShortcutId::ToggleOverlay, + bindings: &[ShortcutBinding { + code: KeyCode::Char('?'), + modifiers: KeyModifiers::NONE, + overlay_text: "?", + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to hide shortcuts", + }, + ShortcutDescriptor { + id: ShortcutId::EditPrevious, + bindings: &[ShortcutBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + overlay_text: "esc", + condition: DisplayCondition::Always, + }], + prefix: "", + label: "", + }, ]; #[cfg(test)] 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 9633aab2ae..c08e7f2d83 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,6 +10,6 @@ expression: terminal.backend() " " " " " " -" / for commands shift + enter for newline shift + tab to change mode " -" @ for file paths ctrl + v to paste images esc again to edit previous message " -" ctrl + c to exit ctrl + t to view transcript " +" / for commands @ for file paths shift + enter for newline " +" ctrl + v to paste images ctrl + c to exit ctrl + t to view transcript " +" ? to hide shortcuts 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 111c14136a..96c10733dc 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,6 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands shift + enter for newline shift + tab to change m" -" @ for file paths ctrl + v to paste images esc again to edit previ" -" ctrl + c to exit ctrl + t to view transcript " +" / for commands @ for file paths shift + enter for ne" +" ctrl + v to paste images ctrl + c to exit ctrl + t to view tra" +" ? to hide shortcuts esc again to edit previous message " From bf1b20463c30f9c059ff4ce2904543b7d9d0b49a Mon Sep 17 00:00:00 2001 From: Fouad Matin Date: Sat, 27 Sep 2025 12:07:37 -0700 Subject: [PATCH 2/2] Revert "Add "? for shortcuts" (#4316)" This reverts commit 2719fdd12ab2eb325e62828e67866d8a1b78a3a9. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 239 ++------- codex-rs/tui/src/bottom_pane/footer.rs | 461 ++++++++---------- codex-rs/tui/src/bottom_pane/mod.rs | 8 + ...mposer__tests__backspace_after_pastes.snap | 2 +- ...tom_pane__chat_composer__tests__empty.snap | 2 +- ...__tests__footer_mode_ctrl_c_interrupt.snap | 13 - ...poser__tests__footer_mode_ctrl_c_quit.snap | 13 - ...sts__footer_mode_ctrl_c_then_esc_hint.snap | 13 - ...tests__footer_mode_esc_hint_backtrack.snap | 13 - ...ts__footer_mode_esc_hint_from_overlay.snap | 13 - ...r_mode_overlay_then_external_esc_hint.snap | 13 - ...__tests__footer_mode_shortcut_overlay.snap | 15 - ...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 | 4 +- ...er__tests__footer_ctrl_c_quit_running.snap | 4 +- ...__footer__tests__footer_esc_hint_idle.snap | 5 - ...footer__tests__footer_esc_hint_primed.snap | 5 - ...oter__tests__footer_shortcuts_default.snap | 4 +- ...tests__footer_shortcuts_shift_and_esc.snap | 6 +- codex-rs/tui/src/chatwidget.rs | 2 + ...exec_and_status_layout_vt100_snapshot.snap | 3 +- ...atwidget__tests__status_widget_active.snap | 3 +- codex-rs/tui/src/key_hint.rs | 26 + 25 files changed, 286 insertions(+), 587 deletions(-) delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap delete mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3b02c59334..b3c1eea6d9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,3 +1,4 @@ +use codex_core::protocol::TokenUsageInfo; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -18,14 +19,8 @@ use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; -use super::footer::FooterMode; use super::footer::FooterProps; -use super::footer::esc_hint_mode; -use super::footer::footer_height; -use super::footer::prompt_mode; use super::footer::render_footer; -use super::footer::reset_mode_after_activity; -use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; use crate::bottom_pane::paste_burst::FlushResult; @@ -79,6 +74,7 @@ pub(crate) struct ChatComposer { dismissed_file_popup_token: Option, current_file_query: Option, pending_pastes: Vec<(String, String)>, + token_usage_info: Option, has_focus: bool, attached_images: Vec, placeholder_text: String, @@ -88,7 +84,6 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, - footer_mode: FooterMode, } /// Popup state – at most one can be visible at any time. @@ -98,7 +93,7 @@ enum ActivePopup { File(FileSearchPopup), } -const FOOTER_SPACING_HEIGHT: u16 = 1; +const FOOTER_HINT_HEIGHT: u16 = 1; impl ChatComposer { pub fn new( @@ -122,6 +117,7 @@ impl ChatComposer { dismissed_file_popup_token: None, current_file_query: None, pending_pastes: Vec::new(), + token_usage_info: None, has_focus: has_input_focus, attached_images: Vec::new(), placeholder_text, @@ -129,7 +125,6 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), - footer_mode: FooterMode::ShortcutPrompt, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -137,41 +132,26 @@ impl ChatComposer { } pub fn desired_height(&self, width: u16) -> u16 { - let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - let footer_total_height = footer_hint_height + footer_spacing; self.textarea .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + 2 + match &self.active_popup { - ActivePopup::None => footer_total_height, + ActivePopup::None => FOOTER_HINT_HEIGHT, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), } } fn layout_areas(&self, area: Rect) -> [Rect; 3] { - let footer_props = self.footer_props(); - let footer_hint_height = footer_height(footer_props); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - let footer_total_height = footer_hint_height + footer_spacing; let popup_constraint = match &self.active_popup { ActivePopup::Command(popup) => { Constraint::Max(popup.calculate_required_height(area.width)) } ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), - ActivePopup::None => Constraint::Max(footer_total_height), + ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT), }; let mut area = area; + // Leave an empty row at the top, unless there isn't room. if area.height > 1 { area.height -= 1; area.y += 1; @@ -195,6 +175,13 @@ impl ChatComposer { self.textarea.is_empty() } + /// Update the cached *context-left* percentage and refresh the placeholder + /// text. The UI relies on the placeholder to convey the remaining + /// context when the composer is empty. + pub(crate) fn set_token_usage(&mut self, token_info: Option) { + self.token_usage_info = token_info; + } + /// Record the history metadata advertised by `SessionConfiguredEvent` so /// that the composer can navigate cross-session history. pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { @@ -332,11 +319,6 @@ impl ChatComposer { pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { self.ctrl_c_quit_hint = show; - if show { - self.footer_mode = prompt_mode(); - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } self.set_has_focus(has_focus); } @@ -372,18 +354,6 @@ impl ChatComposer { /// Handle key event when the slash-command popup is visible. fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } let ActivePopup::Command(popup) = &mut self.active_popup else { unreachable!(); }; @@ -508,18 +478,6 @@ impl ChatComposer { /// Handle key events when file search popup is visible. fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } let ActivePopup::File(popup) = &mut self.active_popup else { unreachable!(); }; @@ -771,18 +729,6 @@ impl ChatComposer { /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { - if self.handle_shortcut_overlay_key(&key_event) { - return (InputResult::None, true); - } - if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); - } - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } match key_event { KeyEvent { code: KeyCode::Char('d'), @@ -926,10 +872,6 @@ impl ChatComposer { let now = Instant::now(); self.handle_paste_burst_flush(now); - if !matches!(input.code, KeyCode::Esc) { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } - // If we're capturing a burst and receive Enter, accumulate it instead of inserting. if matches!(input.code, KeyCode::Enter) && self.paste_burst.is_active() @@ -1203,39 +1145,6 @@ impl ChatComposer { false } - fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { - if key_event.kind == KeyEventKind::Press - && key_event.modifiers.is_empty() - && matches!(key_event.code, KeyCode::Char('?')) - { - let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint); - let changed = next != self.footer_mode; - self.footer_mode = next; - changed - } else { - false - } - } - - fn footer_props(&self) -> FooterProps { - FooterProps { - mode: self.footer_mode(), - esc_backtrack_hint: self.esc_backtrack_hint, - use_shift_enter_hint: self.use_shift_enter_hint, - is_task_running: self.is_task_running, - } - } - - fn footer_mode(&self) -> FooterMode { - if matches!(self.footer_mode, FooterMode::EscHint) { - FooterMode::EscHint - } else if self.ctrl_c_quit_hint { - FooterMode::CtrlCReminder - } else { - self.footer_mode - } - } - /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. @@ -1319,18 +1228,10 @@ impl ChatComposer { pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; - if running { - self.footer_mode = prompt_mode(); - } } pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { self.esc_backtrack_hint = show; - if show { - self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - } else { - self.footer_mode = reset_mode_after_activity(self.footer_mode); - } } } @@ -1351,26 +1252,20 @@ impl WidgetRef for ChatComposer { popup.render_ref(popup_rect, buf); } ActivePopup::None => { - let footer_hint_height = footer_height(self.footer_props()); - let footer_spacing = if footer_hint_height > 0 { - FOOTER_SPACING_HEIGHT - } else { - 0 - }; - let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - let mut footer_rect = hint_rect; - footer_rect.x = footer_rect.x.saturating_add(2); - footer_rect.width = footer_rect.width.saturating_sub(2); - render_footer(footer_rect, buf, self.footer_props()); + let mut hint_rect = popup_rect; + hint_rect.x += 2; + hint_rect.width = hint_rect.width.saturating_sub(2); + render_footer( + hint_rect, + buf, + FooterProps { + ctrl_c_quit_hint: self.ctrl_c_quit_hint, + is_task_running: self.is_task_running, + esc_backtrack_hint: self.esc_backtrack_hint, + use_shift_enter_hint: self.use_shift_enter_hint, + token_usage_info: self.token_usage_info.as_ref(), + }, + ); } } let style = user_message_style(terminal_palette::default_bg()); @@ -1409,7 +1304,6 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; - use crate::bottom_pane::footer::footer_height; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -1440,7 +1334,7 @@ mod tests { 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") { + if row.contains(" send") { hint_row = Some((y, row)); break; } @@ -1467,81 +1361,6 @@ mod tests { ); } - fn snapshot_composer_state(name: &str, enhanced_keys_supported: bool, setup: F) - where - F: FnOnce(&mut ChatComposer), - { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let width = 100; - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - enhanced_keys_supported, - "Ask Codex to do anything".to_string(), - false, - ); - setup(&mut composer); - let footer_lines = footer_height(composer.footer_props()); - let height = footer_lines + 8; - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|f| f.render_widget_ref(composer, f.area())) - .unwrap(); - insta::assert_snapshot!(name, terminal.backend()); - } - - #[test] - fn footer_mode_snapshots() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| { - composer.set_esc_backtrack_hint(true); - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| { - composer.set_ctrl_c_quit_hint(true, true); - }); - - snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| { - composer.set_task_running(true); - composer.set_ctrl_c_quit_hint(true, true); - }); - - snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| { - composer.set_ctrl_c_quit_hint(true, true); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| { - let _ = - composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| { - composer.set_esc_backtrack_hint(true); - let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - }); - - snapshot_composer_state( - "footer_mode_overlay_then_external_esc_hint", - true, - |composer| { - let _ = composer - .handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - composer.set_esc_backtrack_hint(true); - }, - ); - } - #[test] fn test_current_at_token_basic_cases() { let test_cases = vec![ diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e63493e31..77e2c106e5 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,190 +1,149 @@ +use codex_core::protocol::TokenUsageInfo; +use codex_protocol::num_format::format_si_suffix; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +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 ratatui::widgets::WidgetRef; +use crate::key_hint; + #[derive(Clone, Copy, Debug)] -pub(crate) struct FooterProps { - pub(crate) mode: FooterMode, +pub(crate) struct FooterProps<'a> { + pub(crate) ctrl_c_quit_hint: bool, + pub(crate) is_task_running: bool, pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, - pub(crate) is_task_running: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum FooterMode { - CtrlCReminder, - ShortcutPrompt, - ShortcutOverlay, - EscHint, -} - -pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode { - if ctrl_c_hint { - return current; - } - match current { - FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt, - _ => FooterMode::ShortcutOverlay, - } -} - -pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode { - if is_task_running { - current - } else { - FooterMode::EscHint - } -} - -pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode { - match current { - FooterMode::EscHint | FooterMode::ShortcutOverlay => FooterMode::ShortcutPrompt, - other => other, - } -} - -pub(crate) fn prompt_mode() -> FooterMode { - FooterMode::ShortcutPrompt -} - -pub(crate) fn footer_height(props: FooterProps) -> u16 { - footer_lines(props).len() as u16 -} - -pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { - let lines = footer_lines(props); - for (idx, line) in lines.into_iter().enumerate() { - let y = area.y + idx as u16; - if y >= area.y + area.height { - break; - } - let row = Rect::new(area.x, y, area.width, 1); - line.render_ref(row, buf); - } -} - -fn footer_lines(props: FooterProps) -> Vec> { - match props.mode { - FooterMode::CtrlCReminder => { - vec![ctrl_c_reminder_line(CtrlCReminderState { - is_task_running: props.is_task_running, - })] - } - FooterMode::ShortcutPrompt => vec![Line::from(vec!["? for shortcuts".dim()])], - FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_task_running: props.is_task_running, - }), - FooterMode::EscHint => { - vec![esc_hint_line(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - is_task_running: props.is_task_running, - })] - } - } + pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, } #[derive(Clone, Copy, Debug)] struct CtrlCReminderState { - is_task_running: bool, + pub(crate) is_task_running: bool, } #[derive(Clone, Copy, Debug)] struct ShortcutsState { - use_shift_enter_hint: bool, - esc_backtrack_hint: bool, - is_task_running: bool, + pub(crate) use_shift_enter_hint: bool, + pub(crate) esc_backtrack_hint: bool, } -fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { - let action = if state.is_task_running { - "interrupt" - } else { - "quit" - }; - Line::from(vec![ - Span::from(format!(" ctrl + c again to {action}")).dim(), - ]) +#[derive(Clone, Copy, Debug)] +enum FooterContent { + Shortcuts(ShortcutsState), + CtrlCReminder(CtrlCReminderState), } -fn esc_hint_line(state: ShortcutsState) -> Line<'static> { - let text = if state.esc_backtrack_hint { - " esc again to edit previous message" +pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) { + let content = if props.ctrl_c_quit_hint { + FooterContent::CtrlCReminder(CtrlCReminderState { + is_task_running: props.is_task_running, + }) } else { - " esc esc to edit previous message" + FooterContent::Shortcuts(ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + }) }; - Line::from(vec![Span::from(text).dim()]) -} -fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { - let mut rendered = Vec::new(); - for descriptor in SHORTCUTS { - if let Some(text) = descriptor.overlay_entry(state) { - rendered.push(text); - } + let mut spans = footer_spans(content); + if let Some(token_usage_info) = props.token_usage_info { + append_token_usage_spans(&mut spans, token_usage_info); } - build_columns(rendered) + + let spans = spans + .into_iter() + .map(|span| span.patch_style(Style::default().dim())) + .collect::>(); + Line::from(spans).render_ref(area, buf); } -fn build_columns(entries: Vec) -> Vec> { - if entries.is_empty() { - return Vec::new(); +fn footer_spans(content: FooterContent) -> Vec> { + match content { + FooterContent::Shortcuts(state) => shortcuts_spans(state), + FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), } +} - const COLUMNS: usize = 3; - const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28]; - - let rows = entries.len().div_ceil(COLUMNS); - let mut column_widths = [0usize; COLUMNS]; +fn append_token_usage_spans(spans: &mut Vec>, token_usage_info: &TokenUsageInfo) { + let token_usage = &token_usage_info.total_token_usage; + spans.push(" ".into()); + spans.push( + Span::from(format!( + "{} tokens used", + format_si_suffix(token_usage.blended_total()) + )) + .style(Style::default().add_modifier(Modifier::DIM)), + ); + + let last_token_usage = &token_usage_info.last_token_usage; + if let Some(context_window) = token_usage_info.model_context_window { + let percent_remaining: u8 = if context_window > 0 { + last_token_usage.percent_of_context_window_remaining(context_window) + } else { + 100 + }; - for (idx, entry) in entries.iter().enumerate() { - let column = idx % COLUMNS; - column_widths[column] = column_widths[column].max(entry.len()); + let context_style = if percent_remaining < 20 { + Style::default().fg(Color::Yellow) + } else { + Style::default().add_modifier(Modifier::DIM) + }; + spans.push(" ".into()); + spans.push(Span::styled( + format!("{percent_remaining}% context left"), + context_style, + )); } +} - let mut lines = Vec::new(); - for row in 0..rows { - let mut line = String::from(" "); - for col in 0..COLUMNS { - let idx = row * COLUMNS + col; - if idx >= entries.len() { - continue; - } - let entry = &entries[idx]; - if col < COLUMNS - 1 { - let max_width = MAX_PADDED_WIDTHS[col]; - let target_width = column_widths[col].min(max_width); - let pad_width = target_width + 2; - line.push_str(&format!("{entry: Vec> { + let mut spans = Vec::new(); + for descriptor in SHORTCUTS { + if let Some(segment) = descriptor.footer_segment(state) { + if !segment.prefix.is_empty() { + spans.push(segment.prefix.into()); } + spans.push(segment.binding.span()); + spans.push(segment.label.into()); } - lines.push(Line::from(vec![Span::from(line).dim()])); } + spans +} - lines +fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec> { + let followup = if state.is_task_running { + " to interrupt" + } else { + " to quit" + }; + vec![ + " ".into(), + key_hint::ctrl('C'), + " again".into(), + followup.into(), + ] } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug)] +struct FooterSegment { + prefix: &'static str, + binding: ShortcutBinding, + label: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] enum ShortcutId { - Commands, - FilePaths, + Send, InsertNewline, - PasteImage, - Quit, ShowTranscript, - ToggleOverlay, + Quit, EditPrevious, } @@ -192,13 +151,30 @@ enum ShortcutId { struct ShortcutBinding { code: KeyCode, modifiers: KeyModifiers, - overlay_text: &'static str, + display: ShortcutDisplay, condition: DisplayCondition, } impl ShortcutBinding { - fn matches(&self, state: ShortcutsState) -> bool { - self.condition.matches(state) + fn span(&self) -> Span<'static> { + self.display.into_span() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShortcutDisplay { + Plain(&'static str), + Ctrl(char), + Shift(char), +} + +impl ShortcutDisplay { + fn into_span(self) -> Span<'static> { + match self { + ShortcutDisplay::Plain(text) => key_hint::plain(text), + ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch), + ShortcutDisplay::Shift(ch) => key_hint::shift(ch), + } } } @@ -222,66 +198,49 @@ impl DisplayCondition { struct ShortcutDescriptor { id: ShortcutId, bindings: &'static [ShortcutBinding], - prefix: &'static str, - label: &'static str, + footer_label: &'static str, + footer_prefix: &'static str, } impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> { - self.bindings.iter().find(|binding| binding.matches(state)) + fn binding_for(&self, state: ShortcutsState) -> Option { + self.bindings + .iter() + .find(|binding| binding.condition.matches(state)) + .copied() } - fn overlay_entry(&self, state: ShortcutsState) -> Option { + fn should_show(&self, state: ShortcutsState) -> bool { + match self.id { + ShortcutId::EditPrevious => state.esc_backtrack_hint, + _ => true, + } + } + + fn footer_segment(&self, state: ShortcutsState) -> Option { + if !self.should_show(state) { + return None; + } let binding = self.binding_for(state)?; - let label = match self.id { - ShortcutId::Quit => { - if state.is_task_running { - " to interrupt" - } else { - self.label - } - } - ShortcutId::EditPrevious => { - if state.esc_backtrack_hint { - " again to edit previous message" - } else { - " esc to edit previous message" - } - } - _ => self.label, - }; - let text = match self.id { - ShortcutId::Quit if state.is_task_running => { - format!("{}{} to interrupt", self.prefix, binding.overlay_text) - } - _ => format!("{}{}{}", self.prefix, binding.overlay_text, label), - }; - Some(text) + Some(FooterSegment { + prefix: self.footer_prefix, + binding, + label: self.footer_label, + }) } } const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutDescriptor { - id: ShortcutId::Commands, + id: ShortcutId::Send, bindings: &[ShortcutBinding { - code: KeyCode::Char('/'), + code: KeyCode::Enter, modifiers: KeyModifiers::NONE, - overlay_text: "/", + display: ShortcutDisplay::Plain("⏎"), condition: DisplayCondition::Always, }], - prefix: "", - label: " for commands", - }, - ShortcutDescriptor { - id: ShortcutId::FilePaths, - bindings: &[ShortcutBinding { - code: KeyCode::Char('@'), - modifiers: KeyModifiers::NONE, - overlay_text: "@", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " for file paths", + footer_label: " send ", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::InsertNewline, @@ -289,154 +248,138 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ ShortcutBinding { code: KeyCode::Enter, modifiers: KeyModifiers::SHIFT, - overlay_text: "shift + enter", + display: ShortcutDisplay::Shift('⏎'), condition: DisplayCondition::WhenShiftEnterHint, }, ShortcutBinding { code: KeyCode::Char('j'), modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + j", + display: ShortcutDisplay::Ctrl('J'), condition: DisplayCondition::WhenNotShiftEnterHint, }, ], - prefix: "", - label: " for newline", + footer_label: " newline ", + footer_prefix: "", }, ShortcutDescriptor { - id: ShortcutId::PasteImage, + id: ShortcutId::ShowTranscript, bindings: &[ShortcutBinding { - code: KeyCode::Char('v'), + code: KeyCode::Char('t'), modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + v", + display: ShortcutDisplay::Ctrl('T'), condition: DisplayCondition::Always, }], - prefix: "", - label: " to paste images", + footer_label: " transcript ", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::Quit, bindings: &[ShortcutBinding { code: KeyCode::Char('c'), modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + c", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to exit", - }, - ShortcutDescriptor { - id: ShortcutId::ShowTranscript, - bindings: &[ShortcutBinding { - code: KeyCode::Char('t'), - modifiers: KeyModifiers::CONTROL, - overlay_text: "ctrl + t", + display: ShortcutDisplay::Ctrl('C'), condition: DisplayCondition::Always, }], - prefix: "", - label: " to view transcript", - }, - ShortcutDescriptor { - id: ShortcutId::ToggleOverlay, - bindings: &[ShortcutBinding { - code: KeyCode::Char('?'), - modifiers: KeyModifiers::NONE, - overlay_text: "?", - condition: DisplayCondition::Always, - }], - prefix: "", - label: " to hide shortcuts", + footer_label: " quit", + footer_prefix: "", }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, - overlay_text: "esc", + display: ShortcutDisplay::Plain("Esc"), condition: DisplayCondition::Always, }], - prefix: "", - label: "", + footer_label: " edit prev", + footer_prefix: " ", }, ]; #[cfg(test)] mod tests { use super::*; + use codex_core::protocol::TokenUsage; use insta::assert_snapshot; use ratatui::Terminal; use ratatui::backend::TestBackend; - fn snapshot_footer(name: &str, props: FooterProps) { - let height = footer_height(props).max(1); - let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); + fn snapshot_footer(name: &str, props: FooterProps<'_>) { + let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); terminal .draw(|f| { - let area = Rect::new(0, 0, f.area().width, height); + let area = Rect::new(0, 0, f.area().width, 1); render_footer(area, f.buffer_mut(), props); }) .unwrap(); assert_snapshot!(name, terminal.backend()); } + fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo { + let usage = TokenUsage { + input_tokens: total_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens, + }; + let last = TokenUsage { + input_tokens: last_tokens, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_output_tokens: 0, + total_tokens: last_tokens, + }; + TokenUsageInfo { + total_token_usage: usage, + last_token_usage: last, + model_context_window: Some(context_window), + } + } + #[test] fn footer_snapshots() { snapshot_footer( "footer_shortcuts_default", FooterProps { - mode: FooterMode::ShortcutPrompt, + ctrl_c_quit_hint: false, + is_task_running: false, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); snapshot_footer( "footer_shortcuts_shift_and_esc", FooterProps { - mode: FooterMode::ShortcutOverlay, + ctrl_c_quit_hint: false, + is_task_running: false, esc_backtrack_hint: true, use_shift_enter_hint: true, - is_task_running: false, + token_usage_info: Some(&token_usage(4_200, 900, 8_000)), }, ); snapshot_footer( "footer_ctrl_c_quit_idle", FooterProps { - mode: FooterMode::CtrlCReminder, + ctrl_c_quit_hint: true, + is_task_running: false, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); snapshot_footer( "footer_ctrl_c_quit_running", FooterProps { - mode: FooterMode::CtrlCReminder, - esc_backtrack_hint: false, - use_shift_enter_hint: false, + ctrl_c_quit_hint: true, is_task_running: true, - }, - ); - - snapshot_footer( - "footer_esc_hint_idle", - FooterProps { - mode: FooterMode::EscHint, esc_backtrack_hint: false, use_shift_enter_hint: false, - is_task_running: false, - }, - ); - - snapshot_footer( - "footer_esc_hint_primed", - FooterProps { - mode: FooterMode::EscHint, - esc_backtrack_hint: true, - use_shift_enter_hint: false, - is_task_running: false, + token_usage_info: None, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index c65067b581..a01ffb498f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::protocol::TokenUsageInfo; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -376,6 +377,13 @@ impl BottomPane { !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } + /// Update the *context-window remaining* indicator in the composer. This + /// is forwarded directly to the underlying `ChatComposer`. + pub(crate) fn set_token_usage(&mut self, token_info: Option) { + self.composer.set_token_usage(token_info); + self.request_redraw(); + } + pub(crate) fn show_view(&mut self, view: Box) { self.push_view(view); } 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 3c84d708b0..3f57cab0f6 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() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " 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 3bdd7260ff..beccbaf7c7 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() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " 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 deleted file mode 100644 index 6f8ad7b98c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" 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 deleted file mode 100644 index f7917277fd..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_quit.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" 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 deleted file mode 100644 index 99d263da31..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_then_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" 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 deleted file mode 100644 index 404aa48d8c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_backtrack.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" 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 deleted file mode 100644 index 99d263da31..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_esc_hint_from_overlay.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" 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 deleted file mode 100644 index 404aa48d8c..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_overlay_then_external_esc_hint.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" 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 deleted file mode 100644 index c08e7f2d83..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ /dev/null @@ -1,15 +0,0 @@ ---- -source: tui/src/bottom_pane/chat_composer.rs -expression: terminal.backend() ---- -" " -"› Ask Codex to do anything " -" " -" " -" " -" " -" " -" " -" / for commands @ for file paths shift + enter for newline " -" ctrl + v to paste images ctrl + c to exit ctrl + t to view transcript " -" ? to hide shortcuts esc again to edit previous message " 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 53cfb9d76d..263c7cc549 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() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " 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 52474a1fd8..beb2d8e464 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() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " 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 d4c6bdedf6..41f0f03d8b 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() " " " " " " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " 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 31a1b743b8..32482ba1cd 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 @@ -2,4 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ctrl + c again to quit " +" ⌃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 9979372a1b..8c9c2b0d46 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 @@ -2,4 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ctrl + c again to interrupt " +" ⌃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 deleted file mode 100644 index b2333b025f..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_idle.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -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 deleted file mode 100644 index 20f9b178b4..0000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_esc_hint_primed.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/bottom_pane/footer.rs -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_default.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_default.snap index aff75fdcdf..8c8a10df85 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,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -"? for shortcuts " +"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +" " +" " 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 96c10733dc..9b94d89f98 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,6 +2,6 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands @ for file paths shift + enter for ne" -" ctrl + v to paste images ctrl + c to exit ctrl + t to view tra" -" ? to hide shortcuts esc again to edit previous message " +"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" +" " +" " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9a36addb0a..724feb29f2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -395,6 +395,7 @@ impl ChatWidget { pub(crate) fn set_token_info(&mut self, info: Option) { if info.is_some() { + self.bottom_pane.set_token_usage(info.clone()); self.token_info = info; } } @@ -1974,6 +1975,7 @@ impl ChatWidget { pub(crate) fn clear_token_usage(&mut self) { self.token_info = None; + self.bottom_pane.set_token_usage(None); } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { 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 1698f2c77f..248a57f8f3 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 @@ -14,5 +14,4 @@ expression: term.backend().vt100().screen().contents() › Summarize recent commits - - ? for shortcuts + ⏎ send ⌃J newline ⌃T transcript ⌃C quit 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 index fcd146282b..9c94206a44 100644 --- 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 @@ -8,6 +8,5 @@ expression: terminal.backend() " " "› Ask Codex to do anything " " " -" " -" ? for shortcuts " +" ⏎ send ⌃J newline ⌃T transcript ⌃C quit " " " diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f68a493e9c..80f91d50b2 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -10,6 +10,20 @@ const ALT_PREFIX: &str = "⌥"; #[cfg(all(not(test), not(target_os = "macos")))] const ALT_PREFIX: &str = "Alt+"; +#[cfg(test)] +const CTRL_PREFIX: &str = "⌃"; +#[cfg(all(not(test), target_os = "macos"))] +const CTRL_PREFIX: &str = "⌃"; +#[cfg(all(not(test), not(target_os = "macos")))] +const CTRL_PREFIX: &str = "Ctrl+"; + +#[cfg(test)] +const SHIFT_PREFIX: &str = "⇧"; +#[cfg(all(not(test), target_os = "macos"))] +const SHIFT_PREFIX: &str = "⇧"; +#[cfg(all(not(test), not(target_os = "macos")))] +const SHIFT_PREFIX: &str = "Shift+"; + fn key_hint_style() -> Style { Style::default().bold() } @@ -18,6 +32,18 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> { Span::styled(format!("{prefix}{key}"), key_hint_style()) } +pub(crate) fn ctrl(key: impl Display) -> Span<'static> { + modifier_span(CTRL_PREFIX, key) +} + pub(crate) fn alt(key: impl Display) -> Span<'static> { modifier_span(ALT_PREFIX, key) } + +pub(crate) fn shift(key: impl Display) -> Span<'static> { + modifier_span(SHIFT_PREFIX, key) +} + +pub(crate) fn plain(key: impl Display) -> Span<'static> { + Span::styled(format!("{key}"), key_hint_style()) +}