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
parent 5b80f87c97
commit 94800ecbbf
21 changed files with 710 additions and 23 deletions
+89
View File
@@ -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<KeymapDebugActionMatch> {
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<KeybindingsSpec>> {
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,
}
}
+243
View File
@@ -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<String, String>,
raw_event: String,
matches: Vec<actions::KeymapDebugActionMatch>,
}
/// 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<KeymapDebugReport>,
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<Line<'static>> {
self.lines_at(width, Instant::now())
}
fn lines_at(&self, width: u16, now: Instant) -> Vec<Line<'static>> {
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<Duration> {
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<Line<'static>>,
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("|")
}
+39
View File
@@ -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(),
])
}