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
+3
View File
@@ -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,
+30 -4
View File
@@ -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());
}
}
+3
View File
@@ -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,
@@ -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<std::time::Duration> {
None
}
}
+11
View File
@@ -466,6 +466,7 @@ impl BottomPane {
fn push_view(&mut self, view: Box<dyn BottomPaneView>) {
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`.
@@ -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
@@ -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;
@@ -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::<Vec<_>>()
.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;
+22 -14
View File
@@ -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<KeyBinding> 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())
}
}
+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!(
+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(),
])
}
+1
View File
@@ -153,6 +153,7 @@ impl SlashCommand {
| SlashCommand::Goal
| SlashCommand::Fast
| SlashCommand::Ide
| SlashCommand::Keymap
| SlashCommand::Mcp
| SlashCommand::Side
| SlashCommand::Resume
@@ -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...
@@ -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...
@@ -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]
@@ -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
@@ -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
@@ -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
@@ -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