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.
This commit is contained in:
Felipe Coury
2026-04-18 23:54:29 -03:00
parent f4fd3a6c7f
commit b7b6119c29
8 changed files with 573 additions and 315 deletions

View File

@@ -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<dyn Fn(usize, &crate::app_event_sender::AppEventSender) + Send + Sync>);
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(&current_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(&params, 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(&params, 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::<Vec<_>>()
.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(&params, KEYMAP_CUSTOM_TAB_ID);
let composer_tab = selection_tab(&params, "composer-shortcuts");
assert_eq!(
custom_tab
.items
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>(),
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(&params, 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(&params, KEYMAP_ALL_TAB_ID);
let snapshot = all_tab
.items
.iter()
.take(12)
@@ -589,7 +636,7 @@ mod tests {
.collect::<Vec<_>>()
.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}"
);
}

View File

@@ -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<Vec<KeymapActionDetail>>,
selected_idx: Arc<Mutex<usize>>,
layout: KeymapActionDetailsLayout,
}
impl KeymapActionDetailsRenderable {
pub(super) fn new(
details: Arc<Vec<KeymapActionDetail>>,
selected_idx: Arc<Mutex<usize>>,
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<Line<'static>> {
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<KeymapActionDetail> {
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()
}

View File

@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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<KeymapActionRow> {
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<Item = &'a KeymapActionRow>,
empty_name: &str,
empty_description: &str,
) -> Vec<SelectionItem> {
let items = rows
.into_iter()
.map(keymap_selection_item)
.collect::<Vec<_>>();
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<Span<'static>> {
let status = if row.custom_binding {
"[C] ".cyan()
} else if row.is_unbound() {
"[-] ".dim()
} else {
"[D] ".dim()
};
vec![
status,
format!(
"{:<width$} ",
row.context_label,
width = KEYMAP_CONTEXT_LABEL_WIDTH
)
.dim(),
]
}
fn keymap_source_label(row: &KeymapActionRow) -> &'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<dyn Renderable> {
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(),
])
}

View File

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

View File

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

View File

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

View File

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

View File

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