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