mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
5b80f87c97
commit
94800ecbbf
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("|")
|
||||
}
|
||||
@@ -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(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Goal
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Ide
|
||||
| SlashCommand::Keymap
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Side
|
||||
| SlashCommand::Resume
|
||||
|
||||
+10
@@ -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...
|
||||
+9
@@ -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...
|
||||
+14
@@ -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]
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user