Compare commits

...

4 Commits

Author SHA1 Message Date
Felipe Coury
f11c9ca039 fix(tui): let keymap capture app shortcuts 2026-05-02 13:35:00 -03:00
Felipe Coury
6babd6e750 fix(tui): label keymap global fallback matches 2026-05-02 13:12:36 -03:00
Felipe Coury
26a6252e8b feat(tui): add progressive keymap debug hint 2026-05-02 12:50:14 -03:00
Felipe Coury
de84dfe1d7 feat(tui): add keymap debug inspector 2026-05-02 12:40:17 -03:00
21 changed files with 710 additions and 23 deletions

View File

@@ -1923,6 +1923,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,

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());
}
}

View File

@@ -869,6 +869,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,

View File

@@ -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
}
}

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`.

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

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())
}
}

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!(

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,
}
}

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("|")
}

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(),
])
}

View File

@@ -153,6 +153,7 @@ impl SlashCommand {
| SlashCommand::Goal
| SlashCommand::Fast
| SlashCommand::Ide
| SlashCommand::Keymap
| SlashCommand::Mcp
| SlashCommand::Side
| SlashCommand::Resume

View File

@@ -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...

View File

@@ -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...

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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