From b7b6119c29a52f3759ffa4df492bb97af3d43bfb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 18 Apr 2026 23:54:29 -0300 Subject: [PATCH] feat(tui): overhaul keymap picker Move `/keymap` to a tabbed, single-line picker inspired by the `/plugins` command so shortcut browsing is denser and less repetitive. The picker now groups shortcuts by source and context, with compact row status markers for default, custom, and unbound actions. Move selected-action details into the edit menu header and remove the old side panel so the picker stays focused on scanning and searching. --- codex-rs/tui/src/keymap_setup.rs | 276 ++++++++------ codex-rs/tui/src/keymap_setup/details.rs | 152 -------- codex-rs/tui/src/keymap_setup/picker.rs | 345 ++++++++++++++++++ ...ymap_setup__tests__keymap_action_menu.snap | 6 +- ...__tests__keymap_picker_all_tab_search.snap | 16 + ...p__tests__keymap_picker_first_actions.snap | 34 +- ...ap_setup__tests__keymap_picker_narrow.snap | 29 +- ...ymap_setup__tests__keymap_picker_wide.snap | 30 +- 8 files changed, 573 insertions(+), 315 deletions(-) delete mode 100644 codex-rs/tui/src/keymap_setup/details.rs create mode 100644 codex-rs/tui/src/keymap_setup/picker.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index 7b852f46b3..ac1d1e4ca7 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -4,10 +4,9 @@ //! binding, then validate and persist the resulting runtime keymap. mod actions; -mod details; +mod picker; -use std::sync::Arc; -use std::sync::Mutex; +pub(crate) use picker::build_keymap_picker_params; use codex_config::types::KeybindingSpec; use codex_config::types::KeybindingsSpec; @@ -31,106 +30,35 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; -use crate::bottom_pane::SideContentWidth; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::keymap::RuntimeKeymap; +use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use actions::KEYMAP_ACTIONS; use actions::action_label; use actions::binding_slot; use actions::bindings_for_action; use actions::format_binding_summary; -use details::KeymapActionDetailsLayout; -use details::KeymapActionDetailsRenderable; -use details::build_action_details; -const KEYMAP_PICKER_VIEW_ID: &str = "keymap-picker"; pub(crate) const KEYMAP_ACTION_MENU_VIEW_ID: &str = "keymap-action-menu"; -const KEYMAP_DETAIL_PANEL_WIDTH: u16 = 46; -const KEYMAP_DETAIL_PANEL_MIN_WIDTH: u16 = 40; -pub(crate) fn build_keymap_picker_params( - runtime_keymap: &RuntimeKeymap, - keymap_config: &TuiKeymap, -) -> SelectionViewParams { - let details = Arc::new(build_action_details(runtime_keymap, keymap_config)); - let selected_detail_idx = Arc::new(Mutex::new(0usize)); - let selected_detail_idx_for_callback = selected_detail_idx.clone(); - - let items = KEYMAP_ACTIONS - .iter() - .copied() - .map(|descriptor| { - let bindings = - bindings_for_action(runtime_keymap, descriptor.context, descriptor.action) - .unwrap_or(&[]); - let binding_summary = format_binding_summary(bindings); - let context = descriptor.context.to_string(); - let action = descriptor.action.to_string(); - let label = action_label(descriptor.action); - let search_value = format!( - "{} {} {} {} {}", - descriptor.context_label, - descriptor.action, - label, - descriptor.description, - binding_summary - ); - - SelectionItem { - name: label, - name_prefix_spans: vec![format!("{:<12} ", descriptor.context_label).dim()], - description: Some(binding_summary), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenKeymapActionMenu { - context: context.clone(), - action: action.clone(), - }); - })], - search_value: Some(search_value), - ..Default::default() - } - }) - .collect(); - - let on_selection_changed = Some(Box::new(move |idx: usize, _tx: &_| { - if let Ok(mut selected_idx) = selected_detail_idx_for_callback.lock() { - *selected_idx = idx; - } - }) - as Box); - - SelectionViewParams { - view_id: Some(KEYMAP_PICKER_VIEW_ID), - title: Some("Remap Shortcut".to_string()), - subtitle: Some("Search actions. Enter edits the selected shortcut.".to_string()), - footer_note: Some(Line::from(vec![ - "Saves to root ".dim(), - "`tui.keymap.*`".cyan(), - " so shortcuts stay consistent across profiles.".dim(), - ])), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Search actions...".to_string()), - col_width_mode: ColumnWidthMode::AutoAllRows, - side_content: Box::new(KeymapActionDetailsRenderable::new( - details.clone(), - selected_detail_idx.clone(), - KeymapActionDetailsLayout::Wide, - )), - side_content_width: SideContentWidth::Fixed(KEYMAP_DETAIL_PANEL_WIDTH), - side_content_min_width: KEYMAP_DETAIL_PANEL_MIN_WIDTH, - stacked_side_content: Some(Box::new(KeymapActionDetailsRenderable::new( - details, - selected_detail_idx, - KeymapActionDetailsLayout::NarrowFooter, - ))), - on_selection_changed, - ..Default::default() +fn key_binding_span(binding: &str) -> ratatui::text::Span<'static> { + if binding == "unbound" { + binding.to_string().dim() + } else { + binding.to_string().cyan() } } +fn keymap_action_menu_hint_line() -> Line<'static> { + Line::from(vec![ + "enter".cyan(), + " select · ".dim(), + "esc".cyan(), + " back".dim(), + ]) +} + pub(crate) fn build_keymap_action_menu_params( context: String, action: String, @@ -141,6 +69,16 @@ pub(crate) fn build_keymap_action_menu_params( bindings_for_action(runtime_keymap, &context, &action).unwrap_or(&[]), ); let custom_binding = has_custom_binding(keymap_config, &context, &action).unwrap_or(false); + let descriptor = KEYMAP_ACTIONS + .iter() + .find(|descriptor| descriptor.context == context && descriptor.action == action); + let context_label = descriptor + .map(|descriptor| descriptor.context_label) + .unwrap_or(context.as_str()) + .to_string(); + let description = descriptor + .map(|descriptor| descriptor.description) + .unwrap_or("Configure this shortcut."); let remove_disabled_reason = (!custom_binding) .then(|| "There is no custom root binding for this action to remove.".to_string()); let label = action_label(&action); @@ -148,23 +86,46 @@ pub(crate) fn build_keymap_action_menu_params( let set_action = action.clone(); let remove_context = context.clone(); let remove_action = action.clone(); + let config_path = format!("tui.keymap.{context}.{action}"); + let source = if custom_binding { + "Custom root override".cyan() + } else { + "Default keymap".dim() + }; + let mut header = ColumnRenderable::new(); + header.push(Line::from("Edit Shortcut".bold())); + header.push(Line::from(vec![ + label.bold(), + " · ".dim(), + context_label.dim(), + ])); + header.push(Line::from(vec![ + "Current ".dim(), + key_binding_span(¤t_binding), + " · ".dim(), + source, + ])); + header.push(Line::from(vec![ + "Config ".dim(), + format!("`{config_path}`").cyan(), + ])); + header.push(Line::from(description.to_string().dim())); SelectionViewParams { view_id: Some(KEYMAP_ACTION_MENU_VIEW_ID), - title: Some("Edit Shortcut".to_string()), - subtitle: Some(format!("{label} {context}.{action}")), + header: Box::new(header), footer_note: Some(Line::from(vec![ - "Remove clears the root ".dim(), + "Changes write the root ".dim(), "`tui.keymap.*`".cyan(), - " entry and falls back to the default keymap.".dim(), + " override.".dim(), ])), - footer_hint: Some(standard_popup_hint_line()), + footer_hint: Some(keymap_action_menu_hint_line()), items: vec![ SelectionItem { name: "Set new key".to_string(), - description: Some(format!("Current: {current_binding}")), + description: Some("Capture a replacement key.".to_string()), selected_description: Some(format!( - "Current: {current_binding}. Capture one key and replace this action's custom binding." + "Capture one key and replace the current `{current_binding}` binding." )), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenKeymapCapture { @@ -176,9 +137,13 @@ pub(crate) fn build_keymap_action_menu_params( }, SelectionItem { name: "Remove custom binding".to_string(), - description: Some("Restore the default binding for this action.".to_string()), + description: Some(if custom_binding { + "Restore the default keymap binding.".to_string() + } else { + "No root override to remove.".to_string() + }), selected_description: Some( - "Delete the root custom binding and use the default keymap again.".to_string(), + "Delete the root override and use the default keymap again.".to_string(), ), disabled_reason: remove_disabled_reason, actions: vec![Box::new(move |tx| { @@ -190,8 +155,8 @@ pub(crate) fn build_keymap_action_menu_params( ..Default::default() }, SelectionItem { - name: "Cancel".to_string(), - description: Some("Leave keymap unchanged.".to_string()), + name: "Back to shortcuts".to_string(), + description: Some("Return to the shortcut list.".to_string()), dismiss_on_select: true, ..Default::default() }, @@ -475,10 +440,14 @@ fn key_parts_to_config_key_spec( #[cfg(test)] mod tests { + use super::picker::KEYMAP_ALL_TAB_ID; + use super::picker::KEYMAP_CUSTOM_TAB_ID; + use super::picker::KEYMAP_UNBOUND_TAB_ID; use super::*; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::ListSelectionView; + use crate::bottom_pane::SelectionTab; use crate::tui::FrameRequester; use insta::assert_snapshot; use pretty_assertions::assert_eq; @@ -547,14 +516,24 @@ mod tests { (pane, tx, rx) } + fn selection_tab<'a>(params: &'a SelectionViewParams, id: &str) -> &'a SelectionTab { + params + .tabs + .iter() + .find(|tab| tab.id == id) + .expect("selection tab") + } + #[test] fn picker_covers_every_replaceable_action() { let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); - assert_eq!(params.items.len(), KEYMAP_ACTIONS.len()); + assert!(params.items.is_empty()); + assert_eq!(all_tab.items.len(), KEYMAP_ACTIONS.len()); assert!( - params.items.iter().all(|item| !item.dismiss_on_select), + all_tab.items.iter().all(|item| !item.dismiss_on_select), "keymap picker should stay open behind the action menu" ); assert!(KEYMAP_ACTIONS.iter().all(|descriptor| { @@ -574,7 +553,75 @@ mod tests { fn picker_content_snapshot() { let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); let snapshot = params + .tabs + .iter() + .map(|tab| { + let selectable = tab.items.iter().filter(|item| !item.is_disabled).count(); + format!("tab: {} ({selectable} selectable)", tab.label) + }) + .chain(all_tab.items.iter().take(12).map(|item| { + format!( + "{} | {} | {}", + item.name, + item.description.as_deref().unwrap_or_default(), + item.search_value.as_deref().unwrap_or_default() + ) + })) + .collect::>() + .join("\n"); + + assert_snapshot!("keymap_picker_first_actions", snapshot); + } + + #[test] + fn picker_customized_tab_contains_root_overrides() { + let keymap = + keymap_with_replacement(&TuiKeymap::default(), "composer", "submit", "ctrl-enter") + .expect("replace binding"); + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + let params = build_keymap_picker_params(&runtime, &keymap); + let custom_tab = selection_tab(¶ms, KEYMAP_CUSTOM_TAB_ID); + let composer_tab = selection_tab(¶ms, "composer-shortcuts"); + + assert_eq!( + custom_tab + .items + .iter() + .map(|item| item.name.as_str()) + .collect::>(), + vec!["Submit"] + ); + assert!( + composer_tab + .items + .iter() + .any(|item| item.description.as_deref() == Some("ctrl-enter · Custom")) + ); + } + + #[test] + fn picker_unbound_tab_lists_default_unbound_actions() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let unbound_tab = selection_tab(¶ms, KEYMAP_UNBOUND_TAB_ID); + + assert_eq!(unbound_tab.items.len(), 1); + assert_eq!(unbound_tab.items[0].name, "Toggle Vim Mode"); + assert_eq!( + unbound_tab.items[0].description.as_deref(), + Some("unbound · Default") + ); + assert!(!unbound_tab.items[0].is_disabled); + } + + #[test] + fn picker_all_tab_items_remain_searchable() { + let runtime = RuntimeKeymap::defaults(); + let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); + let snapshot = all_tab .items .iter() .take(12) @@ -589,7 +636,7 @@ mod tests { .collect::>() .join("\n"); - assert_snapshot!("keymap_picker_first_actions", snapshot); + assert_snapshot!("keymap_picker_all_tab_search", snapshot); } #[test] @@ -609,26 +656,15 @@ mod tests { } #[test] - fn picker_detail_tracks_selection() { - let runtime = RuntimeKeymap::defaults(); - let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); - let mut view = - ListSelectionView::new(params, app_event_sender(), RuntimeKeymap::defaults().list); - - view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - - let rendered = render_picker_from_view(&view, /*width*/ 120); - assert!(rendered.contains("Open the current draft in an external editor.")); - } - - #[test] - fn picker_narrow_uses_compact_detail() { + fn picker_narrow_uses_compact_tabs() { let runtime = RuntimeKeymap::defaults(); let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); let rendered = render_picker(params, /*width*/ 78); - assert!(rendered.contains("Open the transcript overlay.")); - assert!(!rendered.contains("Current: ctrl-t")); + assert!(rendered.contains("Keymap")); + assert!(rendered.contains("Open Transcript")); + assert!(rendered.contains("ctrl-t")); + assert!(!rendered.contains("Selected Action")); assert!(!rendered.contains("Source: default keymap")); } @@ -757,7 +793,7 @@ mod tests { pane.render(area, &mut buf); let rendered = render_buffer(&buf); assert!( - rendered.contains("Current: ctrl-shift-k"), + rendered.contains("Current ctrl-shift-k"), "rendered action menu did not include updated binding:\n{rendered}" ); } diff --git a/codex-rs/tui/src/keymap_setup/details.rs b/codex-rs/tui/src/keymap_setup/details.rs deleted file mode 100644 index 99b4f4d13e..0000000000 --- a/codex-rs/tui/src/keymap_setup/details.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Selected-action details for the `/keymap` picker. - -use std::sync::Arc; -use std::sync::Mutex; - -use codex_config::types::TuiKeymap; -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 crate::keymap::RuntimeKeymap; -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; -use crate::render::renderable::Renderable; - -use super::actions::KEYMAP_ACTIONS; -use super::actions::action_label; -use super::actions::bindings_for_action; -use super::actions::format_binding_summary; -use super::has_custom_binding; - -#[derive(Clone, Debug)] -pub(super) struct KeymapActionDetail { - context: String, - context_label: String, - action: String, - label: String, - description: String, - binding_summary: String, - custom_binding: bool, -} - -#[derive(Clone, Copy, Debug)] -pub(super) enum KeymapActionDetailsLayout { - Wide, - NarrowFooter, -} - -#[derive(Clone)] -pub(super) struct KeymapActionDetailsRenderable { - details: Arc>, - selected_idx: Arc>, - layout: KeymapActionDetailsLayout, -} - -impl KeymapActionDetailsRenderable { - pub(super) fn new( - details: Arc>, - selected_idx: Arc>, - layout: KeymapActionDetailsLayout, - ) -> Self { - Self { - details, - selected_idx, - layout, - } - } - - fn selected_detail(&self) -> Option<&KeymapActionDetail> { - let idx = self.selected_idx.lock().map(|idx| *idx).unwrap_or(0); - self.details.get(idx).or_else(|| self.details.first()) - } - - fn lines(&self, width: u16) -> Vec> { - let Some(detail) = self.selected_detail() else { - return vec!["No action selected".dim().into()]; - }; - - if matches!(self.layout, KeymapActionDetailsLayout::NarrowFooter) { - return vec![truncate_line_with_ellipsis_if_overflow( - detail.description.clone().dim().into(), - usize::from(width), - )]; - } - - let mut lines = vec!["Selected Action".bold().into(), Line::from("")]; - - lines.push(detail.label.clone().bold().into()); - lines.push(Line::from(vec![ - detail.context_label.clone().dim(), - " ".dim(), - format!("{}.{}", detail.context, detail.action).dim(), - ])); - lines.push(Line::from("")); - - let binding = if detail.binding_summary == "unbound" { - detail.binding_summary.clone().dim() - } else { - detail.binding_summary.clone().cyan() - }; - lines.push(Line::from(vec!["Current: ".dim(), binding])); - - let source = if detail.custom_binding { - "root override".cyan() - } else { - "default keymap".dim() - }; - lines.push(Line::from(vec!["Source: ".dim(), source])); - lines.push(Line::from("")); - - let wrap_width = usize::from(width.max(1)); - lines.extend( - textwrap::wrap(&detail.description, wrap_width) - .into_iter() - .map(|line| Line::from(line.into_owned().dim())), - ); - - lines.push(Line::from("")); - lines.push("Enter edits this shortcut".cyan().into()); - lines - } -} - -impl Renderable for KeymapActionDetailsRenderable { - 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 - } -} - -pub(super) fn build_action_details( - runtime_keymap: &RuntimeKeymap, - keymap_config: &TuiKeymap, -) -> Vec { - KEYMAP_ACTIONS - .iter() - .map(|descriptor| { - let bindings = - bindings_for_action(runtime_keymap, descriptor.context, descriptor.action) - .unwrap_or(&[]); - KeymapActionDetail { - context: descriptor.context.to_string(), - context_label: descriptor.context_label.to_string(), - action: descriptor.action.to_string(), - label: action_label(descriptor.action), - description: descriptor.description.to_string(), - binding_summary: format_binding_summary(bindings), - custom_binding: has_custom_binding( - keymap_config, - descriptor.context, - descriptor.action, - ) - .unwrap_or(false), - } - }) - .collect() -} diff --git a/codex-rs/tui/src/keymap_setup/picker.rs b/codex-rs/tui/src/keymap_setup/picker.rs new file mode 100644 index 0000000000..abe170720d --- /dev/null +++ b/codex-rs/tui/src/keymap_setup/picker.rs @@ -0,0 +1,345 @@ +//! Shortcut picker construction for `/keymap`. + +use codex_config::types::TuiKeymap; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use unicode_width::UnicodeWidthStr; + +use crate::app_event::AppEvent; +use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionRowDisplay; +use crate::bottom_pane::SelectionTab; +use crate::bottom_pane::SelectionViewParams; +use crate::keymap::RuntimeKeymap; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; + +use super::actions::KEYMAP_ACTIONS; +use super::actions::action_label; +use super::actions::bindings_for_action; +use super::actions::format_binding_summary; +use super::has_custom_binding; + +const KEYMAP_PICKER_VIEW_ID: &str = "keymap-picker"; +pub(super) const KEYMAP_ALL_TAB_ID: &str = "all-shortcuts"; +pub(super) const KEYMAP_CUSTOM_TAB_ID: &str = "custom-shortcuts"; +pub(super) const KEYMAP_UNBOUND_TAB_ID: &str = "unbound-shortcuts"; +const KEYMAP_CONTEXT_LABEL_WIDTH: usize = 12; +const KEYMAP_ROW_PREFIX_WIDTH: usize = 4 + KEYMAP_CONTEXT_LABEL_WIDTH + 1; + +#[derive(Clone, Debug)] +struct KeymapActionRow { + context: &'static str, + context_label: &'static str, + action: &'static str, + label: String, + description: &'static str, + binding_summary: String, + custom_binding: bool, +} + +impl KeymapActionRow { + fn is_unbound(&self) -> bool { + self.binding_summary == "unbound" + } +} + +struct KeymapContextTab { + id: &'static str, + label: &'static str, + description: &'static str, + contexts: &'static [&'static str], +} + +const KEYMAP_CONTEXT_TABS: &[KeymapContextTab] = &[ + KeymapContextTab { + id: "app-shortcuts", + label: "App", + description: "Global and chat-level shortcuts.", + contexts: &["global", "chat"], + }, + KeymapContextTab { + id: "composer-shortcuts", + label: "Composer", + description: "Composer submission and queue shortcuts.", + contexts: &["composer"], + }, + KeymapContextTab { + id: "editor-shortcuts", + label: "Editor", + description: "Inline editor movement and editing shortcuts.", + contexts: &["editor"], + }, + KeymapContextTab { + id: "vim-shortcuts", + label: "Vim", + description: "Vim normal-mode and operator shortcuts.", + contexts: &["vim_normal", "vim_operator"], + }, + KeymapContextTab { + id: "navigation-shortcuts", + label: "Navigation", + description: "Pager and selection-list navigation shortcuts.", + contexts: &["pager", "list"], + }, + KeymapContextTab { + id: "approval-shortcuts", + label: "Approval", + description: "Approval prompt shortcuts.", + contexts: &["approval"], + }, + KeymapContextTab { + id: "onboarding-shortcuts", + label: "Onboarding", + description: "Onboarding flow shortcuts.", + contexts: &["onboarding"], + }, +]; + +pub(crate) fn build_keymap_picker_params( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, +) -> SelectionViewParams { + let rows = build_keymap_rows(runtime_keymap, keymap_config); + let total = rows.len(); + let custom_count = rows.iter().filter(|row| row.custom_binding).count(); + let unbound_count = rows.iter().filter(|row| row.is_unbound()).count(); + let name_column_width = rows + .iter() + .map(|row| KEYMAP_ROW_PREFIX_WIDTH + UnicodeWidthStr::width(row.label.as_str())) + .max(); + + let mut tabs = Vec::new(); + tabs.push(SelectionTab { + id: KEYMAP_ALL_TAB_ID.to_string(), + label: "All".to_string(), + header: keymap_header( + "All configurable shortcuts.".to_string(), + format!("{total} actions, {custom_count} customized, {unbound_count} unbound."), + ), + items: keymap_selection_items( + rows.iter(), + "No shortcuts available", + "No configurable shortcuts are available.", + ), + }); + + let custom_rows = rows + .iter() + .filter(|row| row.custom_binding) + .collect::>(); + tabs.push(SelectionTab { + id: KEYMAP_CUSTOM_TAB_ID.to_string(), + label: format!("Customized ({custom_count})"), + header: keymap_header( + "Root-level shortcut overrides.".to_string(), + action_count_line(custom_count), + ), + items: keymap_selection_items( + custom_rows, + "No customized shortcuts", + "No root-level keymap overrides have been configured.", + ), + }); + + let unbound_rows = rows + .iter() + .filter(|row| row.is_unbound()) + .collect::>(); + tabs.push(SelectionTab { + id: KEYMAP_UNBOUND_TAB_ID.to_string(), + label: format!("Unbound ({unbound_count})"), + header: keymap_header( + "Actions without an active shortcut.".to_string(), + action_count_line(unbound_count), + ), + items: keymap_selection_items( + unbound_rows, + "No unbound shortcuts", + "Every configurable action currently has a shortcut.", + ), + }); + + for tab in KEYMAP_CONTEXT_TABS { + let tab_rows = rows + .iter() + .filter(|row| tab.contexts.contains(&row.context)) + .collect::>(); + let count = tab_rows.len(); + tabs.push(SelectionTab { + id: tab.id.to_string(), + label: tab.label.to_string(), + header: keymap_header(tab.description.to_string(), action_count_line(count)), + items: keymap_selection_items( + tab_rows, + "No shortcuts in this group", + "No configurable actions are available in this group.", + ), + }); + } + + SelectionViewParams { + view_id: Some(KEYMAP_PICKER_VIEW_ID), + header: Box::new(()), + footer_hint: Some(keymap_picker_hint_line()), + tabs, + initial_tab_id: Some(KEYMAP_ALL_TAB_ID.to_string()), + is_searchable: true, + search_placeholder: Some("Type to search shortcuts".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, + row_display: SelectionRowDisplay::SingleLine, + name_column_width, + ..Default::default() + } +} + +fn build_keymap_rows( + runtime_keymap: &RuntimeKeymap, + keymap_config: &TuiKeymap, +) -> Vec { + KEYMAP_ACTIONS + .iter() + .map(|descriptor| { + let bindings = + bindings_for_action(runtime_keymap, descriptor.context, descriptor.action) + .unwrap_or(&[]); + KeymapActionRow { + context: descriptor.context, + context_label: descriptor.context_label, + action: descriptor.action, + label: action_label(descriptor.action), + description: descriptor.description, + binding_summary: format_binding_summary(bindings), + custom_binding: has_custom_binding( + keymap_config, + descriptor.context, + descriptor.action, + ) + .unwrap_or(false), + } + }) + .collect() +} + +fn keymap_selection_items<'a>( + rows: impl IntoIterator, + empty_name: &str, + empty_description: &str, +) -> Vec { + let items = rows + .into_iter() + .map(keymap_selection_item) + .collect::>(); + if items.is_empty() { + return vec![SelectionItem { + name: empty_name.to_string(), + description: Some(empty_description.to_string()), + is_disabled: true, + ..Default::default() + }]; + } + + items +} + +fn keymap_selection_item(row: &KeymapActionRow) -> SelectionItem { + let context = row.context.to_string(); + let action = row.action.to_string(); + let source = keymap_source_label(row); + let search_value = format!( + "{} {} {} {} {} {}", + row.context_label, row.action, row.label, row.description, row.binding_summary, source + ); + + SelectionItem { + name: row.label.clone(), + name_prefix_spans: keymap_row_prefix(row), + description: Some(format!("{} · {source}", row.binding_summary)), + selected_description: Some(format!( + "{} · {}. {}", + current_binding_sentence(row), + keymap_source_sentence(row), + row.description + )), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenKeymapActionMenu { + context: context.clone(), + action: action.clone(), + }); + })], + search_value: Some(search_value), + ..Default::default() + } +} + +fn keymap_row_prefix(row: &KeymapActionRow) -> Vec> { + let status = if row.custom_binding { + "[C] ".cyan() + } else if row.is_unbound() { + "[-] ".dim() + } else { + "[D] ".dim() + }; + + vec![ + status, + format!( + "{: &'static str { + if row.custom_binding { + "Custom" + } else { + "Default" + } +} + +fn keymap_source_sentence(row: &KeymapActionRow) -> &'static str { + if row.custom_binding { + "Custom root override" + } else { + "Default keymap" + } +} + +fn current_binding_sentence(row: &KeymapActionRow) -> String { + if row.is_unbound() { + "Unbound".to_string() + } else { + format!("Current {}", row.binding_summary) + } +} + +fn keymap_header(description: String, summary: String) -> Box { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Keymap".bold())); + header.push(Line::from(description.dim())); + header.push(Line::from(summary.dim())); + Box::new(header) +} + +fn action_count_line(count: usize) -> String { + match count { + 1 => "1 action.".to_string(), + _ => format!("{count} actions."), + } +} + +fn keymap_picker_hint_line() -> Line<'static> { + Line::from(vec![ + "left/right".cyan(), + " group · ".dim(), + "enter".cyan(), + " edit shortcut · ".dim(), + "esc".cyan(), + " close".dim(), + ]) +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap index e8de67492a..58d0ecf84c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_action_menu.snap @@ -2,6 +2,6 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -Set new key | Current: ctrl-enter | enabled -Remove custom binding | Restore the default binding for this action. | enabled -Cancel | Leave keymap unchanged. | enabled +Set new key | Capture a replacement key. | enabled +Remove custom binding | Restore the default keymap binding. | enabled +Back to shortcuts | Return to the shortcut list. | enabled diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap new file mode 100644 index 0000000000..8c6565a230 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/keymap_setup.rs +expression: snapshot +--- +Open Transcript | ctrl-t · Default | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default +Open External Editor | ctrl-g · Default | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default +Copy | ctrl-o · Default | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default +Toggle Vim Mode | unbound · Default | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default +Edit Previous Message | esc · Default | Chat edit_previous_message Edit Previous Message Begin or advance edit-previous-message when the composer is empty. esc Default +Confirm Edit Previous Message | enter · Default | Chat confirm_edit_previous_message Confirm Edit Previous Message Confirm the selected previous message to edit. enter Default +Submit | enter · Default | Composer submit Submit Submit the current composer draft. enter Default +Queue | tab · Default | Composer queue Queue Queue the draft while a task is running. tab Default +Toggle Shortcuts | ?, shift-? · Default | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default +Insert Newline | ctrl-j, ctrl-m, enter, shift-enter · Default | Editor insert_newline Insert Newline Insert a newline in the editor. ctrl-j, ctrl-m, enter, shift-enter Default +Move Left | left, ctrl-b · Default | Editor move_left Move Left Move the cursor left. left, ctrl-b Default +Move Right | right, ctrl-f · Default | Editor move_right Move Right Move the cursor right. right, ctrl-f Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 5d06e15ecd..5cc754da67 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,15 +2,25 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t -Open External Editor | ctrl-g | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g -Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o -Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound -Edit Previous Message | esc | Chat edit_previous_message Edit Previous Message Begin or advance edit-previous-message when the composer is empty. esc -Confirm Edit Previous Message | enter | Chat confirm_edit_previous_message Confirm Edit Previous Message Confirm the selected previous message to edit. enter -Submit | enter | Composer submit Submit Submit the current composer draft. enter -Queue | tab | Composer queue Queue Queue the draft while a task is running. tab -Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? -Insert Newline | ctrl-j, ctrl-m, enter, shift-enter | Editor insert_newline Insert Newline Insert a newline in the editor. ctrl-j, ctrl-m, enter, shift-enter -Move Left | left, ctrl-b | Editor move_left Move Left Move the cursor left. left, ctrl-b -Move Right | right, ctrl-f | Editor move_right Move Right Move the cursor right. right, ctrl-f +tab: All (91 selectable) +tab: Customized (0) (0 selectable) +tab: Unbound (1) (1 selectable) +tab: App (6 selectable) +tab: Composer (3 selectable) +tab: Editor (16 selectable) +tab: Vim (34 selectable) +tab: Navigation (17 selectable) +tab: Approval (6 selectable) +tab: Onboarding (9 selectable) +Open Transcript | ctrl-t · Default | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default +Open External Editor | ctrl-g · Default | Global open_external_editor Open External Editor Open the current draft in an external editor. ctrl-g Default +Copy | ctrl-o · Default | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default +Toggle Vim Mode | unbound · Default | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default +Edit Previous Message | esc · Default | Chat edit_previous_message Edit Previous Message Begin or advance edit-previous-message when the composer is empty. esc Default +Confirm Edit Previous Message | enter · Default | Chat confirm_edit_previous_message Confirm Edit Previous Message Confirm the selected previous message to edit. enter Default +Submit | enter · Default | Composer submit Submit Submit the current composer draft. enter Default +Queue | tab · Default | Composer queue Queue Queue the draft while a task is running. tab Default +Toggle Shortcuts | ?, shift-? · Default | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default +Insert Newline | ctrl-j, ctrl-m, enter, shift-enter · Default | Editor insert_newline Insert Newline Insert a newline in the editor. ctrl-j, ctrl-m, enter, shift-enter Default +Move Left | left, ctrl-b · Default | Editor move_left Move Left Move the cursor left. left, ctrl-b Default +Move Right | right, ctrl-f · Default | Editor move_right Move Right Move the cursor right. right, ctrl-f Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index 262a4fcb81..9cb9662d60 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -3,20 +3,21 @@ source: tui/src/keymap_setup.rs expression: "render_picker(params, 78)" --- - Remap Shortcut - Search actions. Enter edits the selected shortcut. + Keymap + All configurable shortcuts. + 91 actions, 0 customized, 1 unbound. - Search actions... -› Global Open Transcript ctrl-t - Global Open External Editor ctrl-g - Global Copy ctrl-o - Global Toggle Vim Mode unbound - Chat Edit Previous Message esc - Chat Confirm Edit Previous Message enter - Composer Submit enter - Composer Queue tab + [All] Customized (0) Unbound (1) App Composer Editor Vim Navigation + Approval Onboarding - Open the transcript overlay. + Type to search shortcuts +› [D] Global Open Transcript Current ctrl-t · Default … + [D] Global Open External Editor ctrl-g · Default + [D] Global Copy ctrl-o · Default + [-] Global Toggle Vim Mode unbound · Default + [D] Chat Edit Previous Message esc · Default + [D] Chat Confirm Edit Previous Message enter · Default + [D] Composer Submit enter · Default + [D] Composer Queue tab · Default - Saves to root `tui.keymap.*` so shortcuts stay consistent across profiles. - Press enter to confirm or esc to go back + left/right group · enter edit shortcut · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 7fd77daf26..36549b6c0b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -3,18 +3,20 @@ source: tui/src/keymap_setup.rs expression: "render_picker(params, 120)" --- - Remap Shortcut Selected Action - Search actions. Enter edits the selected shortcut. - Open Transcript - Search actions... Global global.open_transcript -› Global Open Transcript ctrl-t - Global Open External Editor ctrl-g Current: ctrl-t - Global Copy ctrl-o Source: default keymap - Global Toggle Vim Mode unbound - Chat Edit Previous Message esc Open the transcript overlay. - Chat Confirm Edit Previous Message enter - Composer Submit enter Enter edits this shortcut - Composer Queue tab + Keymap + All configurable shortcuts. + 91 actions, 0 customized, 1 unbound. - Saves to root `tui.keymap.*` so shortcuts stay consistent across profiles. - Press enter to confirm or esc to go back + [All] Customized (0) Unbound (1) App Composer Editor Vim Navigation Approval Onboarding + + Type to search shortcuts +› [D] Global Open Transcript Current ctrl-t · Default keymap. Open the transcript overlay. + [D] Global Open External Editor ctrl-g · Default + [D] Global Copy ctrl-o · Default + [-] Global Toggle Vim Mode unbound · Default + [D] Chat Edit Previous Message esc · Default + [D] Chat Confirm Edit Previous Message enter · Default + [D] Composer Submit enter · Default + [D] Composer Queue tab · Default + + left/right group · enter edit shortcut · esc close