From 665e22d69d08687b052b41b2b29dbf1733ecb5c3 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 26 Sep 2025 12:29:27 -0700 Subject: [PATCH] shortcuts --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 273 ++++++++-- codex-rs/tui/src/bottom_pane/footer.rs | 509 +++++++----------- codex-rs/tui/src/bottom_pane/mod.rs | 8 - ...mposer__tests__backspace_after_pastes.snap | 3 +- ...tom_pane__chat_composer__tests__empty.snap | 2 +- ...tom_pane__chat_composer__tests__large.snap | 3 +- ...chat_composer__tests__multiple_pastes.snap | 3 +- ...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 +- ...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 | 2 +- ...atwidget__tests__status_widget_active.snap | 2 +- codex-rs/tui/src/key_hint.rs | 26 - 16 files changed, 461 insertions(+), 392 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 55762979ad..91cf94d8a1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,4 +1,3 @@ -use codex_core::protocol::TokenUsageInfo; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -24,7 +23,9 @@ 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::footer_height; use super::footer::render_footer; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; @@ -77,7 +78,6 @@ 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, @@ -87,6 +87,7 @@ pub(crate) struct ChatComposer { // When true, disables paste-burst logic and inserts characters immediately. disable_paste_burst: bool, custom_prompts: Vec, + footer_state: ComposerFooterState, } /// Popup state – at most one can be visible at any time. @@ -96,9 +97,24 @@ enum ActivePopup { File(FileSearchPopup), } -const FOOTER_HINT_HEIGHT: u16 = 1; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ComposerFooterState { + ShortcutPrompt, + ShortcutOverlay, + EscHint, +} + +impl From for FooterMode { + fn from(value: ComposerFooterState) -> Self { + match value { + ComposerFooterState::ShortcutPrompt => FooterMode::ShortcutPrompt, + ComposerFooterState::ShortcutOverlay => FooterMode::ShortcutOverlay, + ComposerFooterState::EscHint => FooterMode::EscHint, + } + } +} + const FOOTER_SPACING_HEIGHT: u16 = 1; -const FOOTER_HEIGHT_WITH_HINT: u16 = FOOTER_HINT_HEIGHT + FOOTER_SPACING_HEIGHT; impl ChatComposer { pub fn new( @@ -122,7 +138,6 @@ 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, @@ -130,30 +145,78 @@ impl ChatComposer { paste_burst: PasteBurst::default(), disable_paste_burst: false, custom_prompts: Vec::new(), + footer_state: ComposerFooterState::ShortcutPrompt, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); this } + fn set_footer_prompt(&mut self) { + self.footer_state = ComposerFooterState::ShortcutPrompt; + } + + fn hide_shortcut_overlay(&mut self) { + if matches!(self.footer_state, ComposerFooterState::ShortcutOverlay) { + self.set_footer_prompt(); + } + } + + fn toggle_shortcut_overlay(&mut self) -> bool { + if self.ctrl_c_quit_hint { + return false; + } + self.footer_state = match self.footer_state { + ComposerFooterState::ShortcutOverlay => ComposerFooterState::ShortcutPrompt, + _ => ComposerFooterState::ShortcutOverlay, + }; + true + } + + fn show_esc_hint(&mut self) -> bool { + self.hide_shortcut_overlay(); + if self.is_task_running { + return false; + } + self.footer_state = ComposerFooterState::EscHint; + true + } + + fn reset_esc_hint(&mut self) { + if matches!(self.footer_state, ComposerFooterState::EscHint) { + self.set_footer_prompt(); + } + } + 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; // Leave 1 column for the left border and 1 column for left padding self.textarea .desired_height(width.saturating_sub(LIVE_PREFIX_COLS)) + match &self.active_popup { - ActivePopup::None => FOOTER_HEIGHT_WITH_HINT, + ActivePopup::None => footer_total_height, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), } } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + let footer_props = self.footer_props(); + let footer_hint_height = footer_height(&footer_props); + let footer_total_height = footer_hint_height + FOOTER_SPACING_HEIGHT; 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_HEIGHT_WITH_HINT), + ActivePopup::None => Constraint::Max(footer_total_height), }; let [textarea_rect, _] = Layout::vertical([Constraint::Min(1), popup_constraint]).areas(area); @@ -170,13 +233,6 @@ 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) { @@ -314,6 +370,11 @@ 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.set_footer_prompt(); + } else { + self.reset_esc_hint(); + } self.set_has_focus(has_focus); } @@ -349,6 +410,16 @@ 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 matches!(key_event.code, KeyCode::Esc) { + if self.show_esc_hint() { + return (InputResult::None, true); + } + } else { + self.reset_esc_hint(); + } let ActivePopup::Command(popup) = &mut self.active_popup else { unreachable!(); }; @@ -473,6 +544,16 @@ 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 matches!(key_event.code, KeyCode::Esc) { + if self.show_esc_hint() { + return (InputResult::None, true); + } + } else { + self.reset_esc_hint(); + } let ActivePopup::File(popup) = &mut self.active_popup else { unreachable!(); }; @@ -724,6 +805,16 @@ 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 matches!(key_event.code, KeyCode::Esc) { + if self.show_esc_hint() { + return (InputResult::None, true); + } + } else { + self.reset_esc_hint(); + } match key_event { KeyEvent { code: KeyCode::Char('d'), @@ -867,6 +958,10 @@ impl ChatComposer { let now = Instant::now(); self.handle_paste_burst_flush(now); + if !matches!(input.code, KeyCode::Esc) { + self.reset_esc_hint(); + } + // 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() @@ -1140,6 +1235,42 @@ impl ChatComposer { false } + fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool { + if let KeyEvent { + code: KeyCode::Char('?'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } = *key_event + { + match self.footer_mode() { + FooterMode::CtrlCReminder => false, + _ => self.toggle_shortcut_overlay(), + } + } 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_state, ComposerFooterState::EscHint) { + FooterMode::EscHint + } else if self.ctrl_c_quit_hint { + FooterMode::CtrlCReminder + } else { + self.footer_state.into() + } + } + /// 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. @@ -1223,15 +1354,31 @@ impl ChatComposer { pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; + if running { + self.set_footer_prompt(); + } } pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) { self.esc_backtrack_hint = show; + if show { + let _ = self.show_esc_hint(); + } else { + self.reset_esc_hint(); + } } } impl WidgetRef for ChatComposer { fn render_ref(&self, area: Rect, buf: &mut Buffer) { + 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 (popup_constraint, hint_spacing) = match &self.active_popup { ActivePopup::Command(popup) => ( Constraint::Max(popup.calculate_required_height(area.width)), @@ -1239,8 +1386,8 @@ impl WidgetRef for ChatComposer { ), ActivePopup::File(popup) => (Constraint::Max(popup.calculate_required_height()), 0), ActivePopup::None => ( - Constraint::Length(FOOTER_HEIGHT_WITH_HINT), - FOOTER_SPACING_HEIGHT, + Constraint::Length(footer_hint_height + footer_spacing), + footer_spacing, ), }; let [textarea_rect, popup_rect] = @@ -1253,27 +1400,17 @@ impl WidgetRef for ChatComposer { popup.render_ref(popup_rect, buf); } ActivePopup::None => { - let hint_rect = if hint_spacing > 0 { + let hint_rect = if hint_spacing > 0 && footer_hint_height > 0 { let [_, hint_rect] = Layout::vertical([ Constraint::Length(hint_spacing), - Constraint::Length(FOOTER_HINT_HEIGHT), + Constraint::Length(footer_hint_height), ]) .areas(popup_rect); hint_rect } else { popup_rect }; - 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(), - }, - ); + render_footer(hint_rect, buf, footer_props); } } let border_style = if self.has_focus { @@ -1318,9 +1455,37 @@ 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; + 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_hint_row_is_separated_from_composer() { let (tx, _rx) = unbounded_channel::(); @@ -1348,7 +1513,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(" send") { + if row.contains("? for shortcuts") { hint_row = Some((y, row)); break; } @@ -1375,6 +1540,54 @@ mod tests { ); } + #[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 77e2c106e5..6b152821cc 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,385 +1,280 @@ -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<'a> { - pub(crate) ctrl_c_quit_hint: bool, - pub(crate) is_task_running: bool, +pub(crate) struct FooterProps { + pub(crate) mode: FooterMode, pub(crate) esc_backtrack_hint: bool, pub(crate) use_shift_enter_hint: bool, - pub(crate) token_usage_info: Option<&'a TokenUsageInfo>, + pub(crate) is_task_running: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FooterMode { + CtrlCReminder, + ShortcutPrompt, + ShortcutOverlay, + EscHint, } #[derive(Clone, Copy, Debug)] struct CtrlCReminderState { - pub(crate) is_task_running: bool, + is_task_running: bool, } #[derive(Clone, Copy, Debug)] struct ShortcutsState { - pub(crate) use_shift_enter_hint: bool, - pub(crate) esc_backtrack_hint: bool, + use_shift_enter_hint: bool, + esc_backtrack_hint: bool, + is_task_running: bool, } -#[derive(Clone, Copy, Debug)] -enum FooterContent { - Shortcuts(ShortcutsState), - CtrlCReminder(CtrlCReminderState), +struct ShortcutEntry { + render: fn(ShortcutsState) -> Option, } -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 { - FooterContent::Shortcuts(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - }) - }; - - 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); - } - - let spans = spans - .into_iter() - .map(|span| span.patch_style(Style::default().dim())) - .collect::>(); - Line::from(spans).render_ref(area, buf); -} - -fn footer_spans(content: FooterContent) -> Vec> { - match content { - FooterContent::Shortcuts(state) => shortcuts_spans(state), - FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state), - } -} - -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 - }; - - 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, - )); - } -} - -fn shortcuts_spans(state: ShortcutsState) -> 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()); - } - } - spans -} - -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)] -struct FooterSegment { - prefix: &'static str, - binding: ShortcutBinding, - label: &'static str, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] -enum ShortcutId { - Send, - InsertNewline, - ShowTranscript, - Quit, - EditPrevious, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct ShortcutBinding { - code: KeyCode, - modifiers: KeyModifiers, - display: ShortcutDisplay, - condition: DisplayCondition, -} - -impl ShortcutBinding { - 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), - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DisplayCondition { - Always, - WhenShiftEnterHint, - WhenNotShiftEnterHint, -} - -impl DisplayCondition { - fn matches(self, state: ShortcutsState) -> bool { - match self { - DisplayCondition::Always => true, - DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, - DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, - } - } -} - -struct ShortcutDescriptor { - id: ShortcutId, - bindings: &'static [ShortcutBinding], - footer_label: &'static str, - footer_prefix: &'static str, -} - -impl ShortcutDescriptor { - fn binding_for(&self, state: ShortcutsState) -> Option { - self.bindings - .iter() - .find(|binding| binding.condition.matches(state)) - .copied() - } - - 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)?; - Some(FooterSegment { - prefix: self.footer_prefix, - binding, - label: self.footer_label, - }) - } -} - -const SHORTCUTS: &[ShortcutDescriptor] = &[ - ShortcutDescriptor { - id: ShortcutId::Send, - bindings: &[ShortcutBinding { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - display: ShortcutDisplay::Plain("⏎"), - condition: DisplayCondition::Always, - }], - footer_label: " send ", - footer_prefix: "", +const SHORTCUT_ENTRIES: &[ShortcutEntry] = &[ + ShortcutEntry { + render: |_: ShortcutsState| Some("/ for commands".to_string()), }, - ShortcutDescriptor { - id: ShortcutId::InsertNewline, - bindings: &[ - ShortcutBinding { - code: KeyCode::Enter, - modifiers: KeyModifiers::SHIFT, - display: ShortcutDisplay::Shift('⏎'), - condition: DisplayCondition::WhenShiftEnterHint, - }, - ShortcutBinding { - code: KeyCode::Char('j'), - modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('J'), - condition: DisplayCondition::WhenNotShiftEnterHint, - }, - ], - footer_label: " newline ", - footer_prefix: "", + ShortcutEntry { + render: |_: ShortcutsState| Some("@ for file paths".to_string()), }, - ShortcutDescriptor { - id: ShortcutId::ShowTranscript, - bindings: &[ShortcutBinding { - code: KeyCode::Char('t'), - modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('T'), - condition: DisplayCondition::Always, - }], - footer_label: " transcript ", - footer_prefix: "", + ShortcutEntry { + render: |state: ShortcutsState| { + let binding = if state.use_shift_enter_hint { + "shift + enter" + } else { + "ctrl + j" + }; + Some(format!("{binding} for newline")) + }, }, - ShortcutDescriptor { - id: ShortcutId::Quit, - bindings: &[ShortcutBinding { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - display: ShortcutDisplay::Ctrl('C'), - condition: DisplayCondition::Always, - }], - footer_label: " quit", - footer_prefix: "", + ShortcutEntry { + render: |_: ShortcutsState| Some("ctrl + v to paste images".to_string()), }, - ShortcutDescriptor { - id: ShortcutId::EditPrevious, - bindings: &[ShortcutBinding { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - display: ShortcutDisplay::Plain("Esc"), - condition: DisplayCondition::Always, - }], - footer_label: " edit prev", - footer_prefix: " ", + ShortcutEntry { + render: |state: ShortcutsState| { + let action = if state.is_task_running { + "interrupt" + } else { + "exit" + }; + Some(format!("ctrl + c to {action}")) + }, + }, + ShortcutEntry { + render: |_: ShortcutsState| Some("ctrl + t to view transcript".to_string()), + }, + ShortcutEntry { + render: |_: ShortcutsState| Some("? to hide shortcuts".to_string()), + }, + ShortcutEntry { + render: |state: ShortcutsState| { + let label = if state.esc_backtrack_hint { + "esc again to edit previous message" + } else { + "esc esc to edit previous message" + }; + Some(label.to_string()) + }, }, ]; +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, + })] + } + } +} + +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(), + ]) +} + +fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { + let mut rendered = Vec::new(); + for entry in SHORTCUT_ENTRIES { + if let Some(text) = (entry.render)(state) { + rendered.push(text); + } + } + build_columns(rendered) +} + +fn esc_hint_line(state: ShortcutsState) -> Line<'static> { + let text = if state.esc_backtrack_hint { + " esc again to edit previous message" + } else { + " esc esc to edit previous message" + }; + Line::from(vec![Span::from(text).dim()]) +} + +fn build_columns(entries: Vec) -> Vec> { + if entries.is_empty() { + return Vec::new(); + } + + const COLUMNS: usize = 3; + const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28]; + + let rows = (entries.len() + COLUMNS - 1) / COLUMNS; + let mut column_widths = vec![0usize; COLUMNS]; + + for (idx, entry) in entries.iter().enumerate() { + let column = idx % COLUMNS; + column_widths[column] = column_widths[column].max(entry.len()); + } + + 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:) { - let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap(); + fn snapshot_footer(name: &str, props: FooterProps) { + let height = footer_height(&props).max(1); + let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap(); terminal .draw(|f| { - let area = Rect::new(0, 0, f.area().width, 1); + let area = Rect::new(0, 0, f.area().width, height); 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 { - ctrl_c_quit_hint: false, - is_task_running: false, + mode: FooterMode::ShortcutPrompt, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + is_task_running: false, }, ); snapshot_footer( "footer_shortcuts_shift_and_esc", FooterProps { - ctrl_c_quit_hint: false, - is_task_running: false, + mode: FooterMode::ShortcutOverlay, esc_backtrack_hint: true, use_shift_enter_hint: true, - token_usage_info: Some(&token_usage(4_200, 900, 8_000)), + is_task_running: false, }, ); snapshot_footer( "footer_ctrl_c_quit_idle", FooterProps { - ctrl_c_quit_hint: true, - is_task_running: false, + mode: FooterMode::CtrlCReminder, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + is_task_running: false, }, ); snapshot_footer( "footer_ctrl_c_quit_running", FooterProps { - ctrl_c_quit_hint: true, - is_task_running: true, + mode: FooterMode::CtrlCReminder, esc_backtrack_hint: false, use_shift_enter_hint: false, - token_usage_info: None, + 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, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 062ccdbb59..080769377c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -4,7 +4,6 @@ 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; @@ -371,13 +370,6 @@ 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 c9f5cb9720..74ec110045 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 @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1760 expression: terminal.backend() --- "▌ [Pasted Content 1002 chars][Pasted Content 1004 chars] " @@ -11,4 +12,4 @@ expression: terminal.backend() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"? for shortcuts " 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 823a41cf62..496018ce45 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() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"? for shortcuts " 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 d89c4033de..39cfc0e103 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 @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1760 expression: terminal.backend() --- "▌ [Pasted Content 1005 chars] " @@ -11,4 +12,4 @@ expression: terminal.backend() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"? for shortcuts " 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 636d5c22af..f0aa9193c5 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 @@ -1,5 +1,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 1760 expression: terminal.backend() --- "▌ [Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste " @@ -11,4 +12,4 @@ expression: terminal.backend() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"? for shortcuts " 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 111e45b65b..9704705c51 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() "▌ " "▌ " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"? for shortcuts " 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 32482ba1cd..31a1b743b8 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,6 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ⌃C again to quit " -" " -" " +" 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 8c9c2b0d46..9979372a1b 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,6 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" ⌃C again to interrupt " -" " -" " +" ctrl + c again to interrupt " 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 8c8a10df85..aff75fdcdf 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,6 +2,4 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " -" " -" " +"? 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 9b94d89f98..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() --- -"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use" -" " -" " +" / 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 " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a373338f58..081f47697e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -394,7 +394,6 @@ 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; } } @@ -1961,7 +1960,6 @@ 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 9a2859046a..7ed907d833 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 @@ -13,4 +13,4 @@ expression: visual ▌ Summarize recent commits -⏎ send ⌃J newline ⌃T transcript ⌃C quit +? for shortcuts 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 e28659e2aa..da6221c109 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 @@ -7,5 +7,5 @@ expression: terminal.backend() " " "▌ Ask Codex to do anything " " " -"⏎ send ⌃J newline ⌃T transcript ⌃C quit " +"? for shortcuts " " " diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index deeee6ae84..e1b5eb588c 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -10,20 +10,6 @@ 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().fg(Color::Cyan) } @@ -32,18 +18,6 @@ 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()) -}