mirror of
https://github.com/openai/codex.git
synced 2026-05-03 19:06:58 +00:00
Compare commits
4 Commits
dev/mzeng/
...
debug-keyb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11c9ca039 | ||
|
|
6babd6e750 | ||
|
|
26a6252e8b | ||
|
|
de84dfe1d7 |
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
243
codex-rs/tui/src/keymap_setup/debug.rs
Normal file
243
codex-rs/tui/src/keymap_setup/debug.rs
Normal 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("|")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user