From 111362a6a0a92d08885b1ae62d3760714907ad94 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 15 Oct 2026 18:51:22 +0000 Subject: [PATCH] Add a keyboard shortcut menu for presets (#54381) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Replace the `/presets` recall picker with a responsive menu above the composer. Press `0`–`9` to run a saved preset or create one in an empty slot; use `n`, `s`, `e`, and `d` to create, start from current settings, edit, and delete presets. Add `tui.keymap.chat.open_presets` with an implicit `leader p` shortcut that yields to existing bindings and explicit configuration. Show the action in `/keymap` and add a shortcut tooltip. Preserve the composer draft, respect popup ownership, and consume held-key repeats after menu actions. ## Testing Add snapshots for empty, populated, and narrow preset menus, plus coverage for draft preservation, cancellation, held-key handling, transcript search precedence, and default shortcut conflicts. GitOrigin-RevId: f438c7015dbd416c90cdeb479d2a15d9d6d24954 --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 + codex-rs/tui/assets/tooltips.txt | 1 + codex-rs/tui/src/app/event_dispatch.rs | 4 - codex-rs/tui/src/app/input.rs | 4 + codex-rs/tui/src/app/tests/key_chords.rs | 29 +++ codex-rs/tui/src/app_event.rs | 2 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 + codex-rs/tui/src/bottom_pane/mod.rs | 7 + codex-rs/tui/src/bottom_pane/shortcut_hud.rs | 160 ++++++++++++++ codex-rs/tui/src/chatwidget.rs | 4 + codex-rs/tui/src/chatwidget/constructor.rs | 2 + codex-rs/tui/src/chatwidget/interaction.rs | 13 ++ codex-rs/tui/src/chatwidget/quick_dials.rs | 203 ++++++++++++------ codex-rs/tui/src/chatwidget/rendering.rs | 12 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 2 +- ...atwidget__tests__quick_dial_shortcuts.snap | 18 ++ ...et__tests__quick_dial_shortcuts_empty.snap | 12 ++ ...t__tests__quick_dial_shortcuts_narrow.snap | 22 ++ ...idget__tests__saved_quick_dials_clear.snap | 9 - ...dget__tests__saved_quick_dials_recall.snap | 23 -- .../src/chatwidget/tests/quick_dials_tests.rs | 66 +++++- codex-rs/tui/src/keymap.rs | 42 +++- codex-rs/tui/src/keymap/bindings.rs | 25 ++- codex-rs/tui/src/keymap/chords.rs | 17 +- .../src/keymap/chords/quick_dial_defaults.rs | 68 ++++++ codex-rs/tui/src/keymap_setup.rs | 47 ++-- codex-rs/tui/src/keymap_setup/actions.rs | 1 + ...ap_setup__tests__keymap_picker_custom.snap | 4 +- ...ests__keymap_picker_fast_mode_enabled.snap | 4 +- ...p__tests__keymap_picker_first_actions.snap | 4 +- ...up__tests__keymap_picker_global_chord.snap | 6 +- ...ap_setup__tests__keymap_picker_narrow.snap | 4 +- ...ymap_setup__tests__keymap_picker_wide.snap | 4 +- codex-rs/tui/src/tooltips/keybinding_tests.rs | 1 + ..._shortcut_tips_render_at_narrow_width.snap | 6 + 36 files changed, 688 insertions(+), 152 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/shortcut_hud.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts_empty.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts_narrow.snap delete mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_recall.snap create mode 100644 codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index f0e5b13f0d..c59b79e292 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -171,6 +171,8 @@ pub struct TuiGlobalKeymap { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiChatKeymap { + /// Open saved prompts and slash commands. + pub open_presets: Option, /// Start or stop a voice conversation. pub toggle_voice: Option, /// Toggle the microphone in an active voice conversation. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 82f03f6f7d..2a31fc816f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4770,6 +4770,7 @@ "increase_reasoning_effort": null, "interrupt_turn": null, "next_permission_mode": null, + "open_presets": null, "previous_permission_mode": null, "prompt_stack_back": null, "skip_question": null, @@ -5313,6 +5314,14 @@ ], "description": "Switch to the next available permission mode." }, + "open_presets": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open saved prompts and slash commands." + }, "previous_permission_mode": { "allOf": [ { @@ -5764,6 +5773,7 @@ "increase_reasoning_effort": null, "interrupt_turn": null, "next_permission_mode": null, + "open_presets": null, "previous_permission_mode": null, "prompt_stack_back": null, "skip_question": null, diff --git a/codex-rs/tui/assets/tooltips.txt b/codex-rs/tui/assets/tooltips.txt index d36ae196e8..3475bde95f 100644 --- a/codex-rs/tui/assets/tooltips.txt +++ b/codex-rs/tui/assets/tooltips.txt @@ -44,3 +44,4 @@ Press {key:global.find_transcript} to search this conversation. Press {key:global.open_external_editor} to edit your current draft in an external editor. Press {key:composer.history_search_previous} to search previously entered prompts. For models with adjustable reasoning, press {key:chat.increase_reasoning_effort} to increase reasoning effort or {key:chat.decrease_reasoning_effort} to decrease it. +Use {key:chat.open_presets}, then 0–9 to run a preset; Enter browses, s saves, and d deletes. diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ac4cff843a..f1fad3d863 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2540,13 +2540,9 @@ impl App { self.app_event_tx.send(AppEvent::FollowTranscript); self.select_session_model(app_server, model, effort, service_tier).await; } - AppEvent::OpenQuickDials(mode) => self.chat_widget.open_quick_dials_picker(mode), AppEvent::OpenQuickDialEditor { target, dial } => { self.chat_widget.open_quick_dial_editor(target, dial); } - AppEvent::OpenQuickDialPrefills(target) => { - self.chat_widget.open_quick_dial_prefills(target); - } AppEvent::OpenQuickDialSavePicker(dial) => { self.chat_widget.open_quick_dial_save_picker(dial); } diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index f1c6dec705..773eb1d1a1 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -87,6 +87,9 @@ impl App { if let Some(Overlay::Transcript(overlay)) = &mut self.overlay { overlay.set_keymap_bindings(&self.keymap); } + if self.overlay.is_none() && self.chat_widget.handle_quick_dial_menu(key_event) { + return None; + } if matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event)) || (tui.is_owned_screen() && self.overlay.is_none() @@ -200,6 +203,7 @@ impl App { } pub(super) fn cancel_pending_key_chord(&mut self) { + self.chat_widget.cancel_quick_dial_menu(); if self.key_chord_matcher.cancel() { self.set_key_chord_hint_override(/*items*/ None); } diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 6c1321ad52..7151dafd25 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -2,10 +2,12 @@ use super::App; use super::AppServerSession; use super::Result; use super::RuntimeKeymap; +use super::ThreadId; use super::TuiEvent; use super::make_test_app; use super::make_test_app_with_channels; use super::start_config_write_test_app_server; +use super::test_thread_session; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::chatwidget::tests::helpers::render_bottom_popup; @@ -835,6 +837,33 @@ async fn command_center_chords_do_not_capture_search_text() -> Result<()> { Ok(()) } +#[tokio::test] +async fn preset_menu_keys_take_precedence_over_transcript_find() -> Result<()> { + let mut app = make_test_app().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.handle_thread_session(test_thread_session( + ThreadId::new(), + app.config.cwd.to_path_buf(), + )); + app.transcript_view.begin_search(); + + app.chat_widget.open_quick_dial_menu(); + assert_eq!( + app.route_key_chord_event(&mut tui, KeyCode::Esc.into()), + None + ); + assert!(app.transcript_view.is_search_editing()); + + app.chat_widget.open_quick_dial_menu(); + assert_eq!( + app.route_key_chord_event(&mut tui, KeyCode::Char('n').into()), + None + ); + assert!(app.transcript_view.is_search_editing()); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("New preset")); + Ok(()) +} + #[tokio::test] async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 04af70635f..c19c77f3c6 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1276,12 +1276,10 @@ pub(crate) enum AppEvent { service_tier: Option, }, - OpenQuickDials(crate::chatwidget::quick_dials::QuickDialPickerMode), OpenQuickDialEditor { target: crate::chatwidget::quick_dials::QuickDialEditorTarget, dial: Option, }, - OpenQuickDialPrefills(crate::chatwidget::quick_dials::QuickDialEditorTarget), OpenQuickDialSavePicker(codex_config::types::QuickDial), ConfirmQuickDial { slot: u8, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 715af4eb6f..970234f5be 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -18,6 +18,8 @@ //! Transcript interactions borrow the hint footer through `ComposerRenderOptions`; //! its resolved presentation drives height, painting, and the focused cursor together. //! +//! Preset submenu hints borrow slots and reflow with the composer. +//! //! The plain-text preset keeps command prefixes literal, including `!`, so Enter and Tab //! submit ordinary text without enabling shell mode. //! diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 3a930b9a55..90ed97e811 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -158,9 +158,11 @@ mod mentions_v2; mod picker_presets; mod picker_rows; mod picker_style; +mod shortcut_hud; mod shortcut_overlay; pub(crate) use picker_style::active_tab_style; pub(crate) use picker_style::selection_style; +pub(crate) use shortcut_hud::ShortcutHud; pub(crate) mod prompt_args; mod selection_picker_layout; mod skill_popup; @@ -1232,6 +1234,11 @@ impl BottomPane { self.composer.current_text_with_pending() } + /// Include buffered typing before a shortcut takes ownership of composer input. + pub(crate) fn flush_pending_composer_input(&mut self) { + self.composer.flush_pending_input(); + } + /// Returns whether the composer currently accepts interactive draft edits. pub(crate) fn composer_input_enabled(&self) -> bool { self.composer.input_enabled() diff --git a/codex-rs/tui/src/bottom_pane/shortcut_hud.rs b/codex-rs/tui/src/bottom_pane/shortcut_hud.rs new file mode 100644 index 0000000000..ff2e66d01d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/shortcut_hud.rs @@ -0,0 +1,160 @@ +//! Passive shortcut hints arranged in responsive columns above the composer. + +use super::selection_popup_common::menu_surface_inset; +use super::selection_popup_common::menu_surface_padding_height; +use super::selection_popup_common::render_menu_surface; +use crate::line_truncation::line_width; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::renderable::Renderable; +use crate::style::accent_color; +use crate::style::secondary_text_style; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +const COLUMN_GAP: usize = 2; +const MIN_COLUMN_WIDTH: usize = 32; +const COMMAND_SEPARATOR: &str = " "; +const SINGLE_COLUMN_ENTRY_LIMIT: usize = 5; + +pub(crate) struct ShortcutHud { + title: String, + entries: Vec>, + commands: Vec>, +} + +impl ShortcutHud { + pub(crate) fn new( + title: impl Into, + entries: Vec>, + commands: Vec>, + ) -> Self { + Self { + title: title.into(), + entries, + commands, + } + } + + pub(crate) fn item(key: impl Into, label: Span<'static>) -> Line<'static> { + vec![ + key.into().fg(accent_color()), + " · ".set_style(secondary_text_style()), + label, + ] + .into() + } + + fn body_lines(&self, width: u16) -> Vec> { + let mut lines = vec![self.title.clone().bold().into(), Line::default()]; + let max_columns = if self.entries.len() > SINGLE_COLUMN_ENTRY_LIMIT { + 2 + } else { + 1 + }; + lines.extend(grid_lines(&self.entries, width, max_columns)); + lines + } + + fn command_lines(&self, width: u16) -> Vec> { + crate::footer_hint::wrap_hint_rows( + self.commands.iter().cloned(), + width, + COMMAND_SEPARATOR.len(), + Line::width, + ) + .into_iter() + .map(|row| { + let mut spans = Vec::new(); + for (index, command) in row.into_iter().enumerate() { + if index > 0 { + spans.push(COMMAND_SEPARATOR.into()); + } + spans.extend(command.spans); + } + spans.into() + }) + .collect() + } +} + +impl Renderable for ShortcutHud { + fn render(&self, area: Rect, buf: &mut Buffer) { + let content = render_menu_surface(area, buf); + let commands = self.command_lines(content.width); + let command_height = u16::try_from(commands.len()) + .unwrap_or(u16::MAX) + .min(content.height); + let gap = content + .height + .saturating_sub(command_height) + .min(/*other*/ 1); + let body = Rect { + height: content.height.saturating_sub(command_height + gap), + ..content + }; + let command_area = Rect { + y: content.bottom().saturating_sub(command_height), + height: command_height, + ..content + }; + render_clipped(self.body_lines(content.width), body, buf); + Paragraph::new(commands).render(command_area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let content_width = menu_surface_inset(Rect::new( + /*x*/ 0, + /*y*/ 0, + width, + /*height*/ u16::MAX, + )) + .width; + let body_height = self.body_lines(content_width).len(); + let command_height = self.command_lines(content_width).len(); + u16::try_from(body_height + 1 + command_height) + .unwrap_or(u16::MAX) + .saturating_add(menu_surface_padding_height()) + } +} + +fn grid_lines(items: &[Line<'static>], width: u16, max_columns: usize) -> Vec> { + let width = usize::from(width); + let columns = ((width + COLUMN_GAP) / (MIN_COLUMN_WIDTH + COLUMN_GAP)) + .clamp(/*min*/ 1, max_columns) + .min(items.len().max(/*other*/ 1)); + let rows = items.len().div_ceil(columns); + let cell_width = width.saturating_sub(COLUMN_GAP * (columns - 1)) / columns; + (0..rows) + .map(|row| { + let mut line = Line::default(); + for column in 0..columns { + let item = items.get(column * rows + row).cloned().unwrap_or_default(); + let item = truncate_line_with_ellipsis_if_overflow(item, cell_width); + let padding = cell_width.saturating_sub(line_width(&item)) + COLUMN_GAP; + line.extend(item.spans); + if column + 1 < columns { + line.push_span(" ".repeat(padding)); + } + } + line + }) + .collect() +} + +fn render_clipped(mut lines: Vec>, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + let height = usize::from(area.height); + if lines.len() > height { + lines.truncate(height); + lines[height - 1] = "… resize to see all".dim().into(); + } + Paragraph::new(lines).render(area, buf); +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 06aa1abc66..d28be65c8f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -701,6 +701,10 @@ pub(crate) struct ChatWidget { safety_buffering_source: UserMessageSource, /// Main chat-surface bindings resolved from `tui.keymap.chat`. chat_keymap: ChatKeymap, + /// Whether the preset submenu owns the next key. + quick_dial_menu: bool, + /// Ignore a held key after it dismisses the preset submenu. + quick_dial_menu_key_guard: Option, permission_shortcut_pending: bool, // Pending notification to show when unfocused on next Draw pub(crate) pending_notification: Option, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 05dde949ed..185a10ae2a 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -253,6 +253,8 @@ impl ChatWidget { suppress_initial_user_message_submit: false, pending_notification: None, quit_shortcut_expires_at: None, + quick_dial_menu: false, + quick_dial_menu_key_guard: None, quit_shortcut_key: None, turn_runtime_metrics: RuntimeMetricsSummary::default(), last_rendered_width: std::cell::Cell::new(None), diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 549a5623a2..8a0d75ad25 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -96,6 +96,9 @@ impl ChatWidget { key_event: KeyEvent, activation_key: Option, ) -> KeyEventAction { + if self.handle_quick_dial_menu(key_event) { + return KeyEventAction::None; + } if self.handle_startup_submission_key(key_event) { return KeyEventAction::None; } @@ -136,6 +139,14 @@ impl ChatWidget { return KeyEventAction::None; } + if key_event.kind == KeyEventKind::Press + && self.chat_keymap.open_presets.is_pressed(key_event) + && self.bottom_pane.no_modal_or_popup_active() + { + self.open_quick_dial_menu(); + return KeyEventAction::None; + } + if (self.chat_keymap.interrupt_turn.is_pressed(key_event) || key_hint::ctrl(KeyCode::Char('c')).is_press(key_event)) && self.bottom_pane.no_modal_or_popup_active() @@ -353,6 +364,7 @@ impl ChatWidget { } pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.quick_dial_menu = false; if items.is_some() { // Active input instructions supersede transient feedback from a previous action. self.bottom_pane @@ -529,6 +541,7 @@ impl ChatWidget { } pub(crate) fn handle_paste(&mut self, text: String) { + self.cancel_quick_dial_menu(); if self.external_writer_view && !self.bottom_pane.has_active_view() { return; } diff --git a/codex-rs/tui/src/chatwidget/quick_dials.rs b/codex-rs/tui/src/chatwidget/quick_dials.rs index e731623a50..68bf5e1b2d 100644 --- a/codex-rs/tui/src/chatwidget/quick_dials.rs +++ b/codex-rs/tui/src/chatwidget/quick_dials.rs @@ -1,7 +1,9 @@ -//! Numbered reusable inputs using the existing picker and queued submission path. +//! Numbered reusable inputs with one HUD and pickers for editing, saving, and deleting. //! Saving leaves the draft untouched; recall preserves drafts and obeys normal command gates. +//! The shared-leader submenu respects popup ownership, cancellation, and held-key repeats. use super::*; +use crate::bottom_pane::ShortcutHud; use crate::wrapping::word_wrap_lines; use codex_config::types::QuickDial; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; @@ -22,8 +24,7 @@ impl Renderable for QuickDialConfirmationHeader { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum QuickDialPickerMode { - Recall, +enum QuickDialPickerMode { Save, Edit, Clear, @@ -35,14 +36,6 @@ pub(crate) enum QuickDialEditorTarget { Slot(u8), } -#[derive(Clone, Copy)] -enum QuickDialMenuAction { - New, - Edit, - Prefill, - Clear, -} - fn quick_dial_label(dial: &QuickDial) -> String { dial.label .as_deref() @@ -56,6 +49,34 @@ fn quick_dial_label(dial: &QuickDial) -> String { } impl ChatWidget { + pub(super) fn quick_dial_hud(&self) -> ShortcutHud { + let mut entries: Vec<_> = (1..=9) + .chain(std::iter::once(/*value*/ 0)) + .filter_map(|slot| { + let key = slot.to_string(); + self.local_settings + .tui + .quick_dials + .get(&key) + .map(|dial| ShortcutHud::item(key, quick_dial_label(dial).into())) + }) + .collect(); + if entries.is_empty() { + entries.push("No presets saved. Press n to create one.".dim().into()); + } + let commands = [ + ("n", "new"), + ("s", "from settings"), + ("e", "edit"), + ("d", "delete"), + ("esc", "close"), + ] + .into_iter() + .map(|(key, label)| ShortcutHud::item(key, label.into())) + .collect(); + ShortcutHud::new("Presets", entries, commands) + } + fn composer_quick_dial_text(&mut self) -> String { if !self.bottom_pane.composer_local_images().is_empty() || !self.bottom_pane.remote_image_urls().is_empty() @@ -167,7 +188,7 @@ impl ChatWidget { ); } - pub(crate) fn open_quick_dials_picker(&mut self, mode: QuickDialPickerMode) { + fn open_quick_dials_picker(&mut self, mode: QuickDialPickerMode) { if !self.quick_dials_available() { return; } @@ -183,21 +204,18 @@ impl ChatWidget { } fn show_quick_dials_picker(&mut self, mode: QuickDialPickerMode, current: Option) { - let thread_id = self.thread_id(); let mut items = Vec::new(); for slot in (1..=9).chain(std::iter::once(/*value*/ 0)) { let saved = self.local_settings.tui.quick_dials.get(&slot.to_string()); - if mode == QuickDialPickerMode::Edit && saved.is_none() { + if mode != QuickDialPickerMode::Save && saved.is_none() { continue; } let dial = current.clone(); let saved_for_edit = saved.cloned(); let has_saved = saved.is_some(); let (is_disabled, dismiss_on_select, dismiss_parent_on_child_accept) = match mode { - QuickDialPickerMode::Recall => (!has_saved, true, false), QuickDialPickerMode::Save => (dial.is_none(), !has_saved, has_saved), - QuickDialPickerMode::Edit => (false, false, true), - QuickDialPickerMode::Clear => (!has_saved, false, true), + QuickDialPickerMode::Edit | QuickDialPickerMode::Clear => (false, false, true), }; items.push(SelectionItem { name: saved.map(quick_dial_label).unwrap_or_default(), @@ -219,9 +237,6 @@ impl ChatWidget { is_disabled, actions: vec![Box::new(move |tx| { let event = match mode { - QuickDialPickerMode::Recall => { - AppEvent::RecallQuickDial { slot, thread_id } - } QuickDialPickerMode::Save if has_saved => AppEvent::ConfirmQuickDial { slot, dial: dial.clone(), @@ -245,49 +260,7 @@ impl ChatWidget { ..Default::default() }); } - if mode == QuickDialPickerMode::Recall { - for (key, name, action) in [ - (Some('s'), "New preset…", QuickDialMenuAction::New), - (Some('e'), "Edit a preset…", QuickDialMenuAction::Edit), - ( - None, - "Start from current settings…", - QuickDialMenuAction::Prefill, - ), - (Some('d'), "Delete a preset…", QuickDialMenuAction::Clear), - ] { - let is_disabled = matches!( - action, - QuickDialMenuAction::Edit | QuickDialMenuAction::Clear - ) && self.local_settings.tui.quick_dials.is_empty(); - items.push(SelectionItem { - name: name.into(), - display_shortcut: key.map(|key| key_hint::plain(KeyCode::Char(key)).into()), - actions: vec![Box::new(move |tx| { - tx.send(match action { - QuickDialMenuAction::New => AppEvent::OpenQuickDialEditor { - target: QuickDialEditorTarget::PickSlot, - dial: None, - }, - QuickDialMenuAction::Edit => { - AppEvent::OpenQuickDials(QuickDialPickerMode::Edit) - } - QuickDialMenuAction::Prefill => { - AppEvent::OpenQuickDialPrefills(QuickDialEditorTarget::PickSlot) - } - QuickDialMenuAction::Clear => { - AppEvent::OpenQuickDials(QuickDialPickerMode::Clear) - } - }) - })], - is_disabled, - dismiss_parent_on_child_accept: true, - ..Default::default() - }); - } - } let title = match mode { - QuickDialPickerMode::Recall => "Presets", QuickDialPickerMode::Save => "Save preset to slot", QuickDialPickerMode::Edit => "Edit a preset", QuickDialPickerMode::Clear => "Delete a preset", @@ -308,7 +281,7 @@ impl ChatWidget { view_id: Some("quick-dials"), auto_number_shortcuts: false, initial_selected_idx, - max_visible_rows: 14, + max_visible_rows: 10, title: Some(title.into()), subtitle: (mode == QuickDialPickerMode::Save) .then(|| current.as_ref().map(quick_dial_label)) @@ -378,7 +351,7 @@ impl ChatWidget { self.defer_input_until_settings_applied(); } - pub(crate) fn open_quick_dial_prefills(&mut self, target: QuickDialEditorTarget) { + fn open_quick_dial_prefills(&mut self) { if !self.quick_dials_available() { return; } @@ -388,7 +361,7 @@ impl ChatWidget { let description = Some(dial.input.clone()); let actions = vec![Box::new(move |tx: &AppEventSender| { tx.send(AppEvent::OpenQuickDialEditor { - target, + target: QuickDialEditorTarget::PickSlot, dial: Some(dial.clone()), }) }) as SelectionAction]; @@ -455,4 +428,104 @@ impl ChatWidget { ..SelectionViewParams::picker() }); } + + pub(crate) fn cancel_quick_dial_menu(&mut self) { + if self.quick_dial_menu { + self.set_footer_hint_override(/*items*/ None); + } + } + + pub(crate) fn open_quick_dial_menu(&mut self) { + if self.quick_dials_available() { + self.bottom_pane.flush_pending_composer_input(); + if !self.bottom_pane.no_modal_or_popup_active() { + return; + } + self.set_footer_hint_override(Some(Vec::new())); + self.quick_dial_menu = true; + } + } + + pub(crate) fn handle_quick_dial_menu(&mut self, key: KeyEvent) -> bool { + if let Some(guard) = self.quick_dial_menu_key_guard.as_mut() { + if guard.consume(KeyBinding::from_event(key), key.kind) { + return true; + } + if !guard.is_active() { + self.quick_dial_menu_key_guard = None; + } + } + if !self.quick_dial_menu { + return false; + } + if !self.bottom_pane.no_modal_or_popup_active() { + self.cancel_quick_dial_menu(); + return false; + } + if key.kind != KeyEventKind::Press || key.code == KeyCode::Enter { + return true; + } + self.cancel_quick_dial_menu(); + if self.chat_keymap.chord_hints.leader.is_pressed(key) + || self.chat_keymap.open_presets.is_pressed(key) + { + return true; + } + if key.modifiers != KeyModifiers::NONE { + return false; + } + let consumed = match key.code { + KeyCode::Esc => true, + KeyCode::Char('n') => { + self.open_quick_dial_editor(QuickDialEditorTarget::PickSlot, /*dial*/ None); + true + } + KeyCode::Char('s') => { + self.open_quick_dial_prefills(); + true + } + KeyCode::Char('e') => { + self.open_quick_dials_picker(QuickDialPickerMode::Edit); + true + } + KeyCode::Char('d') => { + self.open_quick_dials_picker(QuickDialPickerMode::Clear); + true + } + KeyCode::Char(c @ '0'..='9') => { + if self.quick_dials_available() { + let slot = c as u8 - b'0'; + if self + .local_settings + .tui + .quick_dials + .contains_key(&slot.to_string()) + { + self.input_queue.suppress_queue_autosend = true; + self.app_event_tx.send(AppEvent::RecallQuickDial { + slot, + thread_id: self.thread_id(), + }); + self.on_modal_or_popup_closed(); + } else { + self.open_quick_dial_editor( + QuickDialEditorTarget::Slot(slot), + /*dial*/ None, + ); + } + } + true + } + _ => false, + }; + if consumed { + self.quick_dial_menu_key_guard = + Some(crate::activation_key_guard::ActivationKeyGuard::new_at( + KeyBinding::from_event(key), + Instant::now(), + /*key_releases_reported*/ false, + )); + } + consumed + } } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index eee242df2e..15bbdad9d8 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -257,7 +257,17 @@ impl ChatWidget { options.textarea_right_reserve = right_reserve; options.separate_status_line = options.command_popup_placement != crate::bottom_pane::CommandPopupPlacement::AboveComposer; - self.bottom_pane.backdrop_with_options(options) + let bottom_pane = self.bottom_pane.backdrop_with_options(options); + if !self.quick_dial_menu { + return bottom_pane; + } + let mut column = FlexRenderable::new(); + column.push( + /*flex*/ 1, + RenderableItem::Owned(Box::new(self.quick_dial_hud())), + ); + column.push(/*flex*/ 0, bottom_pane); + RenderableItem::Owned(Box::new(column)) } } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 46fe775d17..d6f9ee55e3 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -379,7 +379,7 @@ impl ChatWidget { self.defer_input_until_settings_applied(); } SlashCommand::Presets => { - self.open_quick_dials_picker(quick_dials::QuickDialPickerMode::Recall); + self.open_quick_dial_menu(); } SlashCommand::Plan => { self.apply_plan_slash_command(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts.snap new file mode 100644 index 0000000000..e1f10e1f9c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: "render_bottom_popup(&chat, 100)" +--- + Presets + + 1 · coding 5 · /skills simplify-code + 2 · /permissions workspace-write 7 · Plan the requested change + 3 · Review the current changes and explain eve… 0 · /status + + n · new s · from settings e · edit d · delete esc · close + +• Queued follow-up inputs + ↳ /model gpt-5.5 --reasoning=high --speed=fast + ⇧← edit last queued message + +› Review the current changes + for safety diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts_empty.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts_empty.snap new file mode 100644 index 0000000000..72c8899875 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts_empty.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: "render_bottom_popup(&chat, 100)" +--- + Presets + + No presets saved. Press n to create one. + + n · new s · from settings e · edit d · delete esc · close + + +› Ask Codex to do anything diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts_narrow.snap new file mode 100644 index 0000000000..fc6b4be116 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__quick_dial_shortcuts_narrow.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/chatwidget/tests/quick_dials_tests.rs +expression: "render_bottom_popup(&chat, 48)" +--- + Presets + + 1 · coding + 2 · /permissions workspace-write + 3 · Review the current changes and explain … + 5 · /skills simplify-code + 7 · Plan the requested change + 0 · /status + + n · new s · from settings e · edit + d · delete esc · close + +• Queued follow-up inputs + ↳ /model gpt-5.5 --reasoning=high --speed=fast + ⇧← edit last queued message + +› Review the current changes + for safety diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_clear.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_clear.snap index 7b0955a079..20c5f2a46b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_clear.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_clear.snap @@ -6,14 +6,5 @@ expression: "render_bottom_popup(&chat, 80)" › 1. coding /model gpt-5.5 --reasoning=high --speed=fast - 2. empty - 3. empty - 4. empty - 5. empty - 6. empty - 7. empty - 8. empty - 9. empty - 0. empty enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_recall.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_recall.snap deleted file mode 100644 index 0a02e68f47..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__saved_quick_dials_recall.snap +++ /dev/null @@ -1,23 +0,0 @@ ---- -source: tui/src/chatwidget/tests/quick_dials_tests.rs -expression: "render_bottom_popup(&chat, 80)" ---- - Presets - - -› 1. coding /model gpt-5.5 --reasoning=high --speed=fast - 2. empty - 3. empty - 4. empty - 5. empty - 6. empty - 7. empty - 8. empty - 9. empty - 0. empty - s. New preset… - e. Edit a preset… - Start from current settings… - d. Delete a preset… - - enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests/quick_dials_tests.rs b/codex-rs/tui/src/chatwidget/tests/quick_dials_tests.rs index 649580621a..014e2c0ee1 100644 --- a/codex-rs/tui/src/chatwidget/tests/quick_dials_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/quick_dials_tests.rs @@ -1,8 +1,7 @@ -//! Save, Clear, and confirmation layouts keep destructive preset actions explicit. +//! Preset HUD, editor, and save/delete flows preserve drafts and explicit confirmation. use super::*; use crate::chatwidget::quick_dials::QuickDialEditorTarget; -use crate::chatwidget::quick_dials::QuickDialPickerMode; use codex_config::types::QuickDial; #[tokio::test] @@ -11,6 +10,19 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() { let (mut chat, mut events, _ops) = make_chatwidget_manual(/*model_override*/ Some("gpt-5.5")).await; chat.thread_id = Some(ThreadId::new()); + chat.open_quick_dial_menu(); + assert_chatwidget_snapshot!( + "quick_dial_shortcuts_empty", + render_bottom_popup(&chat, /*width*/ 100) + ); + let escape = KeyCode::Esc.into(); + assert!(chat.handle_quick_dial_menu(escape)); + assert!(chat.handle_quick_dial_menu(escape)); + assert!(chat.handle_quick_dial_menu(KeyEvent::new_with_kind( + KeyCode::Esc, + KeyModifiers::NONE, + crossterm::event::KeyEventKind::Release, + ))); chat.local_settings.tui.quick_dials = [( "1".into(), QuickDial { @@ -21,10 +33,14 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() { .into(); chat.bottom_pane.apply_external_edit(DRAFT.into()); chat.dispatch_command(SlashCommand::Presets); - assert_chatwidget_snapshot!( - "saved_quick_dials_recall", - render_bottom_popup(&chat, /*width*/ 80) - ); + let hud = render_bottom_popup(&chat, /*width*/ 80); + chat.handle_key_event(KeyCode::Enter.into()); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), hud); + assert_eq!(chat.bottom_pane.composer_text(), DRAFT); + assert!(chat.queued_user_message_texts().is_empty()); + chat.cancel_quick_dial_menu(); + chat.open_quick_dial_menu(); + assert_eq!(render_bottom_popup(&chat, /*width*/ 80), hud); chat.handle_key_event(KeyCode::Esc.into()); while events.try_recv().is_ok() {} chat.open_quick_dial_editor(QuickDialEditorTarget::PickSlot, /*dial*/ None); @@ -66,7 +82,8 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() { ); chat.handle_key_event(KeyCode::Esc.into()); - chat.open_quick_dials_picker(QuickDialPickerMode::Clear); + chat.open_quick_dial_menu(); + chat.handle_key_event(KeyCode::Char('d').into()); assert_chatwidget_snapshot!( "saved_quick_dials_clear", render_bottom_popup(&chat, /*width*/ 80) @@ -120,6 +137,38 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() { ["/model gpt-5.5 --reasoning=high --speed=fast"] ); + chat.local_settings.tui.quick_dials.extend( + [ + ("2", "/permissions workspace-write"), + ( + "3", + "Review the current changes and explain every important behavior change", + ), + ("5", "/skills simplify-code"), + ("7", "Plan the requested change"), + ("0", "/status"), + ] + .map(|(slot, input)| { + ( + slot.into(), + QuickDial { + input: input.into(), + label: None, + }, + ) + }), + ); + chat.open_quick_dial_menu(); + assert_chatwidget_snapshot!( + "quick_dial_shortcuts", + render_bottom_popup(&chat, /*width*/ 100) + ); + assert_chatwidget_snapshot!( + "quick_dial_shortcuts_narrow", + render_bottom_popup(&chat, /*width*/ 48) + ); + chat.handle_key_event(KeyCode::Esc.into()); + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); chat.effective_service_tier = Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.into()); assert_eq!( @@ -130,7 +179,8 @@ async fn saved_quick_dials_save_and_clear_picker_snapshots() { }) ); let expected_model = chat.current_model_quick_dial().expect("model command"); - chat.open_quick_dial_prefills(QuickDialEditorTarget::PickSlot); + chat.open_quick_dial_menu(); + chat.handle_key_event(KeyCode::Char('s').into()); chat.handle_key_event(KeyCode::Enter.into()); assert_matches!( std::iter::from_fn(|| events.try_recv().ok()) diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 5a8f3aa93d..c185f40f02 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -62,6 +62,7 @@ pub(crate) use chords::KeyChordMatch; pub(crate) use chords::KeyChordMatcher; pub(crate) use chords::KeymapContextSet; pub(crate) use chords::RuntimeChordKeymap; +pub(crate) use chords::is_dispatch_token; pub(crate) use chords::is_dispatch_token_event; /// Runtime keymap used by TUI input handlers. @@ -129,11 +130,13 @@ pub(crate) struct AppKeymap { /// handler code, not here. #[derive(Clone, Debug)] pub(crate) struct ChatKeymap { + /// Open the preset submenu. + pub(crate) open_presets: Vec, /// Start or stop a voice conversation. pub(crate) toggle_voice: Vec, /// Toggle capture in the active voice session. pub(crate) toggle_voice_mute: Vec, - chord_hints: Arc, + pub(crate) chord_hints: Arc, /// Interrupt the active turn. pub(crate) interrupt_turn: Vec, /// Decrease the active reasoning effort. @@ -777,6 +780,7 @@ impl RuntimeKeymap { })); let mut chat = ChatKeymap { + open_presets: resolve_local!(keymap, defaults, chat, open_presets), toggle_voice: if voice_toggle_default_is_shadowed { Vec::new() } else { @@ -1643,6 +1647,7 @@ impl RuntimeKeymap { } } resolved.configure_search(keymap)?; + chords::quick_dial_defaults::register(keymap, &mut resolved); resolved.validate_conflicts()?; chords::validate_chord_conflicts(&resolved)?; chords::install_dispatch_bindings(&mut resolved)?; @@ -1683,6 +1688,7 @@ impl RuntimeKeymap { }, chords: Arc::default(), chat: ChatKeymap { + open_presets: Vec::new(), toggle_voice: default_bindings![plain(KeyCode::F(8))], toggle_voice_mute: default_bindings![plain(KeyCode::F(9))], chord_hints: Arc::default(), @@ -1992,6 +1998,7 @@ impl RuntimeKeymap { /// backtracking, intentionally stay outside this configurable keymap. fn validate_conflicts(&self) -> Result<(), String> { for (action, bindings) in [ + ("open_presets", &self.chat.open_presets), ("toggle_voice", &self.chat.toggle_voice), ( "previous_permission_mode", @@ -2014,6 +2021,7 @@ impl RuntimeKeymap { #[cfg(unix)] for (action, bindings) in [ ("global.open_agents", &self.app.open_agents), + ("chat.open_presets", &self.chat.open_presets), ("chat.toggle_voice", &self.chat.toggle_voice), ] { if bindings.contains(&key_hint::ctrl(KeyCode::Char('z'))) { @@ -2056,6 +2064,7 @@ impl RuntimeKeymap { ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ("toggle_side_conversation", side_toggle_bindings.as_slice()), + ("chat.open_presets", self.chat.open_presets.as_slice()), ("chat.toggle_voice", self.chat.toggle_voice.as_slice()), ( "chat.toggle_voice_mute", @@ -2217,6 +2226,7 @@ impl RuntimeKeymap { ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), + ("chat.open_presets", self.chat.open_presets.as_slice()), ("chat.toggle_voice", self.chat.toggle_voice.as_slice()), ( "chat.toggle_voice_mute", @@ -4122,5 +4132,35 @@ mod tests { RuntimeKeymap::defaults() .validate_conflicts() .expect("default keymap should be conflict free"); + + let mut keymap = TuiKeymap::default(); + keymap.global.leader = Some(one("ctrl-r")); + let runtime = RuntimeKeymap::from_config(&keymap) + .expect("an unused leader must preserve single-key shortcuts"); + assert_eq!( + ( + runtime.composer.history_search_previous, + runtime.chat.open_presets + ), + (vec![key_hint::ctrl(KeyCode::Char('r'))], Vec::new()) + ); + let defaults = RuntimeKeymap::defaults(); + for leader in ["page-up", "page-down"] { + keymap.global.leader = Some(one(leader)); + let runtime = RuntimeKeymap::from_config(&keymap) + .expect("an unused leader must preserve transcript paging"); + assert_eq!( + ( + runtime.pager.page_up, + runtime.pager.page_down, + runtime.chat.open_presets + ), + ( + defaults.pager.page_up.clone(), + defaults.pager.page_down.clone(), + Vec::new() + ) + ); + } } } diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 08c7b0930b..ac7447af53 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -103,15 +103,23 @@ pub(crate) struct KeymapActionId { } impl KeymapActionId { - /// Activity focus and Find remain active while transcript groups use list navigation. + /// Whether two actions can be active together, including list-specific exceptions. pub(super) fn overlaps(self, other: Self) -> bool { - self.context.overlaps(other.context) - || (self.context == KeymapContext::Global - && matches!(self.action, "focus_activity" | "find_transcript") - && other.context == KeymapContext::List) - || (other.context == KeymapContext::Global - && matches!(other.action, "focus_activity" | "find_transcript") - && self.context == KeymapContext::List) + use KeymapContext::Chat; + use KeymapContext::Global; + use KeymapContext::List; + + match (self.context, other.context) { + // Presets cannot open while a list owns input. + (Chat, List) => self.action != "open_presets", + (List, Chat) => other.action != "open_presets", + + // Activity focus and Find remain available during list navigation. + (Global, List) => matches!(self.action, "focus_activity" | "find_transcript"), + (List, Global) => matches!(other.action, "focus_activity" | "find_transcript"), + + _ => self.context.overlaps(other.context), + } } pub(crate) fn config_path(self) -> String { @@ -284,6 +292,7 @@ define_runtime_action_bindings! { toggle_side_conversation, ], "chat" => Chat, chat, chat [ + open_presets, toggle_voice, interrupt_turn, decrease_reasoning_effort, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 4c32e26127..865dffcc7b 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -33,6 +33,8 @@ pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1); const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY + 1; const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX; const MAX_LEADER_CHORD_BINDINGS: usize = 1024; + +pub(super) mod quick_dial_defaults; const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ ("cancel", ctrl(KeyCode::Char('c'))), ("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))), @@ -140,6 +142,11 @@ impl KeymapContextSet { /// Whether this input path can dispatch the action, including focus-specific exclusions. pub(crate) fn contains_action(self, action: KeymapActionId) -> bool { + if action.context == KeymapContext::Chat && action.action == "open_presets" { + return self.contains(KeymapContext::Chat) + && self.contains(KeymapContext::Global) + && !self.contains(KeymapContext::List); + } if action.context == KeymapContext::Chat && action.action == "toggle_voice" { return self.0 & VOICE_TOGGLE_BLOCKED == 0 && (self.0 & VOICE_TOGGLE != 0 || self.contains(KeymapContext::Chat)); @@ -199,7 +206,7 @@ pub(crate) struct KeyChord { pub(crate) struct RuntimeChordBinding { pub(crate) action: KeymapActionId, pub(crate) chord: KeyChord, - spec: String, + pub(crate) spec: String, } impl RuntimeChordBinding { @@ -325,11 +332,10 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.", }); } super::leader::resolve(keymap, &mut keymap_chords)?; - if leader_bindings + let explicit_count = leader_bindings .len() - .saturating_mul(keymap_chords.leader.len()) - > MAX_LEADER_CHORD_BINDINGS - { + .saturating_mul(keymap_chords.leader.len()); + if explicit_count > MAX_LEADER_CHORD_BINDINGS { return Err(format!( "Too many leader shortcut combinations (maximum {MAX_LEADER_CHORD_BINDINGS}). \ Reduce `tui.keymap.global.leader` alternatives or `leader ...` bindings." @@ -486,6 +492,7 @@ impl KeyChordMatcher { .take(/*n*/ 6) { let label = match binding.action.action { + "open_presets" if pending.started_at.is_none() => "presets", "focus_activity" => "activity", "find_transcript" => "find", "open_warnings" => "warnings", diff --git a/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs b/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs new file mode 100644 index 0000000000..b1849f7ab1 --- /dev/null +++ b/codex-rs/tui/src/keymap/chords/quick_dial_defaults.rs @@ -0,0 +1,68 @@ +//! Install the implicit preset shortcut only for free leader chords. +//! Explicit bindings, including an explicit unbind, always take priority. +//! Implicit chords fit within the shared leader expansion limit. + +use super::KeyChord; +use super::KeymapActionId; +use super::KeymapContext; +use super::MAX_LEADER_CHORD_BINDINGS; +use super::RuntimeChordBinding; +use super::RuntimeKeymap; +use super::normalize_chord_binding; +use super::runtime_action_bindings; +use codex_config::types::TuiKeymap; +use crossterm::event::KeyCode; + +pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) { + let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub( + resolved + .chords + .bindings + .iter() + .filter(|binding| binding.is_leader()) + .count(), + ); + if keymap.chat.open_presets.is_some() || remaining == 0 { + return; + } + let action = KeymapActionId { + context: KeymapContext::Chat, + action: "open_presets", + }; + for prefix in resolved.chords.leader.clone() { + let binding = RuntimeChordBinding { + action, + chord: KeyChord { + prefix, + completion: crate::key_hint::plain(KeyCode::Char('p')), + }, + spec: "leader p".into(), + }; + if !resolved + .chords + .bindings + .iter() + .any(|existing| binding.overlaps(existing.action) && existing.chord == binding.chord) + && !runtime_action_bindings(resolved) + // Owned transcript navigation also runs beside the composer. + .filter(|single| { + binding.overlaps(single.id) || single.id.context == KeymapContext::Pager + }) + .any(|single| { + single + .bindings + .iter() + .any(|key| normalize_chord_binding(*key) == prefix) + }) + { + std::sync::Arc::make_mut(&mut resolved.chords) + .bindings + .push(binding); + remaining -= 1; + if remaining == 0 { + break; + } + } + } + resolved.chat.chord_hints = std::sync::Arc::clone(&resolved.chords); +} diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 7d8faa0b6e..ae9c92ce36 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -601,20 +601,43 @@ pub(crate) fn active_binding_specs( let bindings = bindings_for_action(runtime_keymap, context, action).ok_or_else(|| { format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.") })?; - if let Some(action_id) = action_id - && let Some(crate::key_hint::ShortcutHint::Chord { prefix, completion }) = - runtime_keymap.chords.primary_hint(action_id, bindings) - { - return Ok(vec![format!( - "{} {}", - binding_to_config_key_spec(prefix)?, - binding_to_config_key_spec(completion)? - )]); - } - bindings + let mut specs = bindings .iter() + .filter(|binding| !crate::keymap::is_dispatch_token(**binding)) .map(|binding| binding_to_config_key_spec(*binding)) - .collect() + .collect::, _>>()?; + if let Some(action_id) = action_id { + let chords: Vec<_> = runtime_keymap + .chords + .bindings + .iter() + .filter(|binding| binding.action == action_id) + .collect(); + let available: std::collections::HashSet<_> = chords + .iter() + .map(|binding| (binding.chord.prefix, binding.chord.completion)) + .collect(); + for binding in &chords { + // An implicit default can yield to a different action on some leader aliases. + // Only persist the symbolic form when all aliases remain available. + let partial_leader = binding.spec.starts_with("leader ") + && runtime_keymap + .chords + .leader + .iter() + .any(|prefix| !available.contains(&(*prefix, binding.chord.completion))); + specs.push(if partial_leader { + format!( + "{} {}", + binding_to_config_key_spec(binding.chord.prefix)?, + binding_to_config_key_spec(binding.chord.completion)? + ) + } else { + binding.spec.clone() + }); + } + } + Ok(dedup_bindings(specs)) } fn dedup_bindings(bindings: Vec) -> Vec { diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 0e9de1220a..de7668b248 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -136,6 +136,7 @@ define_keymap_actions! { prompt_stack_back => "Move back through questions toward the composer.", skip_question => "Skip the focused question.", toggle_voice => "Start or stop a voice conversation.", + open_presets => "Run or save reusable inputs.", toggle_voice_mute => "Mute or unmute the voice microphone.", } composer "Composer" { diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index ab1c5ac979..bec1df2c81 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 152 actions, 1 customized, 5 unbound. + 153 actions, 1 customized, 5 unbound. All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval › @@ -31,9 +31,9 @@ expression: "render_picker(params, 120)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 + Chat Open Presets leader p (ctrl-x p) Chat Toggle Voice Mute f9 Composer * Submit ctrl-enter Composer Queue tab - Composer Toggle Shortcuts ?, shift-? ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 87197e2c21..ae034dbccf 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 153 actions, 0 customized, 6 unbound. + 154 actions, 0 customized, 6 unbound. All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval › @@ -32,8 +32,8 @@ expression: "render_picker(params, 120)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 + Chat Open Presets leader p (ctrl-x p) Chat Toggle Voice Mute f9 Composer Submit enter - Composer Queue tab ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index f539bb578e..cc5ba997ed 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,11 +2,11 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (152 selectable) +tab: All (153 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (5) (5 selectable) -tab: App (21 selectable) +tab: App (22 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (69 selectable) diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap index e517985cb8..401b5edfa5 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_global_chord.snap @@ -5,9 +5,9 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 152 actions, 1 customized, 6 unbound. + 153 actions, 1 customized, 7 unbound. - All Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval › + All Common Customized (1) Unbound (7) App Composer Editor Vim Navigation Agents Approval › Type to search shortcuts @@ -31,9 +31,9 @@ expression: "render_picker(params, 120)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 + Chat - Open Presets unbound Chat Toggle Voice Mute f9 Composer Submit enter Composer Queue tab - Composer Toggle Shortcuts ?, shift-? ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 0d353cc83b..959a54cb0a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 152 actions, 0 customized, 5 unbound. + 153 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor › @@ -31,9 +31,9 @@ expression: "render_picker(params, 78)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 + Chat Open Presets leader p (ctrl-x p) Chat Toggle Voice Mute f9 Composer Submit enter Composer Queue tab - Composer Toggle Shortcuts ?, shift-? ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index a7e7335399..edf78a1c92 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 152 actions, 0 customized, 5 unbound. + 153 actions, 0 customized, 5 unbound. All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval › @@ -31,9 +31,9 @@ expression: "render_picker(params, 120)" Chat Prompt Stack Back shift-right, alt-down Chat Skip Question ctrl-] Chat Toggle Voice f8 + Chat Open Presets leader p (ctrl-x p) Chat Toggle Voice Mute f9 Composer Submit enter Composer Queue tab - Composer Toggle Shortcuts ?, shift-? ↓ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/tooltips/keybinding_tests.rs b/codex-rs/tui/src/tooltips/keybinding_tests.rs index 7811f83a9d..5b8a549f56 100644 --- a/codex-rs/tui/src/tooltips/keybinding_tests.rs +++ b/codex-rs/tui/src/tooltips/keybinding_tests.rs @@ -19,6 +19,7 @@ fn configured_shortcut_tips_render_at_narrow_width() { "queue": "ctrl-x q" }, "chat": { + "open_presets": "ctrl-x p", "increase_reasoning_effort": "ctrl-x +", "decrease_reasoning_effort": "ctrl-x minus" } diff --git a/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap b/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap index 467be5f32f..1bcfccadd7 100644 --- a/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap +++ b/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap @@ -26,6 +26,9 @@ Line::from_iter([Span::from("Tip:").fg(Color::Reset).bold(), Span::from(" For mo Line::from_iter([Span::from("reasoning, press "), Span::from("⌥.").fg(Color::Rgb(166, 227, 161)), Span::from(" to increase")]) Line::from_iter([Span::from("reasoning effort or "), Span::from("⌥,").fg(Color::Rgb(166, 227, 161)), Span::from(" to decrease it.")]) Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use "), Span::from("⌃x p").fg(Color::Rgb(166, 227, 161)), Span::from(", then 0–9 to run a preset;")]) +Line::from("Enter browses, s saves, and d deletes.") +Line::default() Line::from("Remapped") Line::from_iter([Span::from("Tip:").fg(Color::Reset).bold(), Span::from(" Press "), Span::from("⌃x q").fg(Color::Rgb(166, 227, 161)), Span::from(" to queue a message when")]) Line::from("a task is running; otherwise it sends") @@ -50,3 +53,6 @@ Line::from_iter([Span::from("Tip:").fg(Color::Reset).bold(), Span::from(" For mo Line::from_iter([Span::from("reasoning, press "), Span::from("⌃x +").fg(Color::Rgb(166, 227, 161)), Span::from(" to increase")]) Line::from_iter([Span::from("reasoning effort or "), Span::from("⌃x -").fg(Color::Rgb(166, 227, 161)), Span::from(" to decrease it.")]) Line::default() +Line::from_iter([Span::from("Tip:").bold(), Span::from(" Use "), Span::from("⌃x p").fg(Color::Rgb(166, 227, 161)), Span::from(", then 0–9 to run a preset;")]) +Line::from("Enter browses, s saves, and d deletes.") +Line::default()