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 |
|---|---|---|
| <img width="1369" height="796" alt="CleanShot 2026-05-02 at 12 57 12"
src="https://github.com/user-attachments/assets/512b6faa-344e-4aee-9c00-b4bdc633a662"
/> | <img width="1261" height="754" alt="CleanShot 2026-05-02 at 12 56
36"
src="https://github.com/user-attachments/assets/a6ddae7d-e174-4ee4-893f-e6bec4fff4ab"
/> | <img width="1369" height="796" alt="CleanShot 2026-05-02 at 12 57
30"
src="https://github.com/user-attachments/assets/db507784-f40a-4cff-ac23-a61d9703769b"
/> |
## 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`
This commit is contained in:
Felipe Coury
2026-05-04 14:40:50 -03:00
committed by GitHub
Unverified
parent 5b80f87c97
commit 94800ecbbf
21 changed files with 710 additions and 23 deletions
+109 -2
View File
@@ -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<String, String> {
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<String, String> {
fn binding_to_config_key_spec(binding: KeyBinding) -> Result<String, String> {
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!(