From 94800ecbbfbe2e4c1a1842eee9f1c6a5ccad772d Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Mon, 4 May 2026 14:40:50 -0300 Subject: [PATCH] feat(tui): add keymap debug inspector (#20794) ## Why We constantly get bug reports about keys not being recognized by Codex when the terminal is not handling the key press. Running `/keymap debug` or `/keymap` and going to the Debug tab, we can allow the user to either understand that the key being pressed is not being recognized or to check what it's being recognized as and report or reassign that key. | Menu | Inspector | Hint | |---|---|---| | CleanShot 2026-05-02 at 12 57 12 | CleanShot 2026-05-02 at 12 56
36 | CleanShot 2026-05-02 at 12 57
30 | ## Summary - add a Debug tab to `/keymap` and support `/keymap debug` for direct access - show what key Codex receives, the config key representation, raw event details, and matching actions - add a progressive missing-key hint that escalates after a few seconds with no detected keypress ## Validation - `just fmt` - `cargo test -p codex-tui keymap_setup::tests::debug_view` - `cargo test -p codex-tui keymap_setup::tests` - `cargo test -p codex-tui slash_keymap` - `cargo test -p codex-tui` (unit tests passed; integration test `suite::model_availability_nux::resume_startup_does_not_consume_model_availability_nux_count` failed locally by itself with `codex resume` exiting 1 and terminal probe escape output) - `just fix -p codex-tui` - `just argument-comment-lint` - `cargo insta pending-snapshots` - `git diff --check` --- codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app/input.rs | 34 ++- codex-rs/tui/src/app_event.rs | 3 + .../tui/src/bottom_pane/bottom_pane_view.rs | 5 + codex-rs/tui/src/bottom_pane/mod.rs | 11 + codex-rs/tui/src/chatwidget/keymap_picker.rs | 7 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 14 + .../src/chatwidget/tests/slash_commands.rs | 97 +++++++ codex-rs/tui/src/key_hint.rs | 36 ++- codex-rs/tui/src/keymap_setup.rs | 111 +++++++- codex-rs/tui/src/keymap_setup/actions.rs | 89 +++++++ codex-rs/tui/src/keymap_setup/debug.rs | 243 ++++++++++++++++++ codex-rs/tui/src/keymap_setup/picker.rs | 39 +++ codex-rs/tui/src/slash_command.rs | 1 + ...tests__keymap_debug_view_delayed_hint.snap | 10 + ...tup__tests__keymap_debug_view_initial.snap | 9 + ...setup__tests__keymap_debug_view_match.snap | 14 + ...ap_setup__tests__keymap_picker_custom.snap | 2 +- ...p__tests__keymap_picker_first_actions.snap | 1 + ...ap_setup__tests__keymap_picker_narrow.snap | 2 +- ...ymap_setup__tests__keymap_picker_wide.snap | 2 +- 21 files changed, 710 insertions(+), 23 deletions(-) create mode 100644 codex-rs/tui/src/keymap_setup/debug.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 5af8e7d2a..dfe7ceb4f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1926,6 +1926,9 @@ impl App { self.chat_widget .open_keymap_capture(context, action, intent, &self.keymap); } + AppEvent::OpenKeymapDebug => { + self.chat_widget.open_keymap_debug(&self.keymap); + } AppEvent::KeymapCaptured { context, action, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index fc18b6273..a9cce1f35 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -122,12 +122,14 @@ impl App { return; } - if self.keymap.app.toggle_vim_mode.is_pressed(key_event) { + let app_keymap_shortcuts_available = self.app_keymap_shortcuts_available(); + + if app_keymap_shortcuts_available && self.keymap.app.toggle_vim_mode.is_pressed(key_event) { self.chat_widget.toggle_vim_mode_and_notify(); return; } - if self.keymap.app.open_transcript.is_pressed(key_event) { + 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(); self.overlay = Some(Overlay::new_transcript( @@ -138,7 +140,9 @@ impl App { return; } - if self.keymap.app.open_external_editor.is_pressed(key_event) { + if app_keymap_shortcuts_available + && self.keymap.app.open_external_editor.is_pressed(key_event) + { // Only launch the external editor if there is no overlay and the bottom pane is not in use. // Note that it can be launched while a task is running to enable editing while the previous turn is ongoing. if self.overlay.is_none() @@ -166,7 +170,9 @@ impl App { } match key_event { - _ if self.keymap.app.clear_terminal.is_pressed(key_event) => { + _ if app_keymap_shortcuts_available + && self.keymap.app.clear_terminal.is_pressed(key_event) => + { if !self.chat_widget.can_run_ctrl_l_clear_now() { return; } @@ -217,7 +223,27 @@ impl App { && !self.chat_widget.should_handle_vim_insert_escape(key_event) } + fn app_keymap_shortcuts_available(&self) -> bool { + self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() + } + pub(super) fn refresh_status_line(&mut self) { self.chat_widget.refresh_status_line(); } } + +#[cfg(test)] +mod tests { + use super::super::test_support::make_test_app; + + #[tokio::test] + async fn app_keymap_shortcuts_are_disabled_while_keymap_view_is_active() { + let mut app = make_test_app().await; + assert!(app.app_keymap_shortcuts_available()); + + let keymap = app.keymap.clone(); + app.chat_widget.open_keymap_debug(&keymap); + + assert!(!app.app_keymap_shortcuts_available()); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1032823f2..bc688e32c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -873,6 +873,9 @@ pub(crate) enum AppEvent { intent: KeymapEditIntent, }, + /// Open the keymap keypress inspector. + OpenKeymapDebug, + /// Apply a captured key to the selected keymap action. KeymapCaptured { context: String, diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 780ae77f2..7538d76ca 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -130,4 +130,9 @@ pub(crate) trait BottomPaneView: Renderable { fn terminal_title_requires_action(&self) -> bool { false } + + /// Return the next time-based redraw this view needs while it is active. + fn next_frame_delay(&self) -> Option { + None + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2daec5482..1f2e4b495 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -466,6 +466,7 @@ impl BottomPane { fn push_view(&mut self, view: Box) { self.view_stack.push(view); + self.schedule_active_view_frame(); self.request_redraw(); } @@ -714,6 +715,16 @@ impl BottomPane { fn pre_draw_tick_at(&mut self, now: Instant) { self.composer.sync_popups(); self.maybe_show_delayed_approval_requests_at(now); + self.schedule_active_view_frame(); + } + + fn schedule_active_view_frame(&self) { + if let Some(delay) = self + .active_view() + .and_then(BottomPaneView::next_frame_delay) + { + self.request_redraw_in(delay); + } } /// Replace the composer text with `text`. diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index bd4df0a36..7e0b3811e 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -85,6 +85,13 @@ impl ChatWidget { self.request_redraw(); } + /// Opens the keypress inspector with the current runtime bindings. + pub(crate) fn open_keymap_debug(&mut self, runtime_keymap: &RuntimeKeymap) { + let view = keymap_setup::build_keymap_debug_view(runtime_keymap, &self.config.tui_keymap); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + /// Opens the menu that lets the user choose which existing binding to replace. /// /// This is only used for actions with multiple effective bindings. The chosen binding is diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 24a634cd0..dafa3863f 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -582,6 +582,20 @@ impl ChatWidget { "verbose" => self.add_mcp_output(McpServerStatusDetail::Full), _ => self.add_error_message("Usage: /mcp [verbose]".to_string()), }, + SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() { + "" => self.open_keymap_picker(), + "debug" => { + match crate::keymap::RuntimeKeymap::from_config(&self.config.tui_keymap) { + Ok(runtime_keymap) => self.open_keymap_debug(&runtime_keymap), + Err(err) => { + self.add_error_message(format!( + "Invalid `tui.keymap` configuration: {err}" + )); + } + } + } + _ => self.add_error_message("Usage: /keymap [debug]".to_string()), + }, SlashCommand::Rename if !trimmed.is_empty() => { if !self.ensure_thread_rename_allowed() { return; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 53312a7f8..b376b4782 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1228,6 +1228,103 @@ async fn keymap_capture_can_capture_current_copy_shortcut() { ); } +#[tokio::test] +async fn slash_keymap_capture_can_capture_app_shortcuts() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let runtime_keymap = crate::keymap::RuntimeKeymap::defaults(); + + for (key, expected) in [('t', "ctrl-t"), ('l', "ctrl-l"), ('g', "ctrl-g")] { + chat.open_keymap_capture( + "global".to_string(), + "open_transcript".to_string(), + crate::app_event::KeymapEditIntent::ReplaceAll, + &runtime_keymap, + ); + + chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); + + let AppEvent::KeymapCaptured { + context, + action, + key, + intent, + } = rx.try_recv().expect("captured key event") + else { + panic!("expected keymap capture event"); + }; + assert_eq!(context, "global"); + assert_eq!(action, "open_transcript"); + assert_eq!(key, expected); + assert_eq!(intent, crate::app_event::KeymapEditIntent::ReplaceAll); + } +} + +#[tokio::test] +async fn slash_keymap_debug_opens_keypress_inspector() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Keymap, "debug".to_string(), Vec::new()); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert!(popup.contains("Keypress Inspector")); + assert!(popup.contains("Waiting for a keypress")); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!(popup.contains("global.copy (Copy)")); + assert!( + drain_insert_history(&mut rx).is_empty(), + "debug inspector should open without transcript messages" + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_keymap_debug_can_inspect_app_shortcuts() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Keymap, "debug".to_string(), Vec::new()); + + for (key, expected_action) in [ + ('t', "global.open_transcript (Open Transcript)"), + ('l', "global.clear_terminal (Clear Terminal)"), + ('g', "global.open_external_editor (Open External Editor)"), + ] { + chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + popup.contains(expected_action), + "expected {expected_action:?} in debug popup for ctrl-{key}, got {popup:?}" + ); + } + + assert!( + drain_insert_history(&mut rx).is_empty(), + "debug inspector should not run app shortcut side effects" + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_keymap_invalid_args_show_usage() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/keymap nope"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Usage: /keymap [debug]"), + "expected usage message, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/keymap nope"); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + #[tokio::test] async fn copy_shortcut_can_be_remapped() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f7b4ff398..1d32707e6 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -44,6 +44,11 @@ impl KeyBinding { Self { key, modifiers } } + pub(crate) fn from_event(event: KeyEvent) -> Self { + let (key, modifiers) = normalize_shifted_ascii_char(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) @@ -53,6 +58,22 @@ impl KeyBinding { pub(crate) const fn parts(&self) -> (KeyCode, KeyModifiers) { (self.key, self.modifiers) } + + pub(crate) fn display_label(&self) -> String { + let modifiers = modifiers_to_string(self.modifiers); + let key = match self.key { + KeyCode::Enter => "enter".to_string(), + KeyCode::Char(' ') => "space".to_string(), + KeyCode::Up => "↑".to_string(), + KeyCode::Down => "↓".to_string(), + KeyCode::Left => "←".to_string(), + KeyCode::Right => "→".to_string(), + KeyCode::PageUp => "pgup".to_string(), + KeyCode::PageDown => "pgdn".to_string(), + _ => self.key.to_string().to_ascii_lowercase(), + }; + format!("{modifiers}{key}") + } } fn normalize_shifted_ascii_char( @@ -143,20 +164,7 @@ impl From for Span<'static> { } impl From<&KeyBinding> for Span<'static> { fn from(binding: &KeyBinding) -> Self { - let KeyBinding { key, modifiers } = binding; - let modifiers = modifiers_to_string(*modifiers); - let key = match key { - KeyCode::Enter => "enter".to_string(), - KeyCode::Char(' ') => "space".to_string(), - KeyCode::Up => "↑".to_string(), - KeyCode::Down => "↓".to_string(), - KeyCode::Left => "←".to_string(), - KeyCode::Right => "→".to_string(), - KeyCode::PageUp => "pgup".to_string(), - KeyCode::PageDown => "pgdn".to_string(), - _ => format!("{key}").to_ascii_lowercase(), - }; - Span::styled(format!("{modifiers}{key}"), key_hint_style()) + Span::styled(binding.display_label(), key_hint_style()) } } diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 17cbfbc05..c1ae82c3e 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -18,8 +18,10 @@ //! surface errors. mod actions; +mod debug; mod picker; +pub(crate) use debug::build_keymap_debug_view; pub(crate) use picker::KEYMAP_PICKER_VIEW_ID; pub(crate) use picker::build_keymap_picker_params; pub(crate) use picker::build_keymap_picker_params_for_selected_action; @@ -47,6 +49,7 @@ use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::key_hint::KeyBinding; use crate::keymap::RuntimeKeymap; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -55,6 +58,8 @@ use actions::action_label; use actions::binding_slot; use actions::bindings_for_action; use actions::format_binding_summary; +#[cfg(test)] +use debug::KeymapDebugView; pub(crate) const KEYMAP_ACTION_MENU_VIEW_ID: &str = "keymap-action-menu"; pub(crate) const KEYMAP_REPLACE_BINDING_MENU_VIEW_ID: &str = "keymap-replace-binding-menu"; @@ -691,10 +696,10 @@ impl BottomPaneView for KeymapCaptureView { } fn key_event_to_config_key_spec(key_event: KeyEvent) -> Result { - key_parts_to_config_key_spec(key_event.code, key_event.modifiers) + binding_to_config_key_spec(KeyBinding::from_event(key_event)) } -fn binding_to_config_key_spec(binding: crate::key_hint::KeyBinding) -> Result { +fn binding_to_config_key_spec(binding: KeyBinding) -> Result { let (code, modifiers) = binding.parts(); key_parts_to_config_key_spec(code, modifiers) } @@ -768,6 +773,7 @@ mod tests { use super::picker::KEYMAP_ALL_TAB_ID; use super::picker::KEYMAP_COMMON_TAB_ID; use super::picker::KEYMAP_CUSTOM_TAB_ID; + use super::picker::KEYMAP_DEBUG_TAB_ID; use super::picker::KEYMAP_UNBOUND_TAB_ID; use super::*; use crate::bottom_pane::BottomPane; @@ -793,6 +799,14 @@ mod tests { buf } + fn render_debug(view: &KeymapDebugView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + render_buffer(&buf) + } + fn render_picker(params: SelectionViewParams, width: u16) -> String { let view = ListSelectionView::new(params, app_event_sender(), RuntimeKeymap::defaults().list); @@ -1042,6 +1056,28 @@ mod tests { assert!(!unbound_tab.items[0].is_disabled); } + #[test] + fn picker_debug_tab_is_last_and_opens_inspector() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let debug_tab = params.tabs.last().expect("debug tab"); + + assert_eq!(debug_tab.id, KEYMAP_DEBUG_TAB_ID); + assert_eq!(debug_tab.label, "Debug"); + assert_eq!(debug_tab.items.len(), 1); + assert_eq!(debug_tab.items[0].name, "Inspect keypresses"); + assert_eq!( + debug_tab.items[0].description.as_deref(), + Some("Press Enter to start. Then press any key to inspect it; Ctrl+C exits.") + ); + assert!( + params + .tab_footer_hints + .iter() + .any(|(tab_id, _)| tab_id == KEYMAP_DEBUG_TAB_ID) + ); + } + #[test] fn picker_selected_action_starts_on_matching_all_tab_row() { let runtime = RuntimeKeymap::defaults(); @@ -1234,6 +1270,66 @@ mod tests { ); } + #[test] + fn debug_view_initial_snapshot() { + let view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default()); + + assert_snapshot!( + "keymap_debug_view_initial", + render_debug(&view, /*width*/ 80) + ); + } + + #[test] + fn debug_view_shows_delayed_missing_key_hint() { + let mut view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default()); + view.show_delayed_hint_for_test(); + + let rendered = render_debug(&view, /*width*/ 100); + assert!(rendered.contains("Still waiting?")); + assert_snapshot!("keymap_debug_view_delayed_hint", rendered); + } + + #[test] + fn debug_view_reports_detected_key_and_matching_actions() { + let mut view = build_keymap_debug_view(&RuntimeKeymap::defaults(), &TuiKeymap::default()); + view.show_delayed_hint_for_test(); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); + + let rendered = render_debug(&view, /*width*/ 100); + assert!(!rendered.contains("Still waiting?")); + assert_snapshot!("keymap_debug_view_match", rendered); + } + + #[test] + fn debug_view_uses_custom_binding_source() { + let keymap = + keymap_with_replacement(&TuiKeymap::default(), "global", "copy", "ctrl-x").unwrap(); + let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); + let mut view = build_keymap_debug_view(&runtime, &keymap); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)); + + let rendered = render_debug(&view, /*width*/ 100); + assert!(rendered.contains("global.copy (Copy)")); + assert!(rendered.contains("[Custom]")); + } + + #[test] + fn debug_view_labels_custom_global_fallback_source() { + let mut keymap = TuiKeymap::default(); + keymap.global.queue = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-q".to_string()))); + let runtime = RuntimeKeymap::from_config(&keymap).unwrap(); + let mut view = build_keymap_debug_view(&runtime, &keymap); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL)); + + let rendered = render_debug(&view, /*width*/ 100); + assert!(rendered.contains("composer.queue (Queue)")); + assert!(rendered.contains("[Custom global]")); + } + #[test] fn capture_completion_returns_to_selected_keymap_picker_row() { let (mut pane, tx, mut rx) = test_pane(); @@ -1437,6 +1533,17 @@ mod tests { ); } + #[test] + fn key_capture_serializes_c0_control_fallbacks() { + assert_eq!( + key_event_to_config_key_spec(KeyEvent::new( + KeyCode::Char('\u{0010}'), + KeyModifiers::NONE + )), + Ok("ctrl-p".to_string()) + ); + } + #[test] fn key_capture_rejects_unrepresentable_keys() { assert!( diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 40c6c1bb7..66103ab89 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -15,6 +15,7 @@ use std::collections::BTreeSet; use codex_config::types::KeybindingsSpec; use codex_config::types::TuiKeymap; +use crossterm::event::KeyEvent; use crate::key_hint::KeyBinding; use crate::keymap::RuntimeKeymap; @@ -374,3 +375,91 @@ pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String { specs.join(", ") } } + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum KeymapDebugBindingSource { + Custom, + CustomGlobal, + Default, +} + +impl KeymapDebugBindingSource { + pub(super) const fn label(&self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::CustomGlobal => "Custom global", + Self::Default => "Default", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct KeymapDebugActionMatch { + pub(super) context: &'static str, + pub(super) action: &'static str, + pub(super) label: String, + pub(super) description: &'static str, + pub(super) source: KeymapDebugBindingSource, +} + +pub(super) fn matching_actions_for_key_event( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, + event: KeyEvent, +) -> Vec { + KEYMAP_ACTIONS + .iter() + .filter_map(|descriptor| { + let bindings = + bindings_for_action(runtime_keymap, descriptor.context, descriptor.action)?; + bindings + .iter() + .any(|binding| binding.is_press(event)) + .then(|| KeymapDebugActionMatch { + context: descriptor.context, + action: descriptor.action, + label: action_label(descriptor.action), + description: descriptor.description, + source: debug_binding_source(keymap_config, descriptor), + }) + }) + .collect() +} + +fn debug_binding_source( + keymap_config: &TuiKeymap, + descriptor: &KeymapActionDescriptor, +) -> KeymapDebugBindingSource { + let mut keymap_config = keymap_config.clone(); + let Some(slot) = binding_slot(&mut keymap_config, descriptor.context, descriptor.action) else { + return KeymapDebugBindingSource::Default; + }; + if slot.is_some() { + return KeymapDebugBindingSource::Custom; + } + + let Some(global_slot) = global_fallback_slot(&mut keymap_config, descriptor) else { + return KeymapDebugBindingSource::Default; + }; + if global_slot.is_some() { + KeymapDebugBindingSource::CustomGlobal + } else { + KeymapDebugBindingSource::Default + } +} + +fn global_fallback_slot<'a>( + keymap: &'a mut TuiKeymap, + descriptor: &KeymapActionDescriptor, +) -> Option<&'a mut Option> { + if descriptor.context != "composer" { + return None; + } + + match descriptor.action { + "submit" => Some(&mut keymap.global.submit), + "queue" => Some(&mut keymap.global.queue), + "toggle_shortcuts" => Some(&mut keymap.global.toggle_shortcuts), + _ => None, + } +} diff --git a/codex-rs/tui/src/keymap_setup/debug.rs b/codex-rs/tui/src/keymap_setup/debug.rs new file mode 100644 index 000000000..f08cdb8f7 --- /dev/null +++ b/codex-rs/tui/src/keymap_setup/debug.rs @@ -0,0 +1,243 @@ +use codex_config::types::TuiKeymap; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use std::time::Duration; +use std::time::Instant; + +use crate::bottom_pane::BottomPaneView; +use crate::bottom_pane::CancellationEvent; +use crate::key_hint::KeyBinding; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::Renderable; + +use super::actions; +use super::actions::matching_actions_for_key_event; +use super::key_event_to_config_key_spec; + +const MISSING_KEY_HINT_DELAY: Duration = Duration::from_secs(3); +const SHORT_MISSING_KEY_HINT: &str = "Tip: Codex can only inspect keys your terminal sends."; +const DELAYED_MISSING_KEY_HINT: &str = "Still waiting? If nothing changes when you press a key, your terminal is not sending that key to Codex. Only received keys can be assigned as shortcuts."; + +struct KeymapDebugReport { + detected: KeyBinding, + config_key: Result, + raw_event: String, + matches: Vec, +} + +/// Bottom-pane view for inspecting how terminal key events map to keymap actions. +pub(crate) struct KeymapDebugView { + runtime_keymap: RuntimeKeymap, + keymap_config: TuiKeymap, + opened_at: Instant, + last_report: Option, + complete: bool, +} + +pub(crate) fn build_keymap_debug_view( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, +) -> KeymapDebugView { + KeymapDebugView { + runtime_keymap: runtime_keymap.clone(), + keymap_config: keymap_config.clone(), + opened_at: Instant::now(), + last_report: None, + complete: false, + } +} + +impl KeymapDebugView { + fn lines(&self, width: u16) -> Vec> { + self.lines_at(width, Instant::now()) + } + + fn lines_at(&self, width: u16, now: Instant) -> Vec> { + let wrap_width = usize::from(width.max(1)); + let mut lines = vec![ + Line::from("Keypress Inspector".bold()), + Line::from( + "Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes.".dim(), + ), + ]; + let hint = if self.should_show_delayed_hint(now) { + DELAYED_MISSING_KEY_HINT + } else { + SHORT_MISSING_KEY_HINT + }; + push_wrapped_dim(&mut lines, hint.to_string(), wrap_width, "", ""); + + let Some(report) = &self.last_report else { + lines.push(Line::from("")); + lines.push(Line::from("Waiting for a keypress...".cyan())); + return lines; + }; + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + "Detected: ".dim(), + report.detected.display_label().cyan(), + ])); + match &report.config_key { + Ok(config_key) => { + lines.push(Line::from(vec![ + "Config key: ".dim(), + config_key.clone().cyan(), + ])); + } + Err(error) => { + push_wrapped_dim( + &mut lines, + format!("unsupported - {error}"), + wrap_width, + "Config key: ", + " ", + ); + } + } + push_wrapped_dim( + &mut lines, + report.raw_event.clone(), + wrap_width, + "Raw event: ", + " ", + ); + lines.push(Line::from("")); + lines.push(Line::from("Assigned actions:".dim())); + if report.matches.is_empty() { + lines.push(Line::from(" none".dim())); + } else { + for matched_action in &report.matches { + let action = format!( + "{}.{} ({}) - {} [{}]", + matched_action.context, + matched_action.action, + matched_action.label, + matched_action.description, + matched_action.source.label() + ); + push_wrapped_dim(&mut lines, action, wrap_width, " - ", " "); + } + } + lines + } + + fn should_show_delayed_hint(&self, now: Instant) -> bool { + self.last_report.is_none() && now.duration_since(self.opened_at) >= MISSING_KEY_HINT_DELAY + } + + #[cfg(test)] + pub(crate) fn show_delayed_hint_for_test(&mut self) { + self.opened_at = Instant::now() - MISSING_KEY_HINT_DELAY; + } +} + +impl Renderable for KeymapDebugView { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.lines(area.width)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 + } +} + +impl BottomPaneView for KeymapDebugView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + self.last_report = Some(KeymapDebugReport { + detected: KeyBinding::from_event(key_event), + config_key: key_event_to_config_key_spec(key_event), + raw_event: key_event_debug_summary(key_event), + matches: matching_actions_for_key_event( + &self.runtime_keymap, + &self.keymap_config, + key_event, + ), + }); + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } + + fn next_frame_delay(&self) -> Option { + if self.last_report.is_some() { + return None; + } + + self.opened_at + .checked_add(MISSING_KEY_HINT_DELAY) + .and_then(|show_at| show_at.checked_duration_since(Instant::now())) + .filter(|delay| !delay.is_zero()) + } +} + +fn push_wrapped_dim( + lines: &mut Vec>, + text: String, + wrap_width: usize, + initial_indent: &'static str, + subsequent_indent: &'static str, +) { + let options = textwrap::Options::new(wrap_width) + .initial_indent(initial_indent) + .subsequent_indent(subsequent_indent); + lines.extend( + textwrap::wrap(&text, options) + .into_iter() + .map(|line| Line::from(line.into_owned().dim())), + ); +} + +fn key_event_debug_summary(key_event: KeyEvent) -> String { + format!( + "code={:?}, modifiers={}, kind={:?}", + key_event.code, + key_modifiers_debug_label(key_event.modifiers), + key_event.kind + ) +} + +fn key_modifiers_debug_label(modifiers: KeyModifiers) -> String { + if modifiers.is_empty() { + return "none".to_string(); + } + + let mut parts = Vec::new(); + if modifiers.contains(KeyModifiers::CONTROL) { + parts.push("ctrl".to_string()); + } + if modifiers.contains(KeyModifiers::ALT) { + parts.push("alt".to_string()); + } + if modifiers.contains(KeyModifiers::SHIFT) { + parts.push("shift".to_string()); + } + + let known_modifiers = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT; + let other_modifiers = modifiers.difference(known_modifiers); + if !other_modifiers.is_empty() { + parts.push(format!("{other_modifiers:?}")); + } + parts.join("|") +} diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs index 429586bf6..c46d22cc9 100644 --- a/codex-rs/tui/src/keymap_setup/picker.rs +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -27,6 +27,7 @@ pub(super) const KEYMAP_ALL_TAB_ID: &str = "all-shortcuts"; pub(super) const KEYMAP_COMMON_TAB_ID: &str = "common-shortcuts"; pub(super) const KEYMAP_CUSTOM_TAB_ID: &str = "custom-shortcuts"; pub(super) const KEYMAP_UNBOUND_TAB_ID: &str = "unbound-shortcuts"; +pub(super) const KEYMAP_DEBUG_TAB_ID: &str = "debug-shortcuts"; const KEYMAP_CONTEXT_LABEL_WIDTH: usize = 12; const KEYMAP_ROW_PREFIX_WIDTH: usize = KEYMAP_CONTEXT_LABEL_WIDTH + 3; @@ -237,11 +238,13 @@ fn build_keymap_picker_params_for_action( ), }); } + tabs.push(keymap_debug_tab()); SelectionViewParams { view_id: Some(KEYMAP_PICKER_VIEW_ID), header: Box::new(()), footer_hint: Some(keymap_picker_hint_line()), + tab_footer_hints: vec![(KEYMAP_DEBUG_TAB_ID.to_string(), keymap_debug_hint_line())], tabs, initial_tab_id: Some(KEYMAP_ALL_TAB_ID.to_string()), is_searchable: true, @@ -254,6 +257,33 @@ fn build_keymap_picker_params_for_action( } } +fn keymap_debug_tab() -> SelectionTab { + SelectionTab { + id: KEYMAP_DEBUG_TAB_ID.to_string(), + label: "Debug".to_string(), + header: keymap_header( + "Inspect keypresses from your terminal.".to_string(), + "See the key Codex detects and any shortcuts assigned to it.".to_string(), + ), + items: vec![SelectionItem { + name: "Inspect keypresses".to_string(), + description: Some( + "Press Enter to start. Then press any key to inspect it; Ctrl+C exits." + .to_string(), + ), + selected_description: Some( + "Open a live inspector that shows the detected key, config key, and matching actions." + .to_string(), + ), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenKeymapDebug); + })], + search_value: Some("debug inspect keypress key terminal detected actions".to_string()), + ..Default::default() + }], + } +} + fn build_keymap_rows( runtime_keymap: &RuntimeKeymap, keymap_config: &TuiKeymap, @@ -391,3 +421,12 @@ fn keymap_picker_hint_line() -> Line<'static> { " close".dim(), ]) } + +fn keymap_debug_hint_line() -> Line<'static> { + Line::from(vec![ + "enter".cyan(), + " start inspector · ".dim(), + "esc".cyan(), + " close".dim(), + ]) +} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index f8e7fd88e..90c2f0cae 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -153,6 +153,7 @@ impl SlashCommand { | SlashCommand::Goal | SlashCommand::Fast | SlashCommand::Ide + | SlashCommand::Keymap | SlashCommand::Mcp | SlashCommand::Side | SlashCommand::Resume diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap new file mode 100644 index 000000000..912c0520c --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_delayed_hint.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/keymap_setup.rs +expression: rendered +--- +Keypress Inspector +Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. +Still waiting? If nothing changes when you press a key, your terminal is not sending that key to +Codex. Only received keys can be assigned as shortcuts. + +Waiting for a keypress... diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap new file mode 100644 index 000000000..7126a40f9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_initial.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/keymap_setup.rs +expression: "render_debug(&view, 80)" +--- +Keypress Inspector +Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. +Tip: Codex can only inspect keys your terminal sends. + +Waiting for a keypress... diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap new file mode 100644 index 000000000..95c9a4786 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_debug_view_match.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/keymap_setup.rs +expression: rendered +--- +Keypress Inspector +Press any key to see what Codex receives. Esc is inspected; Ctrl+C closes. +Tip: Codex can only inspect keys your terminal sends. + +Detected: ctrl + o +Config key: ctrl-o +Raw event: code=Char('o'), modifiers=ctrl, kind=Press + +Assigned actions: + - global.copy (Copy) - Copy the last agent response to the clipboard. [Default] 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 a9e6b02e8..b0336cd20 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 @@ -7,7 +7,7 @@ expression: "render_picker(params, 120)" All configurable shortcuts. 85 actions, 1 customized, 1 unbound. - [All] Common Customized (1) Unbound (1) App Composer Editor Vim Navigation Approval + [All] Common Customized (1) Unbound (1) 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_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 4c5cf695b..7831d32ec 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 @@ -12,6 +12,7 @@ tab: Editor (16 selectable) tab: Vim (34 selectable) tab: Navigation (14 selectable) tab: Approval (8 selectable) +tab: Debug (1 selectable) Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default 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 76a046086..36503cbe0 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 @@ -8,7 +8,7 @@ expression: "render_picker(params, 78)" 85 actions, 0 customized, 1 unbound. [All] Common Customized (0) Unbound (1) App Composer Editor Vim - Navigation Approval + 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_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 674b2caf6..c93020073 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 @@ -7,7 +7,7 @@ expression: "render_picker(params, 120)" All configurable shortcuts. 85 actions, 0 customized, 1 unbound. - [All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval + [All] Common Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval Debug Type to search shortcuts › Global Open Transcript ctrl-t