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