diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index b23322a53886..fcce1fa8abd1 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -104,6 +104,8 @@ pub struct TuiGlobalKeymap { pub toggle_shortcuts: Option, /// Toggle Vim mode for the composer input. pub toggle_vim_mode: Option, + /// Toggle Fast mode. + pub toggle_fast_mode: Option, } /// Chat context keybindings. @@ -169,6 +171,8 @@ pub struct TuiEditorKeymap { pub delete_forward_word: Option, /// Kill text from cursor to line start. pub kill_line_start: Option, + /// Kill the current line. + pub kill_whole_line: Option, /// Kill text from cursor to line end. pub kill_line_end: Option, /// Yank the kill buffer. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a75e28713455..824f542b7eeb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2441,6 +2441,7 @@ "insert_newline": null, "kill_line_end": null, "kill_line_start": null, + "kill_whole_line": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -2458,6 +2459,7 @@ "open_transcript": null, "queue": null, "submit": null, + "toggle_fast_mode": null, "toggle_shortcuts": null, "toggle_vim_mode": null }, @@ -2811,6 +2813,14 @@ ], "description": "Kill text from cursor to line start." }, + "kill_whole_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill the current line." + }, "move_down": { "allOf": [ { @@ -2938,6 +2948,14 @@ ], "description": "Submit the current composer draft." }, + "toggle_fast_mode": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle Fast mode." + }, "toggle_shortcuts": { "allOf": [ { @@ -3018,6 +3036,7 @@ "insert_newline": null, "kill_line_end": null, "kill_line_start": null, + "kill_whole_line": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -3042,6 +3061,7 @@ "open_transcript": null, "queue": null, "submit": null, + "toggle_fast_mode": null, "toggle_shortcuts": null, "toggle_vim_mode": null } diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index a9cce1f35358..f223db9bb3ff 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -129,6 +129,14 @@ impl App { return; } + if app_keymap_shortcuts_available + && self.keymap.app.toggle_fast_mode.is_pressed(key_event) + && self.chat_widget.can_toggle_fast_mode_from_keybinding() + { + self.chat_widget.toggle_fast_mode_from_ui(); + return; + } + if app_keymap_shortcuts_available && self.keymap.app.open_transcript.is_pressed(key_event) { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 268665ef06d7..c6c756e3e042 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -569,6 +569,10 @@ impl TextArea { self.kill_to_beginning_of_line(); return; } + if keymap.kill_whole_line.is_pressed(event) { + self.kill_current_line(); + return; + } if keymap.kill_line_end.is_pressed(event) { self.kill_to_end_of_line(); return; @@ -780,7 +784,7 @@ impl TextArea { fn handle_vim_operator(&mut self, op: VimOperator, event: KeyEvent) -> bool { if op == VimOperator::Delete && self.vim_operator_keymap.delete_line.is_pressed(event) { - self.delete_current_line(); + self.kill_current_line(); return true; } if op == VimOperator::Yank && self.vim_operator_keymap.yank_line.is_pressed(event) { @@ -1116,7 +1120,7 @@ impl TextArea { self.yank_line_range(range); } - fn delete_current_line(&mut self) { + fn kill_current_line(&mut self) { let range = self.current_line_range_with_newline(); self.kill_line_range(range); } @@ -2447,6 +2451,51 @@ mod tests { assert_eq!(t.cursor(), 3); } + #[test] + fn kill_current_line_removes_current_line_linewise() { + let mut t = ta_with("abc\ndef\nghi"); + t.set_cursor(/*pos*/ 5); + + t.kill_current_line(); + + assert_eq!(t.text(), "abc\nghi"); + assert_eq!(t.cursor(), 4); + assert_eq!(t.kill_buffer, "def\n"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + } + + #[test] + fn kill_current_line_keeps_previous_newline_for_final_line() { + let mut t = ta_with("abc\ndef"); + t.set_cursor(/*pos*/ 5); + + t.kill_current_line(); + + assert_eq!(t.text(), "abc\n"); + assert_eq!(t.cursor(), 4); + assert_eq!(t.kill_buffer, "def"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + } + + #[test] + fn kill_whole_line_keymap_dispatch_uses_linewise_kill() { + let mut t = ta_with("abc\ndef\nghi"); + t.set_cursor(/*pos*/ 5); + let mut keymap = RuntimeKeymap::defaults().editor; + keymap.kill_line_start.clear(); + keymap.kill_whole_line = vec![key_hint::ctrl(KeyCode::Char('u'))]; + + t.input_with_keymap( + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), + &keymap, + ); + + assert_eq!(t.text(), "abc\nghi"); + assert_eq!(t.cursor(), 4); + assert_eq!(t.kill_buffer, "def\n"); + assert_eq!(t.kill_buffer_kind, KillBufferKind::Linewise); + } + #[test] fn delete_forward_word_variants() { let mut t = ta_with("hello world "); @@ -2668,6 +2717,17 @@ mod tests { assert_eq!(t.cursor(), 2); } + #[test] + fn c0_line_feed_inserts_newline_through_insert_newline_keymap() { + let mut t = ta_with("ab"); + t.set_cursor(/*pos*/ 1); + + t.input(KeyEvent::new(KeyCode::Char('\u{000a}'), KeyModifiers::NONE)); + + assert_eq!(t.text(), "a\nb"); + assert_eq!(t.cursor(), 2); + } + #[test] fn c0_control_chars_respect_unbound_editor_movement() { let mut t = ta_with("a\nb"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d06c67456608..6dc68c094887 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -9267,6 +9267,12 @@ impl ChatWidget { self.config.features.enabled(Feature::FastMode) } + pub(crate) fn can_toggle_fast_mode_from_keybinding(&self) -> bool { + self.fast_mode_enabled() + && !self.is_user_turn_pending_or_running() + && self.bottom_pane.no_modal_or_popup_active() + } + pub(crate) fn set_realtime_audio_device( &mut self, kind: RealtimeAudioDeviceKind, @@ -9321,6 +9327,15 @@ impl ChatWidget { .send(AppEvent::PersistServiceTierSelection { service_tier }); } + pub(crate) fn toggle_fast_mode_from_ui(&mut self) { + let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { + None + } else { + Some(ServiceTier::Fast) + }; + self.set_service_tier_selection(next_tier); + } + pub(crate) fn current_model(&self) -> &str { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.model(); diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 7e0b3811e478..8e5f01f04aa7 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -30,9 +30,10 @@ impl ChatWidget { pub(crate) fn open_keymap_picker(&mut self) { match RuntimeKeymap::from_config(&self.config.tui_keymap) { Ok(runtime_keymap) => { - let params = keymap_setup::build_keymap_picker_params( + let params = keymap_setup::build_keymap_picker_params_with_filter( &runtime_keymap, &self.config.tui_keymap, + self.keymap_action_filter(), ); self.bottom_pane.show_selection_view(params); } @@ -120,9 +121,10 @@ impl ChatWidget { action: &str, runtime_keymap: &RuntimeKeymap, ) { - let params = keymap_setup::build_keymap_picker_params_for_selected_action( + let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter( runtime_keymap, &self.config.tui_keymap, + self.keymap_action_filter(), context, action, ); @@ -135,9 +137,10 @@ impl ChatWidget { params, ); if !replaced { - let params = keymap_setup::build_keymap_picker_params_for_selected_action( + let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter( runtime_keymap, &self.config.tui_keymap, + self.keymap_action_filter(), context, action, ); @@ -146,6 +149,12 @@ impl ChatWidget { self.request_redraw(); } + fn keymap_action_filter(&self) -> keymap_setup::KeymapActionFilter { + keymap_setup::KeymapActionFilter { + fast_mode_enabled: self.fast_mode_enabled(), + } + } + /// Applies a committed keymap edit to the live chat widget. /// /// The caller is responsible for persisting the config file before invoking this method. This diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index a6cc50feb033..aaa71cca8053 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -178,12 +178,7 @@ impl ChatWidget { self.open_model_popup(); } SlashCommand::Fast => { - let next_tier = if matches!(self.current_service_tier(), Some(ServiceTier::Fast)) { - None - } else { - Some(ServiceTier::Fast) - }; - self.set_service_tier_selection(next_tier); + self.toggle_fast_mode_from_ui(); } SlashCommand::Realtime => { if !self.realtime_conversation_enabled() { diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index b376b4782874..84cc6e84a576 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1817,6 +1817,51 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + + chat.toggle_fast_mode_from_ui(); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::CodexOp(Op::OverrideTurnContext { + service_tier: Some(Some(ServiceTier::Fast)), + .. + }) + )), + "expected fast-mode override app event; events: {events:?}" + ); + assert!( + events.iter().any(|event| matches!( + event, + AppEvent::PersistServiceTierSelection { + service_tier: Some(ServiceTier::Fast), + } + )), + "expected fast-mode persistence app event; events: {events:?}" + ); + + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +async fn fast_keybinding_toggle_requires_feature_and_idle_surface() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + chat.set_feature_enabled(Feature::FastMode, /*enabled*/ false); + + assert!(!chat.can_toggle_fast_mode_from_keybinding()); + + chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + assert!(chat.can_toggle_fast_mode_from_keybinding()); + + chat.bottom_pane.set_task_running(/*running*/ true); + assert!(!chat.can_toggle_fast_mode_from_keybinding()); +} + #[tokio::test] async fn user_turn_carries_service_tier_after_fast_toggle() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 1d32707e6ebe..1008a00aa5e3 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -2,7 +2,8 @@ //! //! This module provides `KeyBinding`, the runtime representation of a single //! keybinding (key code + modifier set), along with matching logic that handles -//! cross-terminal inconsistencies in how shifted letters are reported. +//! cross-terminal inconsistencies in how shifted letters and raw C0 control +//! characters are reported. //! //! It also supplies rendering helpers that convert bindings into styled //! `ratatui::text::Span` values for UI hint display. @@ -26,10 +27,10 @@ const SHIFT_PREFIX: &str = "shift + "; /// One concrete key event that can trigger a TUI action. /// -/// Matching via `is_press` handles both exact equality and a shifted-letter -/// compatibility fallback for terminals that report uppercase letters without -/// the SHIFT modifier flag. This means a binding defined as `shift-a` will -/// match a terminal event of either `Shift+a` or plain `A`. +/// Matching via `is_press` handles exact equality plus compatibility fallbacks +/// for terminals that report uppercase letters without SHIFT and Ctrl keys as +/// raw C0 control characters. This means a binding defined as `shift-a` will +/// match either `Shift+a` or plain `A`, and `ctrl-j` will match raw LF. /// /// This does not model multi-key chords or partial matches; callers that need /// sequences must keep that state outside this type. @@ -45,13 +46,13 @@ impl KeyBinding { } pub(crate) fn from_event(event: KeyEvent) -> Self { - let (key, modifiers) = normalize_shifted_ascii_char(event.code, event.modifiers); + let (key, modifiers) = normalize_key_parts(event.code, event.modifiers); Self { key, modifiers } } pub fn is_press(&self, event: KeyEvent) -> bool { - normalize_shifted_ascii_char(self.key, self.modifiers) - == normalize_shifted_ascii_char(event.code, event.modifiers) + normalize_key_parts(self.key, self.modifiers) + == normalize_key_parts(event.code, event.modifiers) && (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat) } @@ -76,7 +77,7 @@ impl KeyBinding { } } -fn normalize_shifted_ascii_char( +pub(crate) fn normalize_key_parts( key: KeyCode, mut modifiers: KeyModifiers, ) -> (KeyCode, KeyModifiers) { @@ -96,13 +97,11 @@ fn normalize_shifted_ascii_char( } fn c0_control_char_to_ctrl_char(ch: char) -> Option { - match ch { - '\u{0002}' => Some('b'), - '\u{0006}' => Some('f'), - '\u{000e}' => Some('n'), - '\u{0010}' => Some('p'), - '\u{0012}' => Some('r'), - '\u{0013}' => Some('s'), + let code = u32::from(ch); + match code { + 0x00 => Some(' '), + 0x01..=0x1a => char::from_u32(code - 0x01 + u32::from('a')), + 0x1c..=0x1f => char::from_u32(code - 0x1c + u32::from('4')), _ => None, } } @@ -256,6 +255,68 @@ mod tests { assert!(!binding.is_press(KeyEvent::new(KeyCode::Char('\u{0010}'), KeyModifiers::ALT))); } + #[test] + fn ctrl_bindings_match_all_supported_c0_control_char_events() { + let cases = [ + (' ', '\u{0000}'), + ('a', '\u{0001}'), + ('b', '\u{0002}'), + ('c', '\u{0003}'), + ('d', '\u{0004}'), + ('e', '\u{0005}'), + ('f', '\u{0006}'), + ('g', '\u{0007}'), + ('h', '\u{0008}'), + ('i', '\u{0009}'), + ('j', '\u{000a}'), + ('k', '\u{000b}'), + ('l', '\u{000c}'), + ('m', '\u{000d}'), + ('n', '\u{000e}'), + ('o', '\u{000f}'), + ('p', '\u{0010}'), + ('q', '\u{0011}'), + ('r', '\u{0012}'), + ('s', '\u{0013}'), + ('t', '\u{0014}'), + ('u', '\u{0015}'), + ('v', '\u{0016}'), + ('w', '\u{0017}'), + ('x', '\u{0018}'), + ('y', '\u{0019}'), + ('z', '\u{001a}'), + ('4', '\u{001c}'), + ('5', '\u{001d}'), + ('6', '\u{001e}'), + ('7', '\u{001f}'), + ]; + + for (ctrl_char, c0_char) in cases { + assert!( + ctrl(KeyCode::Char(ctrl_char)) + .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::NONE)), + "expected raw C0 {c0_char:?} to match ctrl-{ctrl_char}" + ); + assert!( + !ctrl(KeyCode::Char(ctrl_char)) + .is_press(KeyEvent::new(KeyCode::Char(c0_char), KeyModifiers::ALT)), + "expected modified raw C0 {c0_char:?} not to match ctrl-{ctrl_char}" + ); + } + } + + #[test] + fn ctrl_binding_does_not_match_ambiguous_c0_escape_or_delete() { + assert!( + !ctrl(KeyCode::Char('[')) + .is_press(KeyEvent::new(KeyCode::Char('\u{001b}'), KeyModifiers::NONE,)) + ); + assert!( + !ctrl(KeyCode::Char('?')) + .is_press(KeyEvent::new(KeyCode::Char('\u{007f}'), KeyModifiers::NONE,)) + ); + } + #[test] fn history_search_ctrl_bindings_match_c0_control_char_events() { assert!( diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index ec5264030fb6..419a5ae8cb49 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -63,6 +63,8 @@ pub(crate) struct AppKeymap { pub(crate) clear_terminal: Vec, /// Toggle Vim mode for the composer input. pub(crate) toggle_vim_mode: Vec, + /// Toggle Fast mode. + pub(crate) toggle_fast_mode: Vec, } /// Chat-level keybindings evaluated at the app event layer. @@ -120,6 +122,7 @@ pub(crate) struct EditorKeymap { pub(crate) delete_backward_word: Vec, pub(crate) delete_forward_word: Vec, pub(crate) kill_line_start: Vec, + pub(crate) kill_whole_line: Vec, pub(crate) kill_line_end: Vec, pub(crate) yank: Vec, } @@ -369,6 +372,11 @@ impl RuntimeKeymap { &defaults.app.toggle_vim_mode, "tui.keymap.global.toggle_vim_mode", )?, + toggle_fast_mode: resolve_bindings( + keymap.global.toggle_fast_mode.as_ref(), + &defaults.app.toggle_fast_mode, + "tui.keymap.global.toggle_fast_mode", + )?, }; let chat = ChatKeymap { @@ -417,6 +425,7 @@ impl RuntimeKeymap { delete_backward_word: resolve_local!(keymap, defaults, editor, delete_backward_word), delete_forward_word: resolve_local!(keymap, defaults, editor, delete_forward_word), kill_line_start: resolve_local!(keymap, defaults, editor, kill_line_start), + kill_whole_line: resolve_local!(keymap, defaults, editor, kill_whole_line), kill_line_end: resolve_local!(keymap, defaults, editor, kill_line_end), yank: resolve_local!(keymap, defaults, editor, yank), }; @@ -536,6 +545,7 @@ impl RuntimeKeymap { copy: default_bindings![ctrl(KeyCode::Char('o'))], clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))], toggle_vim_mode: default_bindings![], + toggle_fast_mode: default_bindings![], }, chat: ChatKeymap { decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))], @@ -594,6 +604,7 @@ impl RuntimeKeymap { alt(KeyCode::Char('d')) ], kill_line_start: default_bindings![ctrl(KeyCode::Char('u'))], + kill_whole_line: default_bindings![], kill_line_end: default_bindings![ctrl(KeyCode::Char('k'))], yank: default_bindings![ctrl(KeyCode::Char('y'))], }, @@ -727,6 +738,7 @@ impl RuntimeKeymap { ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), + ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -767,6 +779,7 @@ impl RuntimeKeymap { ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), + ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), @@ -808,6 +821,7 @@ impl RuntimeKeymap { ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), + ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ], [ ("list.move_up", self.list.move_up.as_slice()), @@ -856,6 +870,7 @@ impl RuntimeKeymap { ), ("composer.submit", self.composer.submit.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), + ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), @@ -903,6 +918,10 @@ impl RuntimeKeymap { "editor.kill_line_start", self.editor.kill_line_start.as_slice(), ), + ( + "editor.kill_whole_line", + self.editor.kill_whole_line.as_slice(), + ), ("editor.kill_line_end", self.editor.kill_line_end.as_slice()), ("editor.yank", self.editor.yank.as_slice()), ], @@ -936,6 +955,7 @@ impl RuntimeKeymap { self.editor.delete_forward_word.as_slice(), ), ("kill_line_start", self.editor.kill_line_start.as_slice()), + ("kill_whole_line", self.editor.kill_whole_line.as_slice()), ("kill_line_end", self.editor.kill_line_end.as_slice()), ("yank", self.editor.yank.as_slice()), ], @@ -1389,6 +1409,7 @@ fn parse_keybinding(spec: &str) -> Option { "page-up" => KeyCode::PageUp, "page-down" => KeyCode::PageDown, "space" => KeyCode::Char(' '), + "minus" => KeyCode::Char('-'), other if other.len() == 1 => KeyCode::Char(char::from(other.as_bytes()[0])), other if other.starts_with('f') => { let number = other[1..].parse::().ok()?; @@ -1587,6 +1608,7 @@ mod tests { runtime.app.clear_terminal, vec![key_hint::ctrl(KeyCode::Char('l'))] ); + assert_eq!(runtime.app.toggle_fast_mode, Vec::new()); assert_eq!( runtime.chat.decrease_reasoning_effort, vec![key_hint::alt(KeyCode::Char(','))] @@ -1607,6 +1629,7 @@ mod tests { runtime.composer.history_search_next, vec![key_hint::ctrl(KeyCode::Char('s'))] ); + assert_eq!(runtime.editor.kill_whole_line, Vec::new()); } #[test] @@ -1732,6 +1755,61 @@ mod tests { assert_eq!(runtime.app.copy, vec![key_hint::alt(KeyCode::Char('.'))]); } + #[test] + fn kill_whole_line_can_be_assigned_without_default_binding() { + let mut keymap = TuiKeymap::default(); + keymap.editor.kill_whole_line = Some(one("ctrl-shift-u")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + + assert_eq!( + runtime.editor.kill_whole_line, + vec![KeyBinding::new( + KeyCode::Char('u'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )] + ); + } + + #[test] + fn kill_whole_line_conflicts_until_kill_line_start_is_unbound() { + let mut keymap = TuiKeymap::default(); + keymap.editor.kill_whole_line = Some(one("ctrl-u")); + + expect_conflict(&keymap, "kill_line_start", "kill_whole_line"); + + keymap.editor.kill_line_start = Some(KeybindingsSpec::Many(vec![])); + let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free"); + assert_eq!( + runtime.editor.kill_whole_line, + vec![key_hint::ctrl(KeyCode::Char('u'))] + ); + } + + #[test] + fn toggle_fast_mode_can_be_assigned_without_default_binding() { + let mut keymap = TuiKeymap::default(); + keymap.global.toggle_fast_mode = Some(one("ctrl-shift-f")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + + assert_eq!( + runtime.app.toggle_fast_mode, + vec![KeyBinding::new( + KeyCode::Char('f'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + )] + ); + } + + #[test] + fn toggle_fast_mode_conflicts_with_existing_main_surface_bindings() { + let mut keymap = TuiKeymap::default(); + keymap.global.toggle_fast_mode = Some(one("ctrl-l")); + + expect_conflict(&keymap, "clear_terminal", "toggle_fast_mode"); + } + #[test] fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() { let mut keymap = TuiKeymap::default(); @@ -1773,6 +1851,7 @@ mod tests { ("page-up", KeyCode::PageUp), ("page-down", KeyCode::PageDown), ("space", KeyCode::Char(' ')), + ("minus", KeyCode::Char('-')), ]; for (spec, expected_key) in cases { @@ -1790,6 +1869,22 @@ mod tests { assert_eq!(parse_keybinding("ff"), None); } + #[test] + fn parses_minus_alias_and_legacy_literal_minus() { + assert_eq!( + parse_keybinding("alt-minus").map(|binding| binding.parts()), + Some((KeyCode::Char('-'), KeyModifiers::ALT)) + ); + assert_eq!( + parse_keybinding("alt--").map(|binding| binding.parts()), + Some((KeyCode::Char('-'), KeyModifiers::ALT)) + ); + assert_eq!( + parse_keybinding("-").map(|binding| binding.parts()), + Some((KeyCode::Char('-'), KeyModifiers::NONE)) + ); + } + #[test] fn explicit_empty_array_unbinds_action() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index c1ae82c3e8eb..f67ea20cb063 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -21,10 +21,15 @@ mod actions; mod debug; mod picker; +pub(crate) use actions::KeymapActionFilter; pub(crate) use debug::build_keymap_debug_view; pub(crate) use picker::KEYMAP_PICKER_VIEW_ID; +#[cfg(test)] pub(crate) use picker::build_keymap_picker_params; +#[cfg(test)] pub(crate) use picker::build_keymap_picker_params_for_selected_action; +pub(crate) use picker::build_keymap_picker_params_for_selected_action_with_filter; +pub(crate) use picker::build_keymap_picker_params_with_filter; use codex_config::types::KeybindingSpec; use codex_config::types::KeybindingsSpec; @@ -708,6 +713,9 @@ fn key_parts_to_config_key_spec( code: KeyCode, mut modifiers: KeyModifiers, ) -> Result { + let (code, normalized_modifiers) = crate::key_hint::normalize_key_parts(code, modifiers); + modifiers = normalized_modifiers; + let supported_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT; if !modifiers.difference(supported_modifiers).is_empty() { return Err( @@ -738,7 +746,7 @@ fn key_parts_to_config_key_spec( KeyCode::Char(' ') => "space".to_string(), KeyCode::Char(mut ch) => { if ch == '-' { - return Err("The `-` key cannot be represented in `tui.keymap` yet.".to_string()); + return Ok(format_key_spec(modifiers, "minus")); } if !ch.is_ascii() || ch.is_ascii_control() { return Err("Only printable ASCII keys can be stored in `tui.keymap`.".to_string()); @@ -754,18 +762,22 @@ fn key_parts_to_config_key_spec( } }; + Ok(format_key_spec(modifiers, &key)) +} + +fn format_key_spec(modifiers: KeyModifiers, key: &str) -> String { let mut parts = Vec::new(); if modifiers.contains(KeyModifiers::CONTROL) { - parts.push("ctrl".to_string()); + parts.push("ctrl"); } if modifiers.contains(KeyModifiers::ALT) { - parts.push("alt".to_string()); + parts.push("alt"); } if modifiers.contains(KeyModifiers::SHIFT) { - parts.push("shift".to_string()); + parts.push("shift"); } parts.push(key); - Ok(parts.join("-")) + parts.join("-") } #[cfg(test)] @@ -821,6 +833,12 @@ mod tests { render_buffer(&buf) } + fn fast_mode_action_filter() -> KeymapActionFilter { + KeymapActionFilter { + fast_mode_enabled: true, + } + } + fn render_buffer(buf: &Buffer) -> String { let area = buf.area(); (0..area.height) @@ -891,7 +909,11 @@ mod tests { #[test] fn picker_covers_every_replaceable_action() { let runtime = RuntimeKeymap::defaults(); - let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let params = build_keymap_picker_params_with_filter( + &runtime, + &TuiKeymap::default(), + fast_mode_action_filter(), + ); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); assert!(params.items.is_empty()); @@ -913,6 +935,57 @@ mod tests { })); } + #[test] + fn picker_hides_fast_mode_action_when_feature_is_disabled() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + + assert!( + all_tab + .items + .iter() + .all(|item| item.name != "Toggle Fast Mode") + ); + } + + #[test] + fn picker_shows_fast_mode_action_when_feature_is_enabled() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params_with_filter( + &runtime, + &TuiKeymap::default(), + fast_mode_action_filter(), + ); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + let common_tab = selection_tab(¶ms, KEYMAP_COMMON_TAB_ID); + let app_tab = selection_tab(¶ms, "app-shortcuts"); + let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID); + + for tab in [all_tab, common_tab, app_tab, unbound_tab] { + assert!( + tab.items.iter().any(|item| item.name == "Toggle Fast Mode"), + "expected Toggle Fast Mode in {}", + tab.label + ); + } + } + + #[test] + fn keymap_picker_fast_mode_enabled_snapshot() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params_with_filter( + &runtime, + &TuiKeymap::default(), + fast_mode_action_filter(), + ); + + assert_snapshot!( + "keymap_picker_fast_mode_enabled", + render_picker(params, /*width*/ 120) + ); + } + #[test] fn picker_common_tab_lists_curated_actions() { let runtime = RuntimeKeymap::defaults(); @@ -1050,10 +1123,13 @@ mod tests { let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID); - assert_eq!(unbound_tab.items.len(), 1); + assert_eq!(unbound_tab.items.len(), 2); assert_eq!(unbound_tab.items[0].name, "Toggle Vim Mode"); assert_eq!(unbound_tab.items[0].description.as_deref(), Some("unbound")); assert!(!unbound_tab.items[0].is_disabled); + assert_eq!(unbound_tab.items[1].name, "Kill Whole Line"); + assert_eq!(unbound_tab.items[1].description.as_deref(), Some("unbound")); + assert!(!unbound_tab.items[1].is_disabled); } #[test] @@ -1534,21 +1610,46 @@ mod tests { } #[test] - fn key_capture_serializes_c0_control_fallbacks() { + fn key_capture_serializes_c0_control_chars_as_ctrl_bindings() { + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new( + KeyCode::Char('\u{000a}'), + KeyModifiers::NONE, + )), + Ok("ctrl-j".to_string()) + ); + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new( + KeyCode::Char('\u{0015}'), + KeyModifiers::NONE, + )), + Ok("ctrl-u".to_string()) + ); assert_eq!( key_event_to_config_key_spec(KeyEvent::new( KeyCode::Char('\u{0010}'), - KeyModifiers::NONE + KeyModifiers::NONE, )), Ok("ctrl-p".to_string()) ); } #[test] - fn key_capture_rejects_unrepresentable_keys() { - assert!( - key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)) - .is_err() + fn key_capture_serializes_minus_as_named_key() { + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)), + Ok("minus".to_string()) + ); + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::ALT)), + Ok("alt-minus".to_string()) + ); + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new( + KeyCode::Char('-'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )), + Ok("ctrl-alt-minus".to_string()) ); } diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 66103ab89086..b6f9660bdd7c 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -30,6 +30,8 @@ pub(super) struct KeymapActionDescriptor { pub(super) action: &'static str, /// Short user-facing explanation of what the action does. pub(super) description: &'static str, + /// Feature required before the action appears in `/keymap`. + required_feature: Option, } const fn action( @@ -43,6 +45,42 @@ const fn action( context_label, action, description, + required_feature: None, + } +} + +const fn gated_action( + context: &'static str, + context_label: &'static str, + action: &'static str, + description: &'static str, + required_feature: KeymapActionFeature, +) -> KeymapActionDescriptor { + KeymapActionDescriptor { + context, + context_label, + action, + description, + required_feature: Some(required_feature), + } +} + +#[derive(Clone, Copy, Debug)] +enum KeymapActionFeature { + FastMode, +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct KeymapActionFilter { + pub(crate) fast_mode_enabled: bool, +} + +impl KeymapActionDescriptor { + pub(super) fn is_visible(self, filter: KeymapActionFilter) -> bool { + match self.required_feature { + None => true, + Some(KeymapActionFeature::FastMode) => filter.fast_mode_enabled, + } } } @@ -53,6 +91,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "copy", "Copy the last agent response to the clipboard."), action("global", "Global", "clear_terminal", "Clear the terminal UI."), action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), + gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."), @@ -75,6 +114,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("editor", "Editor", "delete_backward_word", "Delete the previous word."), action("editor", "Editor", "delete_forward_word", "Delete the next word."), action("editor", "Editor", "kill_line_start", "Delete from cursor to line start."), + action("editor", "Editor", "kill_whole_line", "Delete the current line."), action("editor", "Editor", "kill_line_end", "Delete from cursor to line end."), action("editor", "Editor", "yank", "Paste the kill buffer."), action("vim_normal", "Vim normal", "enter_insert", "Enter insert mode at the cursor."), @@ -172,6 +212,7 @@ pub(super) fn binding_slot<'a>( ("global", "copy") => Some(&mut keymap.global.copy), ("global", "clear_terminal") => Some(&mut keymap.global.clear_terminal), ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), + ("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), ("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message), @@ -194,6 +235,7 @@ pub(super) fn binding_slot<'a>( ("editor", "delete_backward_word") => Some(&mut keymap.editor.delete_backward_word), ("editor", "delete_forward_word") => Some(&mut keymap.editor.delete_forward_word), ("editor", "kill_line_start") => Some(&mut keymap.editor.kill_line_start), + ("editor", "kill_whole_line") => Some(&mut keymap.editor.kill_whole_line), ("editor", "kill_line_end") => Some(&mut keymap.editor.kill_line_end), ("editor", "yank") => Some(&mut keymap.editor.yank), ("vim_normal", "enter_insert") => Some(&mut keymap.vim_normal.enter_insert), @@ -273,6 +315,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "copy") => Some(runtime_keymap.app.copy.as_slice()), ("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()), ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), + ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), ("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()), @@ -295,6 +338,7 @@ pub(super) fn bindings_for_action<'a>( ("editor", "delete_backward_word") => Some(runtime_keymap.editor.delete_backward_word.as_slice()), ("editor", "delete_forward_word") => Some(runtime_keymap.editor.delete_forward_word.as_slice()), ("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()), + ("editor", "kill_whole_line") => Some(runtime_keymap.editor.kill_whole_line.as_slice()), ("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()), ("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()), ("vim_normal", "enter_insert") => Some(runtime_keymap.vim_normal.enter_insert.as_slice()), diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index c46d22cc9b50..ed62d0c2e5a9 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -17,6 +17,7 @@ use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use super::actions::KEYMAP_ACTIONS; +use super::actions::KeymapActionFilter; use super::actions::action_label; use super::actions::bindings_for_action; use super::actions::format_binding_summary; @@ -59,6 +60,7 @@ const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[ ("composer", "submit"), ("editor", "insert_newline"), ("composer", "queue"), + ("global", "toggle_fast_mode"), ("global", "open_external_editor"), ("global", "copy"), ("global", "toggle_vim_mode"), @@ -116,32 +118,69 @@ const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ }, ]; +#[cfg(test)] pub(crate) fn build_keymap_picker_params( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, +) -> SelectionViewParams { + build_keymap_picker_params_with_filter( + runtime_keymap, + keymap_config, + KeymapActionFilter::default(), + ) +} + +pub(crate) fn build_keymap_picker_params_with_filter( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, + action_filter: KeymapActionFilter, ) -> SelectionViewParams { build_keymap_picker_params_for_action( runtime_keymap, keymap_config, + action_filter, /*selected_action*/ None, ) } +#[cfg(test)] pub(crate) fn build_keymap_picker_params_for_selected_action( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, context: &str, action: &str, ) -> SelectionViewParams { - build_keymap_picker_params_for_action(runtime_keymap, keymap_config, Some((context, action))) + build_keymap_picker_params_for_selected_action_with_filter( + runtime_keymap, + keymap_config, + KeymapActionFilter::default(), + context, + action, + ) +} + +pub(crate) fn build_keymap_picker_params_for_selected_action_with_filter( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, + action_filter: KeymapActionFilter, + context: &str, + action: &str, +) -> SelectionViewParams { + build_keymap_picker_params_for_action( + runtime_keymap, + keymap_config, + action_filter, + Some((context, action)), + ) } fn build_keymap_picker_params_for_action( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, + action_filter: KeymapActionFilter, selected_action: Option<(&str, &str)>, ) -> SelectionViewParams { - let rows = build_keymap_rows(runtime_keymap, keymap_config); + let rows = build_keymap_rows(runtime_keymap, keymap_config, action_filter); let total = rows.len(); let custom_count = rows.iter().filter(|row| row.custom_binding).count(); let unbound_count = rows.iter().filter(|row| row.is_unbound()).count(); @@ -287,9 +326,12 @@ fn keymap_debug_tab() -> SelectionTab { fn build_keymap_rows( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, + action_filter: KeymapActionFilter, ) -> Vec { KEYMAP_ACTIONS .iter() + .copied() + .filter(|descriptor| descriptor.is_visible(action_filter)) .map(|descriptor| { let bindings = bindings_for_action(runtime_keymap, descriptor.context, descriptor.action) 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 b0336cd2073a..ab398780e498 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,9 +5,9 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 85 actions, 1 customized, 1 unbound. + 86 actions, 1 customized, 2 unbound. - [All] Common Customized (1) Unbound (1) App Composer Editor Vim Navigation Approval Debug + [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug Type to search shortcuts › Global Open Transcript ctrl-t 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 new file mode 100644 index 000000000000..7538c215004a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_picker(params, 120)" +--- + + Keymap + All configurable shortcuts. + 87 actions, 0 customized, 3 unbound. + + [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug + + Type to search shortcuts +› Global Open Transcript ctrl-t + Global Open External Editor ctrl-g + Global Copy ctrl-o + Global Clear Terminal ctrl-l + Global - Toggle Vim Mode unbound + Global - Toggle Fast Mode unbound + Chat Decrease Reasoning Effort alt-, + Chat Increase Reasoning Effort alt-. + + 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 7831d32ec99c..24b8d81b773c 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,13 +2,13 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (85 selectable) +tab: All (86 selectable) tab: Common (19 selectable) tab: Customized (0) (0 selectable) -tab: Unbound (1) (1 selectable) +tab: Unbound (2) (2 selectable) tab: App (8 selectable) tab: Composer (5 selectable) -tab: Editor (16 selectable) +tab: Editor (17 selectable) tab: Vim (34 selectable) tab: Navigation (14 selectable) tab: Approval (8 selectable) 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 36503cbe0801..9132b3403d0d 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,9 +5,9 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 85 actions, 0 customized, 1 unbound. + 86 actions, 0 customized, 2 unbound. - [All] Common Customized (0) Unbound (1) App Composer Editor Vim + [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug Type to search shortcuts 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 c930200730a8..b6cc5dfa2dcf 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,9 +5,9 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 85 actions, 0 customized, 1 unbound. + 86 actions, 0 customized, 2 unbound. - [All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval Debug + [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug Type to search shortcuts › Global Open Transcript ctrl-t