Support two-stroke TUI key chords (#36511)

## What changed

- Accept two-stroke bindings such as `ctrl-x ctrl-s` in TUI keymap configuration while preserving arrays as alternative bindings.
- Route chords through the active TUI context, show pending and configured chord hints, and cancel pending chords on `esc`, context changes, or a one-second timeout.
- Extend `/keymap` to capture, add, replace, and display key chords.
- Reject chords that conflict with active single-key bindings, overlapping-context chords, or reserved terminal shortcuts.

## Testing

- Add coverage for chord parsing, validation, matching, timeout and cancellation behavior, context dispatch, key capture, and rendered shortcut hints.

GitOrigin-RevId: dfdede03f518d4936ad8621ebf5537026f2b282e
This commit is contained in:
Felipe Coury
2026-08-01 17:43:03 +00:00
committed by copyberry
parent a1dd74b535
commit 1e85ca099e
61 changed files with 3503 additions and 660 deletions
+34 -9
View File
@@ -26,20 +26,21 @@ use std::collections::BTreeMap;
/// Highest function key supported by portable TUI keymap configuration.
pub const MAX_FUNCTION_KEY: u8 = 24;
/// Normalized string representation of a single key event (for example `ctrl-a`).
/// Maximum number of key events in one configurable TUI binding.
const MAX_KEY_CHORD_STROKES: usize = 2;
/// Normalized representation of one key event or a two-stroke key chord.
///
/// The parser accepts a small alias set (for example `escape` -> `esc`,
/// `pageup` -> `page-up`) and stores the canonical form.
///
/// This deliberately represents one terminal key event, not a sequence of
/// events. A value like `ctrl-x ctrl-s` is not a chord in this schema; adding
/// multi-step chords would require a separate runtime state machine.
/// `pageup` -> `page-up`) and stores each stroke in its canonical form. Chord
/// strokes are separated by one space, for example `ctrl-x ctrl-s`. Arrays of
/// bindings remain alternatives; their entries do not form a chord together.
#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
pub struct KeybindingSpec(#[schemars(with = "String")] pub String);
impl KeybindingSpec {
/// Returns the canonical key-spec string (for example `ctrl-a`).
/// Returns the canonical key-spec string (for example `ctrl-x ctrl-s`).
pub fn as_str(&self) -> &str {
self.0.as_str()
}
@@ -60,8 +61,8 @@ impl<'de> Deserialize<'de> for KeybindingSpec {
///
/// This accepts either:
///
/// 1. A single key spec string (`"ctrl-a"`).
/// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`).
/// 1. A single key or chord string (`"ctrl-a"` or `"ctrl-x ctrl-s"`).
/// 2. A list of alternative bindings (`["ctrl-a", "ctrl-x ctrl-s"]`).
///
/// An empty list explicitly unbinds the action in that scope. Because an
/// explicit empty list is still a configured value, runtime resolution must not
@@ -436,6 +437,26 @@ pub struct TuiKeymap {
/// this function when accepting user-authored key specs, or otherwise equivalent
/// keys can fail to compare equal in tests, UI hints, and duplicate detection.
fn normalize_keybinding_spec(raw: &str) -> Result<String, String> {
let strokes = raw.split_whitespace().collect::<Vec<_>>();
if strokes.is_empty() {
return normalize_keybinding_stroke(raw);
}
if strokes.len() > MAX_KEY_CHORD_STROKES {
return Err(format!(
"invalid keybinding `{raw}`: key chords may contain at most \
{MAX_KEY_CHORD_STROKES} strokes (for example `ctrl-x ctrl-s`)."
));
}
strokes
.into_iter()
.map(normalize_keybinding_stroke)
.collect::<Result<Vec<_>, _>>()
.map(|normalized| normalized.join(" "))
}
/// Normalize the key and modifiers for one stroke in a key binding.
fn normalize_keybinding_stroke(raw: &str) -> Result<String, String> {
let lower = raw.trim().to_ascii_lowercase();
if lower.is_empty() {
return Err(
@@ -687,3 +708,7 @@ mod tests {
assert!(normalize_keybinding_spec("f25").is_err());
}
}
#[cfg(test)]
#[path = "tui_keymap_chord_tests.rs"]
mod chord_tests;
@@ -0,0 +1,76 @@
use super::KeybindingSpec;
use super::KeybindingsSpec;
use super::TuiKeymap;
use super::normalize_keybinding_spec;
use pretty_assertions::assert_eq;
#[test]
fn normalizes_each_stroke_in_a_key_chord() {
assert_eq!(
normalize_keybinding_spec(" CONTROL-X Option-Return "),
Ok("ctrl-x alt-enter".to_string())
);
}
#[test]
fn deserializes_a_two_stroke_global_key_chord() {
let actual = toml::from_str::<TuiKeymap>(
r#"
[global]
open_transcript = "ctrl-x ctrl-t"
"#,
)
.expect("two-stroke key chords should deserialize");
let expected = TuiKeymap {
global: super::TuiGlobalKeymap {
open_transcript: Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x ctrl-t".to_string(),
))),
..Default::default()
},
..Default::default()
};
assert_eq!(actual, expected);
}
#[test]
fn keeps_single_keys_and_chords_as_alternative_bindings() {
let actual = toml::from_str::<TuiKeymap>(
r#"
[global]
open_transcript = ["ctrl-t", "ctrl-x ctrl-t"]
"#,
)
.expect("single keys and key chords should coexist");
let expected = TuiKeymap {
global: super::TuiGlobalKeymap {
open_transcript: Some(KeybindingsSpec::Many(vec![
KeybindingSpec("ctrl-t".to_string()),
KeybindingSpec("ctrl-x ctrl-t".to_string()),
])),
..Default::default()
},
..Default::default()
};
assert_eq!(actual, expected);
}
#[test]
fn rejects_chords_longer_than_two_strokes() {
let error = normalize_keybinding_spec("ctrl-x ctrl-s ctrl-t")
.expect_err("key chords should have a bounded length");
assert!(error.contains("at most 2 strokes"), "{error}");
}
#[test]
fn rejects_an_invalid_second_chord_stroke() {
let error = normalize_keybinding_spec("ctrl-x ctrl-unknown")
.expect_err("every key chord stroke should be valid");
assert!(error.contains("unknown key"), "{error}");
}
+1 -1
View File
@@ -1427,7 +1427,7 @@
"type": "array"
}
],
"description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action."
"description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key or chord string (`\"ctrl-a\"` or `\"ctrl-x ctrl-s\"`). 2. A list of alternative bindings (`[\"ctrl-a\", \"ctrl-x ctrl-s\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action."
},
"LegacyAppPathString": {
"type": "string"
+13
View File
@@ -49,6 +49,7 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::UpdateAvailableHistoryCell;
use crate::hooks_rpc::HookTrustUpdate;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::KeyChordMatcher;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
@@ -537,6 +538,7 @@ pub(crate) struct App {
pub(crate) enhanced_keys_supported: bool,
pub(crate) keymap: RuntimeKeymap,
pub(crate) key_chord_matcher: KeyChordMatcher,
/// Controls the animation thread that sends CommitTick events.
pub(crate) commit_anim_running: Arc<AtomicBool>,
@@ -1048,6 +1050,7 @@ See the Codex keymap documentation for supported actions and examples."
file_search,
enhanced_keys_supported,
keymap: runtime_keymap,
key_chord_matcher: KeyChordMatcher::default(),
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
@@ -1292,9 +1295,19 @@ See the Codex keymap documentation for supported actions and examples."
) -> Result<AppRunControl> {
let screen_size = tui.screen_size_for_event(&event)?;
if !matches!(&event, TuiEvent::Key(_) | TuiEvent::Paste(_)) {
self.expire_pending_key_chord();
self.handle_draw_pre_render(tui, screen_size)?;
}
let event = if let TuiEvent::Key(key_event) = event {
let Some(key_event) = self.route_key_chord_event(tui, key_event) else {
return Ok(AppRunControl::Continue);
};
TuiEvent::Key(key_event)
} else {
event
};
if self.overlay.is_some() {
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
} else {
+10 -2
View File
@@ -2349,9 +2349,15 @@ impl App {
context,
action,
intent,
capture_mode,
} => {
self.chat_widget
.open_keymap_capture(context, action, intent, &self.keymap);
self.chat_widget.open_keymap_capture(
context,
action,
intent,
capture_mode,
&self.keymap,
);
}
AppEvent::OpenKeymapDebug => {
self.chat_widget.open_keymap_debug(&self.keymap);
@@ -2424,6 +2430,7 @@ impl App {
.await
{
Ok(()) => {
self.cancel_pending_key_chord();
self.config.tui_keymap = keymap_config.clone();
self.keymap = runtime_keymap.clone();
self.chat_widget
@@ -2475,6 +2482,7 @@ impl App {
.await
{
Ok(()) => {
self.cancel_pending_key_chord();
self.config.tui_keymap = keymap_config.clone();
self.keymap = runtime_keymap.clone();
self.chat_widget
+77
View File
@@ -7,6 +7,83 @@ use super::*;
use crate::app_backtrack::SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE;
impl App {
pub(super) fn route_key_chord_event(
&mut self,
tui: &mut tui::Tui,
key_event: KeyEvent,
) -> Option<KeyEvent> {
let contexts = self.active_keymap_contexts();
let was_pending = self.key_chord_matcher.is_pending();
match self.key_chord_matcher.advance(
key_event,
&self.keymap.chords,
contexts,
tokio::time::Instant::now(),
) {
crate::keymap::KeyChordMatch::PassThrough => {
if was_pending && !self.key_chord_matcher.is_pending() {
self.chat_widget.set_footer_hint_override(/*items*/ None);
}
Some(key_event)
}
crate::keymap::KeyChordMatch::Pending(prefix) => {
if self.backtrack.primed {
self.reset_backtrack_state();
}
self.chat_widget.set_footer_hint_override(Some(vec![
(
format!("{} …", prefix.display_label()),
"waiting for next key".to_string(),
),
("esc".to_string(), "cancel".to_string()),
]));
tui.frame_requester()
.schedule_frame_in(crate::keymap::KEY_CHORD_TIMEOUT);
None
}
crate::keymap::KeyChordMatch::Completed(dispatch_event) => {
self.chat_widget.set_footer_hint_override(/*items*/ None);
Some(dispatch_event)
}
crate::keymap::KeyChordMatch::Cancelled => {
self.chat_widget.set_footer_hint_override(/*items*/ None);
None
}
crate::keymap::KeyChordMatch::Ignored => None,
}
}
pub(super) fn expire_pending_key_chord(&mut self) {
let contexts = self.active_keymap_contexts();
if self
.key_chord_matcher
.expire(contexts, tokio::time::Instant::now())
{
self.chat_widget.set_footer_hint_override(/*items*/ None);
}
}
pub(super) fn cancel_pending_key_chord(&mut self) {
if self.key_chord_matcher.cancel() {
self.chat_widget.set_footer_hint_override(/*items*/ None);
}
}
fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
if self.overlay.is_some() {
return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Pager);
}
let contexts = self.chat_widget.keymap_contexts();
if self.chat_widget.no_modal_or_popup_active() {
contexts
.with(crate::keymap::KeymapContext::Global)
.with(crate::keymap::KeymapContext::Chat)
} else {
contexts
}
}
pub(super) async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
let editor_cmd = match external_editor::resolve_editor_command() {
Ok(cmd) => cmd,
+8 -5
View File
@@ -244,8 +244,10 @@ impl App {
.side_threads
.values()
.any(|state| state.parent_thread_id == active_thread_id)
&& let Some(binding) =
crate::keymap::primary_binding(&self.keymap.app.toggle_side_conversation)
&& let Some(binding) = self.keymap.primary_hint(
crate::keymap::KeymapContext::Global,
"toggle_side_conversation",
)
{
self.chat_widget
.set_side_conversation_context_label(Some(format!(
@@ -273,9 +275,10 @@ impl App {
if let Some(parent_status) = parent_status {
label_parts.push(parent_status.label(parent_is_main).to_string());
}
if let Some(binding) =
crate::keymap::primary_binding(&self.keymap.app.toggle_side_conversation)
{
if let Some(binding) = self.keymap.primary_hint(
crate::keymap::KeymapContext::Global,
"toggle_side_conversation",
) {
label_parts.push(format!("{} to switch", binding.display_label()));
}
label_parts.push("ctrl + c to close".to_string());
+1
View File
@@ -40,6 +40,7 @@ pub(super) async fn make_test_app() -> App {
initial_history_replay_buffer: None,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
+4
View File
@@ -2,6 +2,8 @@
#[path = "tests/advanced_reasoning_tests.rs"]
mod advanced_reasoning_tests;
#[path = "tests/key_chords.rs"]
mod key_chords;
mod model_catalog;
mod plugin_catalog;
mod rate_limits;
@@ -4740,6 +4742,7 @@ async fn make_test_app() -> App {
initial_history_replay_buffer: None,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
@@ -4808,6 +4811,7 @@ async fn make_test_app_with_channels() -> (
initial_history_replay_buffer: None,
enhanced_keys_supported: false,
keymap: crate::keymap::RuntimeKeymap::defaults(),
key_chord_matcher: crate::keymap::KeyChordMatcher::default(),
commit_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
+217
View File
@@ -0,0 +1,217 @@
use super::App;
use super::AppServerSession;
use super::Result;
use super::RuntimeKeymap;
use super::TuiEvent;
use super::make_test_app;
use super::start_config_write_test_app_server;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::keymap::KeymapContext;
use crate::test_support::test_path_display;
use crate::tui::Tui;
use codex_app_server_protocol::ToolRequestUserInputOption;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
async fn chord_app() -> Result<(App, Tui, AppServerSession)> {
let mut app = make_test_app().await;
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x ctrl-t".to_string(),
)));
config.chat.interrupt_turn = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x ctrl-u".to_string(),
)));
config.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("ctrl-x j".to_string())));
config.list.accept = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let runtime =
RuntimeKeymap::from_config(&config).map_err(|error| color_eyre::eyre::eyre!(error))?;
app.chat_widget.apply_keymap_update(config, &runtime);
app.keymap = runtime;
let app_server = start_config_write_test_app_server(&app).await?;
let tui = crate::tui::test_support::make_test_tui()?;
Ok((app, tui, app_server))
}
async fn press(
app: &mut App,
tui: &mut Tui,
app_server: &mut AppServerSession,
key: KeyEvent,
) -> Result<()> {
app.handle_tui_event(tui, app_server, TuiEvent::Key(key))
.await?;
Ok(())
}
fn ctrl(ch: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL)
}
#[tokio::test]
async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
assert!(app.key_chord_matcher.is_pending());
assert!(app.overlay.is_none());
insta::assert_snapshot!(
render_bottom_popup(&app.chat_widget, /*width*/ 80)
.replace(&test_path_display("/tmp/project"), "/tmp/project"),
@r"
› Ask Codex to do anything
ctrl + x … waiting for next key esc cancel
"
);
press(&mut app, &mut tui, &mut app_server, ctrl('t')).await?;
assert!(!app.key_chord_matcher.is_pending());
assert!(app.overlay.is_some());
Ok(())
}
#[tokio::test]
async fn wrong_second_stroke_passes_through_but_escape_is_consumed() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
let wrong_second = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
assert_eq!(
app.route_key_chord_event(&mut tui, wrong_second),
Some(wrong_second)
);
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.route_key_chord_event(&mut tui, escape), None);
assert!(!app.key_chord_matcher.is_pending());
assert!(!app.backtrack.primed);
Ok(())
}
#[tokio::test]
async fn physical_dispatch_band_events_are_dropped() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
let (code, modifiers) = app
.keymap
.app
.open_transcript
.last()
.expect("configured chord appends a dispatch token")
.parts();
press(
&mut app,
&mut tui,
&mut app_server,
KeyEvent::new(code, modifiers),
)
.await?;
assert!(app.overlay.is_none());
Ok(())
}
#[tokio::test]
async fn physical_chords_route_list_and_mixed_request_input_modals() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
app.chat_widget.show_selection_view(SelectionViewParams {
view_id: Some("list"),
items: ["First", "Second"]
.into_iter()
.map(|name| SelectionItem {
name: name.to_string(),
dismiss_on_select: true,
..Default::default()
})
.collect(),
..Default::default()
});
assert_eq!(
app.chat_widget.selected_index_for_present_view("list"),
Some(0)
);
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
assert!(app.key_chord_matcher.is_pending());
assert_eq!(
app.chat_widget.selected_index_for_present_view("list"),
Some(0)
);
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('j').into(),
)
.await?;
assert_eq!(
app.chat_widget.selected_index_for_present_view("list"),
Some(1)
);
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
press(&mut app, &mut tui, &mut app_server, KeyCode::Enter.into()).await?;
assert_eq!(
app.chat_widget.selected_index_for_present_view("list"),
None
);
app.chat_widget
.handle_request_user_input_now(ToolRequestUserInputParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "call-1".to_string(),
questions: vec![ToolRequestUserInputQuestion {
id: "choice".to_string(),
header: "Pick one".to_string(),
question: "Choose an option.".to_string(),
is_other: false,
is_secret: false,
options: Some(
["First", "Second"]
.into_iter()
.map(|label| ToolRequestUserInputOption {
label: label.to_string(),
description: label.to_string(),
})
.collect(),
),
}],
is_blocking: true,
auto_resolution_ms: None,
});
let contexts = app.chat_widget.keymap_contexts();
assert!(contexts.contains(KeymapContext::Chat));
assert!(contexts.contains(KeymapContext::List));
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("› 1. First"));
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
assert!(app.key_chord_matcher.is_pending());
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('j').into(),
)
.await?;
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("› 2. Second"));
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
press(&mut app, &mut tui, &mut app_server, ctrl('u')).await?;
assert!(app.chat_widget.can_launch_external_editor());
Ok(())
}
+8
View File
@@ -176,6 +176,13 @@ pub(crate) enum KeymapEditIntent {
ReplaceOne { old_key: String },
}
/// Number of key strokes recorded by one `/keymap` capture.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum KeymapCaptureMode {
SingleKey,
Chord,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
@@ -1116,6 +1123,7 @@ pub(crate) enum AppEvent {
context: String,
action: String,
intent: KeymapEditIntent,
capture_mode: KeymapCaptureMode,
},
/// Open the keymap keypress inspector.
+40 -16
View File
@@ -29,7 +29,7 @@ use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
@@ -669,23 +669,29 @@ impl AppLinkView {
}
fn hint_line(&self) -> Line<'static> {
Line::from(vec![
"Use ".into(),
key_hint::plain(KeyCode::Tab).into(),
" / ".into(),
key_hint::plain(KeyCode::Up).into(),
" ".into(),
key_hint::plain(KeyCode::Down).into(),
" to move, ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to select, ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
])
let mut spans = vec!["Use ".into(), key_hint::plain(KeyCode::Tab).into()];
if let Some(move_up) = self.list_keymap.primary_hint(ListAction::MoveUp) {
spans.extend([" / ".into(), move_up.into()]);
}
if let Some(move_down) = self.list_keymap.primary_hint(ListAction::MoveDown) {
spans.extend([" ".into(), move_down.into()]);
}
spans.push(" to move".into());
if let Some(accept) = self.list_keymap.primary_hint(ListAction::Accept) {
spans.extend([", ".into(), accept.into(), " to select".into()]);
}
if let Some(cancel) = self.list_keymap.primary_hint(ListAction::Cancel) {
spans.extend([", ".into(), cancel.into(), " to close".into()]);
}
Line::from(spans)
}
}
impl BottomPaneView for AppLinkView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
@@ -693,6 +699,9 @@ impl BottomPaneView for AppLinkView {
} => {
self.on_ctrl_c();
}
_ if self.list_keymap.action_for(key_event) == Some(ListAction::Cancel) => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Up, ..
}
@@ -705,7 +714,13 @@ impl BottomPaneView for AppLinkView {
modifiers: KeyModifiers::NONE,
..
} => self.move_selection_prev(),
_ if self.list_keymap.move_left.is_pressed(key_event) => self.move_selection_prev(),
_ if matches!(
self.list_keymap.action_for(key_event),
Some(ListAction::MoveUp | ListAction::MoveLeft)
) =>
{
self.move_selection_prev()
}
KeyEvent {
code: KeyCode::Down,
..
@@ -718,7 +733,16 @@ impl BottomPaneView for AppLinkView {
modifiers: KeyModifiers::NONE,
..
} => self.move_selection_next(),
_ if self.list_keymap.move_right.is_pressed(key_event) => self.move_selection_next(),
_ if matches!(
self.list_keymap.action_for(key_event),
Some(ListAction::MoveDown | ListAction::MoveRight)
) =>
{
self.move_selection_next()
}
_ if self.list_keymap.action_for(key_event) == Some(ListAction::Accept) => {
self.activate_selected_action();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE,
@@ -33,8 +33,8 @@ use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ApprovalKeymap;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::primary_binding;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -291,7 +291,7 @@ impl ApprovalOverlay {
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
display_shortcut: opt.shortcuts.first().copied(),
display_shortcut: approval_keymap.hint_for_bindings(&opt.shortcuts),
dismiss_on_select: false,
..Default::default()
})
@@ -563,6 +563,11 @@ impl ApprovalOverlay {
}
impl BottomPaneView for ApprovalOverlay {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::Approval)
.with(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.try_handle_shortcut(&key_event) {
return;
@@ -619,14 +624,15 @@ fn approval_footer_hint(
list_keymap: &ListKeymap,
) -> Line<'static> {
let mut spans = accept_cancel_hint_line(
primary_binding(&list_keymap.accept),
list_keymap.primary_hint(ListAction::Accept),
"to confirm",
primary_binding(&list_keymap.cancel),
list_keymap.primary_hint(ListAction::Cancel),
"to cancel",
)
.spans;
if request.thread_label().is_some()
&& let Some(open_thread) = primary_binding(&approval_keymap.open_thread)
&& let Some(open_thread) =
approval_keymap.primary_hint("open_thread", &approval_keymap.open_thread)
{
if !spans.is_empty() {
spans.push(" or ".into());
@@ -1106,11 +1112,15 @@ fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::keymap::RuntimeKeymap;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::ExecPolicyAmendment;
use codex_app_server_protocol::NetworkApprovalProtocol;
use codex_app_server_protocol::NetworkPolicyAmendment;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -1242,6 +1252,58 @@ mod tests {
})
}
#[test]
fn approval_shortcuts_display_chords_in_configured_order() {
let chord = crate::key_hint::ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::plain(KeyCode::Char('y')),
};
let single = crate::key_hint::ShortcutHint::Single(key_hint::plain(KeyCode::Char('y')));
let binding = |spec: &str| KeybindingSpec(spec.to_string());
for (specs, expected_shortcut) in [
(KeybindingsSpec::One(binding("ctrl-x y")), chord),
(
KeybindingsSpec::Many(vec![binding("y"), binding("ctrl-x y")]),
single,
),
(
KeybindingsSpec::Many(vec![binding("ctrl-x y"), binding("y")]),
chord,
),
] {
let mut config = TuiKeymap::default();
config.approval.approve = Some(specs);
let keymap = RuntimeKeymap::from_config(&config).expect("valid approval keymap");
let request = make_exec_request();
let (_, params) = ApprovalOverlay::build_options(
&request,
build_header(&request),
&Features::with_defaults(),
&keymap.approval,
&keymap.list,
);
let approval = params
.items
.iter()
.find(|item| item.name == "Yes, proceed")
.expect("approval selection");
assert_eq!(approval.display_shortcut, Some(expected_shortcut));
if matches!(
expected_shortcut,
crate::key_hint::ShortcutHint::Chord { .. }
) {
let dispatch_token = keymap.approval.approve.last().expect("chord token");
assert_eq!(
keymap
.approval
.hint_for_bindings(std::slice::from_ref(dispatch_token)),
Some(expected_shortcut)
);
}
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -1,6 +1,7 @@
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::McpServerElicitationFormRequest;
use crate::keymap::KeymapContextSet;
use crate::render::renderable::Renderable;
use codex_app_server_protocol::ToolRequestUserInputParams;
use crossterm::event::KeyEvent;
@@ -21,6 +22,11 @@ pub(crate) trait BottomPaneView: Renderable {
/// scheduled after this call.
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
/// Return the keymap contexts whose handlers are active in this view.
fn keymap_contexts(&self) -> KeymapContextSet {
KeymapContextSet::default()
}
/// Return `true` if the view has finished and should be removed.
fn is_complete(&self) -> bool {
false
+98 -23
View File
@@ -171,6 +171,7 @@
//!
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::key_hint::has_ctrl_or_alt;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::ui_consts::FOOTER_INDENT_COLS;
@@ -248,9 +249,11 @@ use crate::bottom_pane::paste_burst::FlushResult;
use crate::history_cell::sanitize_user_text;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::EditorKeymap;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::primary_binding;
use crate::keymap::user_bindings;
use crate::onboarding::mark_underlined_hyperlink;
use crate::render::Insets;
use crate::render::RectExt;
@@ -625,19 +628,24 @@ impl ChatComposer {
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
external_editor_key: default_keymap
.primary_hint(KeymapContext::Global, "open_external_editor"),
show_transcript_key: default_keymap
.primary_hint(KeymapContext::Global, "open_transcript"),
insert_newline_key: footer_insert_newline_key(
&default_keymap.editor.insert_newline,
use_shift_enter_hint,
),
queue_key: Some(key_hint::plain(KeyCode::Tab)),
toggle_shortcuts_key: Some(key_hint::plain(KeyCode::Char('?'))),
history_search_key: primary_binding(
&default_keymap.composer.history_search_previous,
),
reasoning_down_key: primary_binding(&default_keymap.chat.decrease_reasoning_effort),
reasoning_up_key: primary_binding(&default_keymap.chat.increase_reasoning_effort),
)
.map(ShortcutHint::from),
queue_key: default_keymap.primary_hint(KeymapContext::Composer, "queue"),
toggle_shortcuts_key: default_keymap
.primary_hint(KeymapContext::Composer, "toggle_shortcuts"),
history_search_key: default_keymap
.primary_hint(KeymapContext::Composer, "history_search_previous"),
reasoning_down_key: default_keymap
.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort"),
reasoning_up_key: default_keymap
.primary_hint(KeymapContext::Chat, "increase_reasoning_effort"),
},
has_focus: has_input_focus,
frame_requester: None,
@@ -842,17 +850,39 @@ impl ChatComposer {
self.editor_keymap = keymap.editor.clone();
self.vim_normal_keymap = keymap.vim_normal.clone();
self.draft.textarea.set_keymap_bindings(keymap);
self.footer.external_editor_key = primary_binding(&keymap.app.open_external_editor);
self.footer.show_transcript_key = primary_binding(&keymap.app.open_transcript);
self.footer.insert_newline_key = footer_insert_newline_key(
&keymap.editor.insert_newline,
self.footer.use_shift_enter_hint,
);
self.footer.queue_key = primary_binding(&keymap.composer.queue);
self.footer.toggle_shortcuts_key = primary_binding(&keymap.composer.toggle_shortcuts);
self.footer.history_search_key = primary_binding(&keymap.composer.history_search_previous);
self.footer.reasoning_down_key = primary_binding(&keymap.chat.decrease_reasoning_effort);
self.footer.reasoning_up_key = primary_binding(&keymap.chat.increase_reasoning_effort);
self.footer.external_editor_key =
keymap.primary_hint(KeymapContext::Global, "open_external_editor");
self.footer.show_transcript_key =
keymap.primary_hint(KeymapContext::Global, "open_transcript");
self.footer.insert_newline_key =
match keymap.primary_hint(KeymapContext::Editor, "insert_newline") {
hint @ Some(ShortcutHint::Chord { .. }) => hint,
_ => footer_insert_newline_key(
&keymap.editor.insert_newline,
self.footer.use_shift_enter_hint,
)
.map(ShortcutHint::from),
};
self.footer.queue_key = keymap.primary_hint(KeymapContext::Composer, "queue");
self.footer.toggle_shortcuts_key =
keymap.primary_hint(KeymapContext::Composer, "toggle_shortcuts");
self.footer.history_search_key =
keymap.primary_hint(KeymapContext::Composer, "history_search_previous");
self.footer.reasoning_down_key =
keymap.primary_hint(KeymapContext::Chat, "decrease_reasoning_effort");
self.footer.reasoning_up_key =
keymap.primary_hint(KeymapContext::Chat, "increase_reasoning_effort");
}
/// Return the contexts whose handlers can consume the next composer key.
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if self.history_search.is_some() {
return KeymapContextSet::new(KeymapContext::Composer);
}
if !self.draft.input_enabled || self.popups.active() {
return KeymapContextSet::default();
}
KeymapContextSet::new(KeymapContext::Composer).with(self.draft.textarea.keymap_context())
}
pub fn set_collaboration_mode_indicator(
@@ -3679,7 +3709,7 @@ impl ChatComposer {
queue: self.footer.queue_key,
insert_newline: self.footer.insert_newline_key,
external_editor: self.footer.external_editor_key,
edit_previous: Some(key_hint::plain(KeyCode::Esc)),
edit_previous: Some(key_hint::plain(KeyCode::Esc).into()),
show_transcript: self.footer.show_transcript_key,
history_search: self.footer.history_search_key,
reasoning_down: self.footer.reasoning_down_key,
@@ -4247,6 +4277,7 @@ fn footer_insert_newline_key(
bindings: &[KeyBinding],
enhanced_keys_supported: bool,
) -> Option<KeyBinding> {
let bindings = user_bindings(bindings);
let shift_enter = key_hint::shift(KeyCode::Enter);
if enhanced_keys_supported && bindings.contains(&shift_enter) {
return Some(shift_enter);
@@ -4850,6 +4881,50 @@ mod tests {
)
}
#[test]
fn shortcut_footer_displays_configured_chords() {
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
let mut config = TuiKeymap::default();
config.global.open_external_editor = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-g ctrl-g".to_string(),
)));
config.editor.insert_newline = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid composer chords");
let (mut composer, _rx) = new_test_composer();
composer.set_keymap_bindings(&keymap);
composer.footer.mode = FooterMode::ShortcutOverlay;
let hints = composer.footer_props().key_hints;
assert_eq!(
hints.external_editor,
Some(ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('g')),
completion: key_hint::ctrl(KeyCode::Char('g')),
})
);
assert_eq!(
hints.insert_newline,
Some(ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::plain(KeyCode::Enter),
})
);
snapshot_composer_state(
"footer_mode_configured_key_chords",
/*enhanced_keys_supported*/ false,
|composer| {
composer.set_keymap_bindings(&keymap);
composer.footer.mode = FooterMode::ShortcutOverlay;
},
);
}
#[test]
fn parent_owned_thread_allows_bare_navigation_commands() {
for (command, expected) in [
@@ -8,6 +8,7 @@ use crate::bottom_pane::footer::CollaborationModeIndicator;
use crate::bottom_pane::footer::FooterMode;
use crate::bottom_pane::footer::GoalStatusIndicator;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
#[cfg(test)]
use std::time::Duration;
@@ -30,14 +31,14 @@ pub(super) struct FooterState {
pub(super) status_line_enabled: bool,
pub(super) side_conversation_context_label: Option<String>,
pub(super) active_agent_label: Option<String>,
pub(super) external_editor_key: Option<KeyBinding>,
pub(super) show_transcript_key: Option<KeyBinding>,
pub(super) insert_newline_key: Option<KeyBinding>,
pub(super) queue_key: Option<KeyBinding>,
pub(super) toggle_shortcuts_key: Option<KeyBinding>,
pub(super) history_search_key: Option<KeyBinding>,
pub(super) reasoning_down_key: Option<KeyBinding>,
pub(super) reasoning_up_key: Option<KeyBinding>,
pub(super) external_editor_key: Option<ShortcutHint>,
pub(super) show_transcript_key: Option<ShortcutHint>,
pub(super) insert_newline_key: Option<ShortcutHint>,
pub(super) queue_key: Option<ShortcutHint>,
pub(super) toggle_shortcuts_key: Option<ShortcutHint>,
pub(super) history_search_key: Option<ShortcutHint>,
pub(super) reasoning_down_key: Option<ShortcutHint>,
pub(super) reasoning_up_key: Option<ShortcutHint>,
}
#[derive(Clone, Debug)]
@@ -14,6 +14,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
@@ -66,7 +67,7 @@ impl ExperimentalFeaturesView {
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: experimental_popup_hint_line(),
footer_hint: experimental_popup_hint_line(&keymap),
keymap,
};
view.initialize_selection();
@@ -164,6 +165,10 @@ impl ExperimentalFeaturesView {
}
impl BottomPaneView for ExperimentalFeaturesView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
@@ -282,12 +287,18 @@ impl Renderable for ExperimentalFeaturesView {
}
}
fn experimental_popup_hint_line() -> Line<'static> {
Line::from(vec![
fn experimental_popup_hint_line(keymap: &ListKeymap) -> Line<'static> {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to select or ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to save for next conversation".into(),
])
" to select".into(),
];
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
spans.extend([
" or ".into(),
accept.into(),
" to save for next conversation".into(),
]);
}
Line::from(spans)
}
+23 -20
View File
@@ -43,6 +43,7 @@
//! `FooterProps` mapping.
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::render::line_utils::prefix_lines;
use crate::status::format_tokens_compact;
use crate::ui_consts::FOOTER_INDENT_COLS;
@@ -106,30 +107,30 @@ const FOOTER_CONTEXT_GAP_COLS: u16 = 1;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct FooterKeyHints {
pub(crate) toggle_shortcuts: Option<KeyBinding>,
pub(crate) queue: Option<KeyBinding>,
pub(crate) insert_newline: Option<KeyBinding>,
pub(crate) external_editor: Option<KeyBinding>,
pub(crate) edit_previous: Option<KeyBinding>,
pub(crate) show_transcript: Option<KeyBinding>,
pub(crate) history_search: Option<KeyBinding>,
pub(crate) reasoning_down: Option<KeyBinding>,
pub(crate) reasoning_up: Option<KeyBinding>,
pub(crate) toggle_shortcuts: Option<ShortcutHint>,
pub(crate) queue: Option<ShortcutHint>,
pub(crate) insert_newline: Option<ShortcutHint>,
pub(crate) external_editor: Option<ShortcutHint>,
pub(crate) edit_previous: Option<ShortcutHint>,
pub(crate) show_transcript: Option<ShortcutHint>,
pub(crate) history_search: Option<ShortcutHint>,
pub(crate) reasoning_down: Option<ShortcutHint>,
pub(crate) reasoning_up: Option<ShortcutHint>,
}
impl FooterKeyHints {
#[cfg(test)]
pub(crate) fn default_bindings() -> Self {
Self {
toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?'))),
queue: Some(key_hint::plain(KeyCode::Tab)),
insert_newline: Some(key_hint::ctrl(KeyCode::Char('j'))),
external_editor: Some(key_hint::ctrl(KeyCode::Char('g'))),
edit_previous: Some(key_hint::plain(KeyCode::Esc)),
show_transcript: Some(key_hint::ctrl(KeyCode::Char('t'))),
history_search: Some(key_hint::ctrl(KeyCode::Char('r'))),
reasoning_down: Some(key_hint::alt(KeyCode::Char(','))),
reasoning_up: Some(key_hint::alt(KeyCode::Char('.'))),
toggle_shortcuts: Some(key_hint::plain(KeyCode::Char('?')).into()),
queue: Some(key_hint::plain(KeyCode::Tab).into()),
insert_newline: Some(key_hint::ctrl(KeyCode::Char('j')).into()),
external_editor: Some(key_hint::ctrl(KeyCode::Char('g')).into()),
edit_previous: Some(key_hint::plain(KeyCode::Esc).into()),
show_transcript: Some(key_hint::ctrl(KeyCode::Char('t')).into()),
history_search: Some(key_hint::ctrl(KeyCode::Char('r')).into()),
reasoning_down: Some(key_hint::alt(KeyCode::Char(',')).into()),
reasoning_up: Some(key_hint::alt(KeyCode::Char('.')).into()),
}
}
}
@@ -1087,7 +1088,9 @@ impl ShortcutDescriptor {
| ShortcutId::FilePaths
| ShortcutId::PasteImage
| ShortcutId::Quit
| ShortcutId::ChangeMode => self.binding_for(state).map(|binding| binding.key),
| ShortcutId::ChangeMode => self
.binding_for(state)
.map(|binding| ShortcutHint::Single(binding.key)),
}?;
let mut line = Line::from(vec![self.prefix.into(), key.into()]);
match self.id {
@@ -1576,7 +1579,7 @@ mod tests {
status_line_value: None,
status_line_enabled: false,
key_hints: FooterKeyHints {
insert_newline: Some(key_hint::shift(KeyCode::Enter)),
insert_newline: Some(key_hint::shift(KeyCode::Enter).into()),
..FooterKeyHints::default_bindings()
},
active_agent_label: None,
@@ -30,6 +30,7 @@ use crate::hooks_rpc::HookTrustUpdate;
use crate::hooks_rpc::hook_needs_review;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable;
@@ -511,35 +512,40 @@ impl HooksBrowserView {
width: area.width.saturating_sub(2),
height: area.height,
};
let accept = self.keymap.primary_hint(ListAction::Accept);
let cancel = self
.keymap
.primary_hint(ListAction::Cancel)
.unwrap_or_else(|| key_hint::plain(KeyCode::Esc).into());
let footer = match self.page {
HooksBrowserPage::Events if self.review_needed_total_count() > 0 => Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust all; ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to review hooks; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
]),
HooksBrowserPage::Events => Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to view hooks; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close".into(),
]),
HooksBrowserPage::Events if self.review_needed_total_count() > 0 => {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust all; ".into(),
];
if let Some(accept) = accept {
spans.extend([accept.into(), " to review hooks; ".into()]);
}
spans.extend([cancel.into(), " to close".into()]);
Line::from(spans)
}
HooksBrowserPage::Events => {
let mut spans = vec!["Press ".into()];
if let Some(accept) = accept {
spans.extend([accept.into(), " to view hooks; ".into()]);
}
spans.extend([cancel.into(), " to close".into()]);
Line::from(spans)
}
HooksBrowserPage::Handlers(event_name) => {
let selected_hook = self.selected_hook(event_name);
if selected_hook.is_none() {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to go back".into(),
])
Line::from(vec!["Press ".into(), cancel.into(), " to go back".into()])
} else if selected_hook.is_some_and(|hook| hook.is_managed) {
Line::from(vec![
"Managed hooks are always on; press ".into(),
key_hint::plain(KeyCode::Esc).into(),
cancel.into(),
" to go back".into(),
])
} else if selected_hook.is_some_and(hook_needs_review) {
@@ -547,19 +553,17 @@ impl HooksBrowserView {
"Press ".into(),
key_hint::plain(KeyCode::Char('t')).into(),
" to trust; ".into(),
key_hint::plain(KeyCode::Esc).into(),
cancel.into(),
" to go back".into(),
])
} else {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" or ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to go back".into(),
])
let mut spans =
vec!["Press ".into(), key_hint::plain(KeyCode::Char(' ')).into()];
if let Some(accept) = accept {
spans.extend([" or ".into(), accept.into()]);
}
spans.extend([" to toggle; ".into(), cancel.into(), " to go back".into()]);
Line::from(spans)
}
}
};
@@ -568,6 +572,10 @@ impl HooksBrowserView {
}
impl BottomPaneView for HooksBrowserView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
@@ -1002,8 +1010,10 @@ mod tests {
#[test]
fn renders_event_browser() {
let view = view();
let mut view = view();
assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112));
view.keymap.accept.clear();
assert!(!render_lines(&view, /*width*/ 112).contains("enter to view hooks"));
}
#[test]
@@ -16,8 +16,8 @@ use super::selection_popup_common::render_menu_surface;
use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::clipboard_paste::normalize_pasted_search_query;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListKeymap;
use crate::render::renderable::ColumnRenderable;
@@ -135,7 +135,7 @@ pub(crate) struct SelectionItem {
pub name_prefix_spans: Vec<Span<'static>>,
pub toggle: Option<SelectionToggle>,
pub toggle_placeholder: Option<&'static str>,
pub display_shortcut: Option<KeyBinding>,
pub display_shortcut: Option<ShortcutHint>,
pub description: Option<String>,
pub selected_description: Option<String>,
pub is_current: bool,
@@ -950,6 +950,10 @@ impl ListSelectionView {
}
impl BottomPaneView for ListSelectionView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
// Searchable lists reserve printable characters for query input. This
// keeps vim-style plain j/k/h/l useful in non-search lists without
@@ -49,8 +49,11 @@ use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::selection_popup_common::render_rows;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::keymap::KeymapContext;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use crate::text_formatting::format_json_compact;
use crate::text_formatting::truncate_text;
@@ -713,6 +716,7 @@ pub(crate) struct McpServerElicitationOverlay {
done: bool,
validation_error: Option<String>,
list_keymap: ListKeymap,
composer_submit_hint: Option<ShortcutHint>,
}
impl McpServerElicitationOverlay {
@@ -730,7 +734,7 @@ impl McpServerElicitationOverlay {
has_input_focus,
enhanced_keys_supported,
disable_paste_burst,
crate::keymap::RuntimeKeymap::defaults().list,
RuntimeKeymap::defaults(),
)
}
@@ -740,8 +744,9 @@ impl McpServerElicitationOverlay {
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
list_keymap: ListKeymap,
keymap: RuntimeKeymap,
) -> Self {
let composer_submit_hint = keymap.primary_hint(KeymapContext::Composer, "submit");
let mut composer = ChatComposer::new_with_config(
has_input_focus,
app_event_tx.clone(),
@@ -750,6 +755,7 @@ impl McpServerElicitationOverlay {
disable_paste_burst,
ChatComposerConfig::plain_text(),
);
composer.set_keymap_bindings(&keymap);
composer.set_footer_hint_override(Some(Vec::new()));
let mut overlay = Self {
app_event_tx,
@@ -760,7 +766,8 @@ impl McpServerElicitationOverlay {
current_idx: 0,
done: false,
validation_error: None,
list_keymap,
list_keymap: keymap.list,
composer_submit_hint,
};
overlay.reset_for_request();
overlay.restore_current_draft();
@@ -984,20 +991,21 @@ impl McpServerElicitationOverlay {
fn footer_tips(&self) -> Vec<FooterTip> {
let mut tips = Vec::new();
let is_last_field = self.current_index().saturating_add(1) >= self.field_count();
if self.current_field_is_select() {
if self.field_count() == 1 {
tips.push(FooterTip::highlighted("enter to submit"));
} else if is_last_field {
tips.push(FooterTip::highlighted("enter to submit all"));
} else {
tips.push(FooterTip::new("enter to submit answer"));
}
} else if self.field_count() == 1 {
tips.push(FooterTip::highlighted("enter to submit"));
} else if is_last_field {
tips.push(FooterTip::highlighted("enter to submit all"));
let submit_hint = if self.current_field_is_select() {
self.list_keymap.primary_hint(ListAction::Accept)
} else {
tips.push(FooterTip::new("enter to submit answer"));
self.composer_submit_hint
};
if let Some(submit_hint) = submit_hint.map(ShortcutHint::display_label) {
if self.field_count() == 1 {
tips.push(FooterTip::highlighted(format!("{submit_hint} to submit")));
} else if is_last_field {
tips.push(FooterTip::highlighted(format!(
"{submit_hint} to submit all"
)));
} else {
tips.push(FooterTip::new(format!("{submit_hint} to submit answer")));
}
}
if self.field_count() > 1 {
if self.current_field_is_select() {
@@ -1505,6 +1513,14 @@ impl Renderable for McpServerElicitationOverlay {
}
impl BottomPaneView for McpServerElicitationOverlay {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
if self.current_field_is_select() {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
} else {
self.composer.keymap_contexts()
}
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
@@ -1514,7 +1530,10 @@ impl BottomPaneView for McpServerElicitationOverlay {
return;
}
if matches!(key_event.code, KeyCode::Esc) {
if matches!(key_event.code, KeyCode::Esc)
|| self.current_field_is_select()
&& self.list_keymap.action_for(key_event) == Some(ListAction::Cancel)
{
self.dispatch_cancel();
self.done = true;
return;
@@ -1561,7 +1580,7 @@ impl BottomPaneView for McpServerElicitationOverlay {
return;
}
_ if self.current_field_is_select()
&& self.list_keymap.move_left.is_pressed(key_event) =>
&& self.list_keymap.action_for(key_event) == Some(ListAction::MoveLeft) =>
{
self.move_field(/*next*/ false);
return;
@@ -1580,7 +1599,7 @@ impl BottomPaneView for McpServerElicitationOverlay {
return;
}
_ if self.current_field_is_select()
&& self.list_keymap.move_right.is_pressed(key_event) =>
&& self.list_keymap.action_for(key_event) == Some(ListAction::MoveRight) =>
{
self.move_field(/*next*/ true);
return;
@@ -1591,28 +1610,28 @@ impl BottomPaneView for McpServerElicitationOverlay {
if self.current_field_is_select() {
self.validation_error = None;
let options_len = self.options_len();
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
match (self.list_keymap.action_for(key_event), key_event.code) {
(Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => {
if let Some(answer) = self.current_answer_mut() {
answer.selection.move_up_wrap(options_len);
answer.answer_committed = false;
}
}
KeyCode::Down | KeyCode::Char('j') => {
(Some(ListAction::MoveDown), _) | (_, KeyCode::Down | KeyCode::Char('j')) => {
if let Some(answer) = self.current_answer_mut() {
answer.selection.move_down_wrap(options_len);
answer.answer_committed = false;
}
}
KeyCode::Backspace | KeyCode::Delete => self.clear_selection(),
KeyCode::Char(' ') => self.select_current_option(/*committed*/ true),
KeyCode::Enter => {
(_, KeyCode::Backspace | KeyCode::Delete) => self.clear_selection(),
(_, KeyCode::Char(' ')) => self.select_current_option(/*committed*/ true),
(Some(ListAction::Accept), _) | (_, KeyCode::Enter) => {
if self.selected_option_index().is_some() {
self.select_current_option(/*committed*/ true);
}
self.go_next_or_submit();
}
KeyCode::Char(ch) => {
(_, KeyCode::Char(ch)) => {
if let Some(option_idx) = self.option_index_for_digit(ch) {
if let Some(answer) = self.current_answer_mut() {
answer.selection.selected_idx = Some(option_idx);
@@ -1738,6 +1757,9 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::render::renderable::Renderable;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
@@ -2234,6 +2256,73 @@ mod tests {
assert_eq!(overlay.current_idx, 1);
overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
assert_eq!(overlay.current_idx, 0);
overlay.list_keymap.accept.clear();
assert!(
overlay
.footer_tips()
.iter()
.all(|tip| !tip.text.contains("submit"))
);
}
#[test]
fn text_fields_inherit_composer_chord_context_and_submit_binding() {
let (tx, _rx) = test_sender();
let thread_id = ThreadId::default();
let request = from_form_request(
thread_id,
form_request(
"Enter a name",
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name",
}
},
"required": ["name"],
}),
/*meta*/ None,
),
)
.expect("supported text field");
let mut config = TuiKeymap::default();
config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid MCP composer chord");
let mut overlay = McpServerElicitationOverlay::new_with_keymap(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ true, keymap,
);
let snapshot = render_snapshot(
&overlay,
Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10,
),
)
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(
"mcp_server_elicitation_text_configured_key_chords",
snapshot
);
assert!(
overlay
.keymap_contexts()
.contains(crate::keymap::KeymapContext::Composer)
);
overlay.composer_submit_hint = None;
assert!(
overlay
.footer_tips()
.iter()
.all(|tip| !tip.text.contains("submit"))
);
}
#[test]
@@ -12,9 +12,10 @@ use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::popup_consts::standard_popup_hint_line_for_keymap;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
@@ -290,14 +291,18 @@ impl MemoriesSettingsView {
fn footer_hint(&self) -> Line<'static> {
if self.reset_confirmation.is_some() {
standard_popup_hint_line()
standard_popup_hint_line_for_keymap(&self.keymap)
} else {
memories_settings_hint_line()
memories_settings_hint_line(&self.keymap)
}
}
}
impl BottomPaneView for MemoriesSettingsView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
_ if self.keymap.move_up.is_pressed(key_event) => self.move_up(),
@@ -459,12 +464,14 @@ impl Renderable for MemoriesSettingsView {
}
}
fn memories_settings_hint_line() -> Line<'static> {
Line::from(vec![
fn memories_settings_hint_line(keymap: &ListKeymap) -> Line<'static> {
let mut spans = vec![
"Press ".into(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle; ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to save or select".into(),
])
" to toggle".into(),
];
if let Some(accept) = keymap.primary_hint(ListAction::Accept) {
spans.extend(["; ".into(), accept.into(), " to save or select".into()]);
}
Line::from(spans)
}
+25 -6
View File
@@ -27,8 +27,9 @@ use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::keymap::RuntimeKeymap;
use crate::keymap::primary_binding;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
@@ -398,7 +399,7 @@ impl BottomPane {
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.keymap = keymap.clone();
self.composer.set_keymap_bindings(keymap);
let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn);
let interrupt_binding = keymap.primary_hint(KeymapContext::Chat, "interrupt_turn");
self.pending_input_preview
.set_interrupt_binding(interrupt_binding);
if let Some(status) = self.status.as_mut() {
@@ -485,7 +486,10 @@ impl BottomPane {
/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: Option<KeyBinding>) {
pub(crate) fn set_queued_message_edit_binding(
&mut self,
binding: Option<crate::key_hint::ShortcutHint>,
) {
self.pending_input_preview.set_edit_binding(binding);
self.request_redraw();
}
@@ -693,6 +697,15 @@ impl BottomPane {
}
}
/// Return the contexts whose ordinary handlers can consume the next key.
pub(crate) fn keymap_contexts(&self) -> KeymapContextSet {
if let Some(view) = self.view_stack.last() {
view.keymap_contexts()
} else {
self.composer.keymap_contexts()
}
}
/// Handles a Ctrl+C press within the bottom pane.
///
/// An active modal view is given the first chance to consume the key (typically to dismiss
@@ -1047,7 +1060,10 @@ impl BottomPane {
}
if let Some(status) = self.status.as_mut() {
status.set_interrupt_hint_visible(/*visible*/ true);
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
status.set_interrupt_binding(
self.keymap
.primary_hint(KeymapContext::Chat, "interrupt_turn"),
);
}
self.sync_status_inline_message();
self.request_redraw();
@@ -1077,7 +1093,10 @@ impl BottomPane {
self.animations_enabled,
));
if let Some(status) = self.status.as_mut() {
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
status.set_interrupt_binding(
self.keymap
.primary_hint(KeymapContext::Chat, "interrupt_turn"),
);
}
self.sync_status_inline_message();
self.request_redraw();
@@ -1553,7 +1572,7 @@ impl BottomPane {
self.has_input_focus,
self.enhanced_keys_supported,
self.disable_paste_burst,
self.keymap.list.clone(),
self.keymap.clone(),
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
@@ -63,9 +63,9 @@ use crate::bottom_pane::selection_popup_common::render_rows_single_line;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::primary_binding;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt;
@@ -515,6 +515,10 @@ impl MultiSelectPicker {
}
impl BottomPaneView for MultiSelectPicker {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn is_complete(&self) -> bool {
self.complete
}
@@ -828,8 +832,8 @@ impl MultiSelectPickerBuilder {
];
if self.ordering_enabled
&& let (Some(move_left), Some(move_right)) = (
primary_binding(&self.keymap.move_left),
primary_binding(&self.keymap.move_right),
self.keymap.primary_hint(ListAction::MoveLeft),
self.keymap.primary_hint(ListAction::MoveRight),
)
{
spans.push("; ".into());
@@ -838,12 +842,12 @@ impl MultiSelectPickerBuilder {
spans.push(move_right.into());
spans.push(" to move".into());
}
if let Some(accept) = primary_binding(&self.keymap.accept) {
if let Some(accept) = self.keymap.primary_hint(ListAction::Accept) {
spans.push("; ".into());
spans.push(accept.into());
spans.push(" to confirm and close".into());
}
if let Some(cancel) = primary_binding(&self.keymap.cancel) {
if let Some(cancel) = self.keymap.primary_hint(ListAction::Cancel) {
spans.push("; ".into());
spans.push(cancel.into());
spans.push(" to close".into());
@@ -26,9 +26,9 @@ pub(crate) struct PendingInputPreview {
pub queued_messages: Vec<String>,
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
/// be overridden for terminals where that chord is unavailable.
edit_binding: Option<key_hint::KeyBinding>,
edit_binding: Option<key_hint::ShortcutHint>,
/// Key combination rendered for immediately interrupting and sending steers.
interrupt_binding: Option<key_hint::KeyBinding>,
interrupt_binding: Option<key_hint::ShortcutHint>,
}
const PREVIEW_LINE_LIMIT: usize = 3;
@@ -39,19 +39,19 @@ impl PendingInputPreview {
pending_steers: Vec::new(),
rejected_steers: Vec::new(),
queued_messages: Vec::new(),
edit_binding: Some(key_hint::alt(KeyCode::Up)),
interrupt_binding: Some(key_hint::plain(KeyCode::Esc)),
edit_binding: Some(key_hint::alt(KeyCode::Up).into()),
interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()),
}
}
/// Replace the keybinding shown in the hint line at the bottom of the
/// queued-messages list. The caller is responsible for also wiring the
/// corresponding key event handler.
pub(crate) fn set_edit_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
pub(crate) fn set_edit_binding(&mut self, binding: Option<key_hint::ShortcutHint>) {
self.edit_binding = binding;
}
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<key_hint::ShortcutHint>) {
self.interrupt_binding = binding;
}
@@ -216,7 +216,7 @@ mod tests {
fn render_one_message_with_shift_left_binding() {
let mut queue = PendingInputPreview::new();
queue.queued_messages.push("Hello, world!".to_string());
queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left)));
queue.set_edit_binding(Some(key_hint::shift(KeyCode::Left).into()));
let width = 40;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
@@ -337,7 +337,7 @@ mod tests {
fn render_one_pending_steer_with_remapped_interrupt_binding() {
let mut queue = PendingInputPreview::new();
queue.pending_steers.push("Please continue.".to_string());
queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12))));
queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into()));
let width = 48;
let height = queue.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
+6 -6
View File
@@ -3,9 +3,9 @@
use ratatui::text::Line;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::primary_binding;
use crossterm::event::KeyCode;
/// Maximum number of rows any popup should attempt to display.
@@ -25,17 +25,17 @@ pub(crate) fn standard_popup_hint_line() -> Line<'static> {
pub(crate) fn standard_popup_hint_line_for_keymap(list_keymap: &ListKeymap) -> Line<'static> {
accept_cancel_hint_line(
primary_binding(&list_keymap.accept),
list_keymap.primary_hint(ListAction::Accept),
"to confirm",
primary_binding(&list_keymap.cancel),
list_keymap.primary_hint(ListAction::Cancel),
"to go back",
)
}
pub(crate) fn accept_cancel_hint_line(
accept: Option<KeyBinding>,
accept: Option<ShortcutHint>,
accept_label: &'static str,
cancel: Option<KeyBinding>,
cancel: Option<ShortcutHint>,
cancel_label: &'static str,
) -> Line<'static> {
match (accept, cancel) {
@@ -33,6 +33,9 @@ use crate::bottom_pane::selection_popup_common::measure_rows_height;
use crate::history_cell;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::keymap::KeymapContext;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
@@ -170,7 +173,9 @@ pub(crate) struct RequestUserInputOverlay {
request_started_at: Instant,
auto_resolution_snoozed: bool,
composer_submit_keys: Vec<KeyBinding>,
composer_submit_hint: Option<ShortcutHint>,
interrupt_turn_keys: Vec<KeyBinding>,
interrupt_turn_hint: Option<ShortcutHint>,
list_keymap: ListKeymap,
}
@@ -228,7 +233,9 @@ impl RequestUserInputOverlay {
request_started_at: Instant::now(),
auto_resolution_snoozed: false,
composer_submit_keys: keymap.composer.submit.clone(),
composer_submit_hint: keymap.primary_hint(KeymapContext::Composer, "submit"),
interrupt_turn_keys: keymap.chat.interrupt_turn.clone(),
interrupt_turn_hint: keymap.primary_hint(KeymapContext::Chat, "interrupt_turn"),
list_keymap: keymap.list,
};
overlay.reset_for_request();
@@ -594,11 +601,11 @@ impl RequestUserInputOverlay {
let question_count = self.question_count();
let is_last_question = self.current_index().saturating_add(1) >= question_count;
let submit_key = if self.focus_is_notes() || !self.has_options() {
self.composer_submit_keys
.first()
.map(KeyBinding::display_label)
self.composer_submit_hint.map(ShortcutHint::display_label)
} else {
Some("enter".to_string())
self.list_keymap
.primary_hint(ListAction::Accept)
.map(ShortcutHint::display_label)
};
if let Some(submit_key) = submit_key {
let submit_tip = if question_count == 1 {
@@ -617,10 +624,10 @@ impl RequestUserInputOverlay {
tips.push(FooterTip::new("ctrl + p / ctrl + n change question"));
}
}
if let Some(interrupt_key) = self.interrupt_turn_keys.first()
if let Some(interrupt_key) = self.interrupt_turn_hint
&& !(self.has_options()
&& notes_visible
&& *interrupt_key == crate::key_hint::plain(KeyCode::Esc))
&& interrupt_key == ShortcutHint::Single(crate::key_hint::plain(KeyCode::Esc)))
{
tips.push(FooterTip::new(format!(
"{} to interrupt",
@@ -1179,6 +1186,19 @@ impl RequestUserInputOverlay {
}
impl BottomPaneView for RequestUserInputOverlay {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
if self.confirm_unanswered_active() {
return crate::keymap::KeymapContextSet::default();
}
if matches!(self.focus, Focus::Options) {
return crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
.with(crate::keymap::KeymapContext::Chat);
}
self.composer
.keymap_contexts()
.with(crate::keymap::KeymapContext::Chat)
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
@@ -1280,7 +1300,7 @@ impl BottomPaneView for RequestUserInputOverlay {
}
_ if self.has_options()
&& matches!(self.focus, Focus::Options)
&& self.list_keymap.move_left.is_pressed(key_event) =>
&& self.list_keymap.action_for(key_event) == Some(ListAction::MoveLeft) =>
{
self.move_question(/*next*/ false);
return;
@@ -1300,7 +1320,7 @@ impl BottomPaneView for RequestUserInputOverlay {
}
_ if self.has_options()
&& matches!(self.focus, Focus::Options)
&& self.list_keymap.move_right.is_pressed(key_event) =>
&& self.list_keymap.action_for(key_event) == Some(ListAction::MoveRight) =>
{
self.move_question(/*next*/ true);
return;
@@ -1312,8 +1332,8 @@ impl BottomPaneView for RequestUserInputOverlay {
Focus::Options => {
let options_len = self.options_len();
// Keep selection synchronized as the user moves.
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
match (self.list_keymap.action_for(key_event), key_event.code) {
(Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => {
let moved = if let Some(answer) = self.current_answer_mut() {
answer.options_state.move_up_wrap(options_len);
answer.answer_committed = false;
@@ -1325,7 +1345,7 @@ impl BottomPaneView for RequestUserInputOverlay {
self.sync_composer_placeholder();
}
}
KeyCode::Down | KeyCode::Char('j') => {
(Some(ListAction::MoveDown), _) | (_, KeyCode::Down | KeyCode::Char('j')) => {
let moved = if let Some(answer) = self.current_answer_mut() {
answer.options_state.move_down_wrap(options_len);
answer.answer_committed = false;
@@ -1337,24 +1357,24 @@ impl BottomPaneView for RequestUserInputOverlay {
self.sync_composer_placeholder();
}
}
KeyCode::Char(' ') => {
(_, KeyCode::Char(' ')) => {
self.select_current_option(/*committed*/ true);
}
KeyCode::Backspace | KeyCode::Delete => {
(_, KeyCode::Backspace | KeyCode::Delete) => {
self.clear_selection();
}
KeyCode::Tab if self.selected_option_index().is_some() => {
(_, KeyCode::Tab) if self.selected_option_index().is_some() => {
self.focus = Focus::Notes;
self.ensure_selected_for_notes();
}
KeyCode::Enter => {
(Some(ListAction::Accept), _) | (_, KeyCode::Enter) => {
let has_selection = self.selected_option_index().is_some();
if has_selection {
self.select_current_option(/*committed*/ true);
}
self.go_next_or_submit();
}
KeyCode::Char(ch) => {
(_, KeyCode::Char(ch)) => {
if let Some(option_idx) = self.option_index_for_digit(ch) {
if let Some(answer) = self.current_answer_mut() {
answer.options_state.selected_idx = Some(option_idx);
@@ -1516,6 +1536,9 @@ mod tests {
use crate::app_event::AppEvent;
use crate::bottom_pane::selection_popup_common::menu_surface_inset;
use crate::render::renderable::Renderable;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -2514,6 +2537,49 @@ mod tests {
);
}
#[test]
fn freeform_footer_displays_configured_chords_without_internal_dispatch_keys() {
for (specs, expected_tips) in [
(
KeybindingsSpec::One(KeybindingSpec("ctrl-x enter".to_string())),
vec!["ctrl + x enter to submit answer", "esc to interrupt"],
),
(
KeybindingsSpec::Many(vec![
KeybindingSpec("ctrl-enter".to_string()),
KeybindingSpec("ctrl-x enter".to_string()),
]),
vec!["ctrl + enter to submit answer", "esc to interrupt"],
),
(
KeybindingsSpec::Many(vec![
KeybindingSpec("ctrl-x enter".to_string()),
KeybindingSpec("ctrl-enter".to_string()),
]),
vec!["ctrl + x enter to submit answer", "esc to interrupt"],
),
] {
let (tx, _rx) = test_sender();
let mut config = TuiKeymap::default();
config.composer.submit = Some(specs);
let keymap = RuntimeKeymap::from_config(&config).expect("valid submit keymap");
let overlay = RequestUserInputOverlay::new_with_keymap(
request_event("turn-1", vec![question_without_options("q1", "Notes")]),
tx,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
keymap,
);
let tips = overlay.footer_tips();
assert_eq!(
tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>(),
expected_tips
);
}
}
#[test]
fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() {
let (tx, mut rx) = test_sender();
@@ -3656,6 +3722,44 @@ mod tests {
);
}
#[test]
fn request_user_input_freeform_chord_only_submit_snapshot() {
let (tx, _rx) = test_sender();
let mut config = TuiKeymap::default();
config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid submit chord");
let overlay = RequestUserInputOverlay::new_with_keymap(
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
tx,
/*has_input_focus*/ true,
/*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
keymap,
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 120, /*height*/ 10,
);
let snapshot = render_snapshot(&overlay, area)
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(snapshot, @r"
Question 1/1 (1 unanswered)
Share details.
› Type your answer (optional)
ctrl + x enter to submit answer | esc to interrupt
");
}
#[test]
fn request_user_input_freeform_remapped_submit_snapshot() {
let (tx, _rx) = test_sender();
@@ -8,7 +8,7 @@ use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::line_truncation::line_width;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
@@ -32,7 +32,7 @@ use super::selection_row_layout::wrap_stacked_row;
pub(crate) struct GenericDisplayRow {
pub name: String,
pub name_prefix_spans: Vec<Span<'static>>,
pub display_shortcut: Option<KeyBinding>,
pub display_shortcut: Option<ShortcutHint>,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub description: Option<String>, // optional grey text after the name
pub category_tag: Option<String>, // optional right-side category label
@@ -15,9 +15,10 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::primary_binding;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
@@ -232,6 +233,10 @@ impl SkillsToggleView {
}
impl BottomPaneView for SkillsToggleView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
// Printable characters always feed search. Movement aliases such as
// plain j/k only apply through non-text events or modified bindings.
@@ -388,8 +393,10 @@ impl Renderable for SkillsToggleView {
fn skills_toggle_hint_line(keymap: &ListKeymap) -> Line<'static> {
let space = key_hint::plain(KeyCode::Char(' '));
let accept = primary_binding(&keymap.accept).filter(|binding| *binding != space);
let cancel = primary_binding(&keymap.cancel);
let accept = keymap
.primary_hint(ListAction::Accept)
.filter(|binding| *binding != ShortcutHint::Single(space));
let cancel = keymap.primary_hint(ListAction::Cancel);
match (accept, cancel) {
(Some(accept), Some(cancel)) => Line::from(vec![
@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" / for commands ! for shell commands "
" ctrl + x enter for newline tab to submit message "
" @ for file paths ctrl + v to paste images "
" ctrl + g ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + r search history ctrl + c to exit "
" ⌥ + , reasoning down ⌥ + . reasoning up "
" ctrl + t to view transcript "
" "
" customize shortcuts with /keymap "
@@ -0,0 +1,13 @@
---
source: tui/src/bottom_pane/mcp_server_elicitation.rs
expression: snapshot
---
Field 1/1 (1 required unanswered)
Enter a name
Name
› Type your answer
ctrl + x enter to submit | esc to cancel
@@ -371,6 +371,10 @@ impl StatusLineSetupView {
}
impl BottomPaneView for StatusLineSetupView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
self.picker.handle_key_event(key_event);
}
+13
View File
@@ -13,6 +13,7 @@
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
use crate::keymap::EditorKeymap;
use crate::keymap::KeymapContext;
use crate::keymap::RuntimeKeymap;
use crate::keymap::VimNormalKeymap;
use crate::keymap::VimOperatorKeymap;
@@ -280,6 +281,18 @@ impl TextArea {
!matches!(self.vim_pending, VimPending::None)
}
/// Return the keymap context that owns the next editing key.
pub(crate) fn keymap_context(&self) -> KeymapContext {
if !self.vim_enabled || self.vim_mode == VimMode::Insert {
return KeymapContext::Editor;
}
match self.vim_pending {
VimPending::None => KeymapContext::VimNormal,
VimPending::Operator(_) => KeymapContext::VimOperator,
VimPending::TextObject { .. } => KeymapContext::VimTextObject,
}
}
/// Enter Vim insert mode if modal editing is enabled.
///
/// Calling this while Vim is disabled is a no-op, which lets parent
@@ -345,6 +345,10 @@ impl TerminalTitleSetupView {
}
impl BottomPaneView for TerminalTitleSetupView {
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
}
fn handle_key_event(&mut self, key_event: crossterm::event::KeyEvent) {
self.picker.handle_key_event(key_event);
}
+16 -6
View File
@@ -240,14 +240,24 @@ fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyB
}
fn queued_message_edit_hint_binding(
bindings: &[KeyBinding],
keymap: &RuntimeKeymap,
terminal_info: TerminalInfo,
) -> Option<KeyBinding> {
) -> Option<crate::key_hint::ShortcutHint> {
let configured = keymap.primary_hint(crate::keymap::KeymapContext::Chat, "edit_queued_message");
if matches!(
configured,
Some(crate::key_hint::ShortcutHint::Chord { .. })
) {
return configured;
}
let terminal_binding = queued_message_edit_binding_for_terminal(terminal_info);
bindings
keymap
.chat
.edit_queued_message
.contains(&terminal_binding)
.then_some(terminal_binding)
.or_else(|| bindings.first().copied())
.then_some(crate::key_hint::ShortcutHint::Single(terminal_binding))
.or(configured)
}
fn normalize_thread_name(name: &str) -> Option<String> {
@@ -681,7 +691,7 @@ pub(crate) struct ChatWidget {
/// Keybinding to show for popping the most-recently queued message back
/// into the composer. This may differ from the first configured binding
/// when the default set includes a terminal-specific fallback.
queued_message_edit_hint_binding: Option<KeyBinding>,
queued_message_edit_hint_binding: Option<crate::key_hint::ShortcutHint>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
/// When `Some`, the user has pressed a quit shortcut and the second press
+1 -1
View File
@@ -82,7 +82,7 @@ impl ChatWidget {
.map(|keymap| keymap.chat.clone())
.unwrap_or_else(|| default_keymap.chat.clone());
let queued_message_edit_hint_binding = queued_message_edit_hint_binding(
&chat_keymap.edit_queued_message,
runtime_keymap.as_ref().unwrap_or(&default_keymap),
current_terminal_info,
);
let pet_http_client = codex_http_client::RouteAwareClientPool::new(
@@ -3,6 +3,10 @@
use super::*;
impl ChatWidget {
pub(crate) fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
self.bottom_pane.keymap_contexts()
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.bottom_pane.has_active_view()
&& !matches!(
+5 -4
View File
@@ -16,6 +16,7 @@ use codex_terminal_detection::terminal_info;
use super::ChatWidget;
use super::queued_message_edit_hint_binding;
use crate::app_event::KeymapCaptureMode;
use crate::app_event::KeymapEditIntent;
use crate::keymap::RuntimeKeymap;
use crate::keymap_setup;
@@ -73,12 +74,14 @@ impl ChatWidget {
context: String,
action: String,
intent: KeymapEditIntent,
capture_mode: KeymapCaptureMode,
runtime_keymap: &RuntimeKeymap,
) {
let view = keymap_setup::build_keymap_capture_view(
context,
action,
intent,
capture_mode,
runtime_keymap,
self.app_event_tx.clone(),
);
@@ -169,10 +172,8 @@ impl ChatWidget {
self.config.tui_keymap = keymap_config;
self.copy_last_response_binding = runtime_keymap.app.copy.clone();
self.chat_keymap = runtime_keymap.chat.clone();
self.queued_message_edit_hint_binding = queued_message_edit_hint_binding(
&self.chat_keymap.edit_queued_message,
terminal_info(),
);
self.queued_message_edit_hint_binding =
queued_message_edit_hint_binding(runtime_keymap, terminal_info());
self.bottom_pane
.set_queued_message_edit_binding(self.queued_message_edit_hint_binding);
self.bottom_pane.set_keymap_bindings(runtime_keymap);
+2 -2
View File
@@ -468,14 +468,14 @@ impl ChatWidget {
let items = vec![
SelectionItem {
name: "Yes".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),
display_shortcut: Some(key_hint::plain(KeyCode::Char('y')).into()),
actions: send_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "No".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),
display_shortcut: Some(key_hint::plain(KeyCode::Char('n')).into()),
is_default: true,
dismiss_on_select: true,
..Default::default()
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Experimental features
Toggle experimental features. Changes are saved to config.toml.
› [x] Shell tool Allow the model to run shell commands.
Press space to select or ctrl + x enter to save for next conversation
@@ -1363,7 +1363,7 @@ async fn restore_thread_input_state_applies_running_state_policy() {
async fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)];
chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up));
chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up).into());
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
@@ -1462,6 +1462,42 @@ async fn shift_left_edits_most_recent_queued_message_in_tmux() {
.await;
}
#[test]
fn queued_message_edit_hint_displays_configured_chords() {
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
let terminal_info = || TerminalInfo {
name: TerminalName::Iterm2,
term_program: None,
version: None,
term: None,
multiplexer: None,
};
let mut config = TuiKeymap::default();
config.chat.edit_queued_message = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x up".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid queued edit chord");
assert_eq!(
queued_message_edit_hint_binding(&keymap, terminal_info()),
Some(crate::key_hint::ShortcutHint::Chord {
prefix: crate::key_hint::ctrl(KeyCode::Char('x')),
completion: crate::key_hint::plain(KeyCode::Up),
})
);
let default_keymap = RuntimeKeymap::defaults();
assert_eq!(
queued_message_edit_hint_binding(&default_keymap, terminal_info()),
Some(crate::key_hint::ShortcutHint::Single(crate::key_hint::alt(
KeyCode::Up,
)))
);
}
#[test]
fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() {
assert_eq!(
+1 -1
View File
@@ -1173,7 +1173,7 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi
) {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.queued_message_edit_hint_binding =
Some(queued_message_edit_binding_for_terminal(terminal_info));
Some(queued_message_edit_binding_for_terminal(terminal_info).into());
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
@@ -2896,6 +2896,26 @@ async fn experimental_features_popup_snapshot() {
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("experimental_features_popup", popup);
let mut config = codex_config::types::TuiKeymap::default();
config.list.accept = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("ctrl-x enter".to_string()),
));
let keymap = crate::keymap::RuntimeKeymap::from_config(&config)
.expect("valid experimental-feature chord");
let view = ExperimentalFeaturesView::new(
vec![ExperimentalFeatureItem {
feature: Feature::ShellTool,
name: "Shell tool".to_string(),
description: "Allow the model to run shell commands.".to_string(),
enabled: true,
}],
chat.app_event_tx.clone(),
keymap.list,
);
chat.bottom_pane.show_view(Box::new(view));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("experimental_features_popup_configured_key_chords", popup);
}
#[tokio::test]
@@ -1780,6 +1780,7 @@ async fn keymap_capture_can_capture_current_copy_shortcut() {
"composer".to_string(),
"submit".to_string(),
crate::app_event::KeymapEditIntent::ReplaceAll,
crate::app_event::KeymapCaptureMode::SingleKey,
&runtime_keymap,
);
@@ -1814,6 +1815,7 @@ async fn slash_keymap_capture_can_capture_app_shortcuts() {
"global".to_string(),
"open_transcript".to_string(),
crate::app_event::KeymapEditIntent::ReplaceAll,
crate::app_event::KeymapCaptureMode::SingleKey,
&runtime_keymap,
);
+37
View File
@@ -45,6 +45,37 @@ pub(crate) struct KeyBinding {
modifiers: KeyModifiers,
}
/// A user-visible shortcut made from one key or a two-key chord.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) enum ShortcutHint {
Single(KeyBinding),
Chord {
prefix: KeyBinding,
completion: KeyBinding,
},
}
impl ShortcutHint {
pub(crate) fn display_label(self) -> String {
match self {
Self::Single(binding) => binding.display_label(),
Self::Chord { prefix, completion } => {
format!("{} {}", prefix.display_label(), completion.display_label())
}
}
}
pub(crate) fn is_press(self, event: KeyEvent) -> bool {
matches!(self, Self::Single(binding) if binding.is_press(event))
}
}
impl From<KeyBinding> for ShortcutHint {
fn from(binding: KeyBinding) -> Self {
Self::Single(binding)
}
}
impl KeyBinding {
pub(crate) const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
Self { key, modifiers }
@@ -193,6 +224,12 @@ impl From<&KeyBinding> for Span<'static> {
}
}
impl From<ShortcutHint> for Span<'static> {
fn from(hint: ShortcutHint) -> Self {
Span::styled(hint.display_label(), key_hint_style())
}
}
fn key_hint_style() -> Style {
Style::default().dim()
}
+174 -2
View File
@@ -20,13 +20,30 @@
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use codex_config::types::KeybindingsSpec;
use codex_config::types::MAX_FUNCTION_KEY;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
mod bindings;
mod chords;
pub(crate) use bindings::KeymapContext;
pub(crate) use bindings::bindings_for_action;
pub(crate) use bindings::keymap_action_id;
use bindings::runtime_action_bindings;
pub(crate) use chords::KEY_CHORD_TIMEOUT;
pub(crate) use chords::KeyChordMatch;
pub(crate) use chords::KeyChordMatcher;
pub(crate) use chords::KeymapContextSet;
pub(crate) use chords::RuntimeChordKeymap;
/// Runtime keymap used by TUI input handlers.
///
@@ -43,6 +60,7 @@ use std::collections::HashMap;
#[derive(Clone, Debug)]
pub(crate) struct RuntimeKeymap {
pub(crate) app: AppKeymap,
pub(crate) chords: Arc<RuntimeChordKeymap>,
pub(crate) chat: ChatKeymap,
pub(crate) composer: ComposerKeymap,
pub(crate) editor: EditorKeymap,
@@ -224,6 +242,50 @@ pub(crate) struct PagerKeymap {
pub(crate) jump_bottom: Vec<KeyBinding>,
pub(crate) close: Vec<KeyBinding>,
pub(crate) close_transcript: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>,
}
impl PagerKeymap {
pub(crate) fn primary_hint(
&self,
action: &'static str,
bindings: &[KeyBinding],
) -> Option<ShortcutHint> {
let action = keymap_action_id(KeymapContext::Pager.config_name(), action)?;
self.chord_hints.primary_hint(action, bindings)
}
}
/// Semantic navigation and confirmation actions shared by list-like views.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ListAction {
MoveUp,
MoveDown,
MoveLeft,
MoveRight,
PageUp,
PageDown,
JumpTop,
JumpBottom,
Accept,
Cancel,
}
impl ListAction {
fn config_name(self) -> &'static str {
match self {
Self::MoveUp => "move_up",
Self::MoveDown => "move_down",
Self::MoveLeft => "move_left",
Self::MoveRight => "move_right",
Self::PageUp => "page_up",
Self::PageDown => "page_down",
Self::JumpTop => "jump_top",
Self::JumpBottom => "jump_bottom",
Self::Accept => "accept",
Self::Cancel => "cancel",
}
}
}
/// Generic list picker keybindings shared across popup list views.
@@ -247,6 +309,47 @@ pub(crate) struct ListKeymap {
pub(crate) jump_bottom: Vec<KeyBinding>,
pub(crate) accept: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>,
}
impl ListKeymap {
pub(crate) fn bindings_for(&self, action: ListAction) -> &[KeyBinding] {
match action {
ListAction::MoveUp => &self.move_up,
ListAction::MoveDown => &self.move_down,
ListAction::MoveLeft => &self.move_left,
ListAction::MoveRight => &self.move_right,
ListAction::PageUp => &self.page_up,
ListAction::PageDown => &self.page_down,
ListAction::JumpTop => &self.jump_top,
ListAction::JumpBottom => &self.jump_bottom,
ListAction::Accept => &self.accept,
ListAction::Cancel => &self.cancel,
}
}
pub(crate) fn action_for(&self, event: KeyEvent) -> Option<ListAction> {
[
ListAction::MoveUp,
ListAction::MoveDown,
ListAction::MoveLeft,
ListAction::MoveRight,
ListAction::PageUp,
ListAction::PageDown,
ListAction::JumpTop,
ListAction::JumpBottom,
ListAction::Accept,
ListAction::Cancel,
]
.into_iter()
.find(|action| self.bindings_for(*action).is_pressed(event))
}
pub(crate) fn primary_hint(&self, action: ListAction) -> Option<ShortcutHint> {
let action_id = keymap_action_id(KeymapContext::List.config_name(), action.config_name())?;
self.chord_hints
.primary_hint(action_id, self.bindings_for(action))
}
}
/// Approval modal keybindings.
@@ -263,6 +366,38 @@ pub(crate) struct ApprovalKeymap {
pub(crate) deny: Vec<KeyBinding>,
pub(crate) decline: Vec<KeyBinding>,
pub(crate) cancel: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>,
}
impl ApprovalKeymap {
pub(crate) fn primary_hint(
&self,
action: &'static str,
bindings: &[KeyBinding],
) -> Option<ShortcutHint> {
let action = keymap_action_id(KeymapContext::Approval.config_name(), action)?;
self.chord_hints.primary_hint(action, bindings)
}
pub(crate) fn hint_for_bindings(&self, bindings: &[KeyBinding]) -> Option<ShortcutHint> {
[
("open_fullscreen", self.open_fullscreen.as_slice()),
("open_thread", self.open_thread.as_slice()),
("approve", self.approve.as_slice()),
("approve_for_session", self.approve_for_session.as_slice()),
("approve_for_prefix", self.approve_for_prefix.as_slice()),
("deny", self.deny.as_slice()),
("decline", self.decline.as_slice()),
("cancel", self.cancel.as_slice()),
]
.into_iter()
.find_map(|(action, configured)| {
(!bindings.is_empty() && bindings.iter().all(|binding| configured.contains(binding)))
.then(|| self.primary_hint(action, configured))
.flatten()
})
.or_else(|| primary_binding(bindings).map(ShortcutHint::from))
}
}
/// Returns the first binding, used as the primary UI hint for an action.
@@ -270,7 +405,19 @@ pub(crate) struct ApprovalKeymap {
/// Rendering code should prefer this for concise hints while preserving all
/// bindings for actual input matching.
pub(crate) fn primary_binding(bindings: &[KeyBinding]) -> Option<KeyBinding> {
bindings.first().copied()
user_bindings(bindings).first().copied()
}
/// Remove the trailing internal dispatch token, when present.
pub(crate) fn user_bindings(bindings: &[KeyBinding]) -> &[KeyBinding] {
if bindings
.last()
.is_some_and(|binding| chords::is_dispatch_token(*binding))
{
&bindings[..bindings.len() - 1]
} else {
bindings
}
}
/// Resolve one context-local action binding from config.
@@ -387,6 +534,7 @@ impl RuntimeKeymap {
/// dispatch, or conflict guarantees from this resolver no longer hold.
pub(crate) fn from_config(keymap: &TuiKeymap) -> Result<Self, String> {
let defaults = Self::built_in_defaults();
let chords = Arc::new(RuntimeChordKeymap::from_config(keymap)?);
let side_toggle_default_is_shadowed = keymap.global.toggle_side_conversation.is_none()
&& ["ctrl-/", "ctrl-7"].into_iter().any(|alias| {
configured_main_surface_alias_is_used(keymap, alias)
@@ -783,6 +931,7 @@ impl RuntimeKeymap {
jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom),
close: resolve_local!(keymap, defaults, pager, close),
close_transcript: resolve_local!(keymap, defaults, pager, close_transcript),
chord_hints: Arc::clone(&chords),
};
let approval = ApprovalKeymap {
@@ -794,6 +943,7 @@ impl RuntimeKeymap {
deny: resolve_local!(keymap, defaults, approval, deny),
decline: resolve_local!(keymap, defaults, approval, decline),
cancel: resolve_local!(keymap, defaults, approval, cancel),
chord_hints: Arc::clone(&chords),
};
let list_move_up = resolve_local!(keymap, defaults, list, move_up);
@@ -903,10 +1053,12 @@ impl RuntimeKeymap {
)?,
accept: list_accept,
cancel: list_cancel,
chord_hints: Arc::clone(&chords),
};
let resolved = Self {
let mut resolved = Self {
app,
chords,
chat,
composer,
editor,
@@ -919,9 +1071,22 @@ impl RuntimeKeymap {
};
resolved.validate_conflicts()?;
chords::validate_chord_conflicts(&resolved)?;
chords::install_dispatch_bindings(&mut resolved)?;
Ok(resolved)
}
/// Resolve the visible primary shortcut from configured declaration order.
pub(crate) fn primary_hint(
&self,
context: KeymapContext,
action: &'static str,
) -> Option<ShortcutHint> {
let action_id = keymap_action_id(context.config_name(), action)?;
let bindings = bindings_for_action(self, context.config_name(), action)?;
self.chords.primary_hint(action_id, bindings)
}
/// Built-in keymap defaults.
///
/// Some actions intentionally include compatibility variants (for example
@@ -939,6 +1104,7 @@ impl RuntimeKeymap {
toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))],
toggle_side_conversation: default_bindings![ctrl(KeyCode::Char('/'))],
},
chords: Arc::default(),
chat: ChatKeymap {
interrupt_turn: default_bindings![plain(KeyCode::Esc)],
decrease_reasoning_effort: default_bindings![
@@ -1132,6 +1298,7 @@ impl RuntimeKeymap {
jump_bottom: default_bindings![plain(KeyCode::End)],
close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))],
close_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
chord_hints: Arc::default(),
},
list: ListKeymap {
move_up: default_bindings![
@@ -1154,6 +1321,7 @@ impl RuntimeKeymap {
jump_bottom: default_bindings![plain(KeyCode::End)],
accept: default_bindings![plain(KeyCode::Enter)],
cancel: default_bindings![plain(KeyCode::Esc)],
chord_hints: Arc::default(),
},
approval: ApprovalKeymap {
open_fullscreen: default_bindings![
@@ -1170,6 +1338,7 @@ impl RuntimeKeymap {
deny: default_bindings![plain(KeyCode::Char('d'))],
decline: default_bindings![plain(KeyCode::Esc), plain(KeyCode::Char('n'))],
cancel: default_bindings![plain(KeyCode::Char('c'))],
chord_hints: Arc::default(),
},
}
}
@@ -1983,6 +2152,9 @@ fn resolve_new_default_bindings(
fn parse_bindings(spec: &KeybindingsSpec, path: &str) -> Result<Vec<KeyBinding>, String> {
let mut parsed = Vec::new();
for raw in spec.specs() {
if raw.as_str().contains(' ') {
continue;
}
let binding = parse_keybinding(raw.as_str()).ok_or_else(|| {
format!(
"Invalid `{path}` = `{}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`. \
+343
View File
@@ -0,0 +1,343 @@
//! Shared inventory of resolved runtime bindings.
//!
//! `/keymap` lookup and chord-prefix conflict detection must inspect the same
//! actions. Declaring those actions once prevents a new binding from appearing
//! in the picker while being silently omitted from chord validation.
use super::RuntimeKeymap;
use crate::key_hint::KeyBinding;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
/// Config context in which a keymap action is active.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) enum KeymapContext {
Global,
Chat,
Composer,
Editor,
VimNormal,
VimOperator,
VimTextObject,
Pager,
List,
Approval,
}
impl KeymapContext {
pub(crate) const fn config_name(self) -> &'static str {
match self {
Self::Global => "global",
Self::Chat => "chat",
Self::Composer => "composer",
Self::Editor => "editor",
Self::VimNormal => "vim_normal",
Self::VimOperator => "vim_operator",
Self::VimTextObject => "vim_text_object",
Self::Pager => "pager",
Self::List => "list",
Self::Approval => "approval",
}
}
pub(crate) const fn allows_plain_chord_prefix(self) -> bool {
matches!(
self,
Self::VimNormal | Self::VimOperator | Self::VimTextObject
)
}
pub(crate) fn overlaps(self, other: Self) -> bool {
if self == other {
return true;
}
matches!(
(self, other),
(Self::List, Self::Approval)
| (Self::Approval, Self::List)
| (Self::Chat, Self::List)
| (Self::List, Self::Chat)
) || self.is_shared_main() && other.is_main_editor()
|| other.is_shared_main() && self.is_main_editor()
|| self.is_shared_main() && other.is_shared_main()
}
const fn is_shared_main(self) -> bool {
matches!(self, Self::Global | Self::Chat | Self::Composer)
}
const fn is_main_editor(self) -> bool {
matches!(
self,
Self::Editor | Self::VimNormal | Self::VimOperator | Self::VimTextObject
)
}
}
/// Stable identity for one configurable action.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct KeymapActionId {
pub(crate) context: KeymapContext,
pub(crate) action: &'static str,
}
impl KeymapActionId {
pub(crate) fn config_path(self) -> String {
format!("tui.keymap.{}.{}", self.context.config_name(), self.action)
}
}
#[derive(Clone, Copy, Debug)]
pub(super) struct RuntimeActionBinding<'a> {
pub(super) id: KeymapActionId,
pub(super) bindings: &'a [KeyBinding],
}
macro_rules! define_runtime_action_bindings {
($($context:literal => $context_id:ident, $group:ident, $config_group:ident [$($action:ident),+ $(,)?]),+ $(,)?) => {
/// Resolve a config context/action pair to its runtime identity.
pub(crate) fn keymap_action_id(
context: &str,
action: &str,
) -> Option<KeymapActionId> {
match (context, action) {
$(
$(
($context, stringify!($action)) => Some(KeymapActionId {
context: KeymapContext::$context_id,
action: stringify!($action),
}),
)+
)+
_ => None,
}
}
/// Return the root-config slot for one runtime action.
pub(super) fn configured_binding_for_action(
keymap: &TuiKeymap,
action: KeymapActionId,
) -> Option<&Option<KeybindingsSpec>> {
match (action.context.config_name(), action.action) {
$(
$(
($context, stringify!($action)) => {
Some(&keymap.$config_group.$action)
}
)+
)+
_ => None,
}
}
/// Iterate over every stable runtime action identity.
pub(crate) fn keymap_action_ids() -> impl Iterator<Item = KeymapActionId> {
[
$(
$(
KeymapActionId {
context: KeymapContext::$context_id,
action: stringify!($action),
},
)+
)+
]
.into_iter()
}
/// Return resolved bindings for one stable config context and action.
pub(crate) fn bindings_for_action<'a>(
runtime_keymap: &'a RuntimeKeymap,
context: &str,
action: &str,
) -> Option<&'a [KeyBinding]> {
match (context, action) {
$(
$(
($context, stringify!($action)) => {
Some(super::user_bindings(
runtime_keymap.$group.$action.as_slice(),
))
}
)+
)+
_ => None,
}
}
/// Append an internal dispatch binding to one resolved action.
pub(super) fn push_binding_for_action(
runtime_keymap: &mut RuntimeKeymap,
action: KeymapActionId,
binding: KeyBinding,
) -> bool {
match (action.context.config_name(), action.action) {
$(
$(
($context, stringify!($action)) => {
runtime_keymap.$group.$action.push(binding);
true
}
)+
)+
_ => false,
}
}
/// Iterate over the same runtime bindings exposed to the keymap editor.
pub(super) fn runtime_action_bindings(
runtime_keymap: &RuntimeKeymap,
) -> impl Iterator<Item = RuntimeActionBinding<'_>> {
[
$(
$(
RuntimeActionBinding {
id: KeymapActionId {
context: KeymapContext::$context_id,
action: stringify!($action),
},
bindings: super::user_bindings(
runtime_keymap.$group.$action.as_slice(),
),
},
)+
)+
]
.into_iter()
}
};
}
define_runtime_action_bindings! {
"global" => Global, app, global [
open_transcript,
open_external_editor,
copy,
clear_terminal,
toggle_vim_mode,
toggle_fast_mode,
toggle_raw_output,
toggle_side_conversation,
],
"chat" => Chat, chat, chat [
interrupt_turn,
decrease_reasoning_effort,
increase_reasoning_effort,
edit_queued_message,
],
"composer" => Composer, composer, composer [
submit,
queue,
toggle_shortcuts,
history_search_previous,
history_search_next,
],
"editor" => Editor, editor, editor [
insert_newline,
move_left,
move_right,
move_up,
move_down,
move_word_left,
move_word_right,
move_line_start,
move_line_end,
delete_backward,
delete_forward,
delete_backward_word,
delete_forward_word,
kill_line_start,
kill_whole_line,
kill_line_end,
yank,
],
"vim_normal" => VimNormal, vim_normal, vim_normal [
enter_insert,
append_after_cursor,
append_line_end,
insert_line_start,
open_line_below,
open_line_above,
move_left,
move_right,
move_up,
move_down,
move_word_forward,
move_word_backward,
move_word_end,
move_line_start,
move_line_end,
delete_char,
substitute_char,
delete_to_line_end,
change_to_line_end,
yank_line,
paste_after,
start_delete_operator,
start_yank_operator,
start_change_operator,
cancel_operator,
],
"vim_operator" => VimOperator, vim_operator, vim_operator [
delete_line,
yank_line,
motion_left,
motion_right,
motion_up,
motion_down,
motion_word_forward,
motion_word_backward,
motion_word_end,
motion_line_start,
motion_line_end,
select_inner_text_object,
select_around_text_object,
cancel,
],
"vim_text_object" => VimTextObject, vim_text_object, vim_text_object [
word,
big_word,
parentheses,
brackets,
braces,
double_quote,
single_quote,
backtick,
cancel,
],
"pager" => Pager, pager, pager [
scroll_up,
scroll_down,
page_up,
page_down,
half_page_up,
half_page_down,
jump_top,
jump_bottom,
close,
close_transcript,
],
"list" => List, list, list [
move_up,
move_down,
move_left,
move_right,
page_up,
page_down,
jump_top,
jump_bottom,
accept,
cancel,
],
"approval" => Approval, approval, approval [
open_fullscreen,
open_thread,
approve,
approve_for_session,
approve_for_prefix,
deny,
decline,
cancel,
],
}
+483
View File
@@ -0,0 +1,483 @@
//! Bounded, context-aware two-stroke shortcuts for TUI actions.
//!
//! Single-event bindings remain in the ordinary runtime keymaps. A completed
//! chord becomes an internal function-key token appended to the target action,
//! so existing handlers remain the only action dispatch table.
use super::MAIN_RESERVED_BINDINGS;
use super::RuntimeKeymap;
use super::TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS;
use super::bindings::KeymapActionId;
use super::bindings::KeymapContext;
use super::bindings::configured_binding_for_action;
use super::bindings::keymap_action_ids;
use super::bindings::push_binding_for_action;
use super::parse_keybinding;
use super::runtime_action_bindings;
use crate::key_hint::KeyBinding;
use crate::key_hint::ctrl;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use std::time::Duration;
use tokio::time::Instant;
const FIRST_DISPATCH_FUNCTION_KEY: u8 = 128;
const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX;
const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
("cancel", ctrl(KeyCode::Char('c'))),
("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))),
("resume_picker.toggle_expansion", ctrl(KeyCode::Char('e'))),
("resume_picker.toggle_density", ctrl(KeyCode::Char('o'))),
];
/// Time allowed between the first and second strokes of a key chord.
pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1);
/// Compact set of keymap contexts that share one active input path.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct KeymapContextSet(u16);
impl KeymapContextSet {
pub(crate) const fn new(context: KeymapContext) -> Self {
Self(context_bit(context))
}
pub(crate) const fn with(self, context: KeymapContext) -> Self {
Self(self.0 | context_bit(context))
}
pub(crate) const fn contains(self, context: KeymapContext) -> bool {
self.0 & context_bit(context) != 0
}
}
const fn context_bit(context: KeymapContext) -> u16 {
1 << match context {
KeymapContext::Global => 0,
KeymapContext::Chat => 1,
KeymapContext::Composer => 2,
KeymapContext::Editor => 3,
KeymapContext::VimNormal => 4,
KeymapContext::VimOperator => 5,
KeymapContext::VimTextObject => 6,
KeymapContext::Pager => 7,
KeymapContext::List => 8,
KeymapContext::Approval => 9,
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct KeyChord {
pub(crate) prefix: KeyBinding,
pub(crate) completion: KeyBinding,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct RuntimeChordBinding {
pub(crate) action: KeymapActionId,
pub(crate) chord: KeyChord,
spec: String,
}
/// Chord bindings plus configured alternatives in declaration order.
#[derive(Clone, Debug, Default)]
pub(crate) struct RuntimeChordKeymap {
pub(crate) bindings: Vec<RuntimeChordBinding>,
configured_specs: Vec<(KeymapActionId, Vec<String>)>,
}
impl RuntimeChordKeymap {
pub(super) fn from_config(keymap: &TuiKeymap) -> Result<Self, String> {
let mut keymap_chords = Self::default();
for action in keymap_action_ids() {
let Some(configured) = effective_configured_binding(keymap, action) else {
continue;
};
let mut configured_specs = Vec::new();
for spec in configured.specs() {
let raw = spec.as_str();
if configured_specs.iter().any(|configured| configured == raw) {
continue;
}
if let Some((prefix, completion)) = raw.split_once(' ') {
let invalid_binding = || {
format!(
"Invalid `{}` = `{raw}`. Use a single key such as `ctrl-a` \
or a two-stroke chord such as `ctrl-x ctrl-t`.",
action.config_path()
)
};
keymap_chords.bindings.push(RuntimeChordBinding {
action,
chord: KeyChord {
prefix: normalize_chord_binding(
parse_keybinding(prefix).ok_or_else(invalid_binding)?,
),
completion: normalize_chord_binding(
parse_keybinding(completion).ok_or_else(invalid_binding)?,
),
},
spec: raw.to_string(),
});
}
configured_specs.push(raw.to_string());
}
keymap_chords
.configured_specs
.push((action, configured_specs));
}
Ok(keymap_chords)
}
pub(crate) fn configured_specs(&self, action: KeymapActionId) -> Option<&[String]> {
self.configured_specs
.iter()
.find_map(|(configured_action, specs)| {
(*configured_action == action).then_some(specs.as_slice())
})
}
/// Return the user's first configured shortcut without exposing dispatch tokens.
pub(crate) fn primary_hint(
&self,
action: KeymapActionId,
bindings: &[KeyBinding],
) -> Option<crate::key_hint::ShortcutHint> {
if let Some(spec) = self
.configured_specs(action)
.and_then(|specs| specs.first())
{
return if let Some((prefix, completion)) = spec.split_once(' ') {
Some(crate::key_hint::ShortcutHint::Chord {
prefix: parse_keybinding(prefix)?,
completion: parse_keybinding(completion)?,
})
} else {
parse_keybinding(spec).map(crate::key_hint::ShortcutHint::Single)
};
}
super::primary_binding(bindings).map(crate::key_hint::ShortcutHint::Single)
}
}
/// Outcome of routing one physical key through the chord state machine.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum KeyChordMatch {
PassThrough,
Pending(KeyBinding),
Completed(KeyEvent),
Cancelled,
Ignored,
}
#[derive(Clone, Copy, Debug)]
struct PendingChord {
prefix: KeyBinding,
contexts: KeymapContextSet,
started_at: Instant,
}
/// Tracks one pending two-stroke chord without buffering ordinary input.
#[derive(Debug, Default)]
pub(crate) struct KeyChordMatcher {
pending: Option<PendingChord>,
}
impl KeyChordMatcher {
pub(crate) fn is_pending(&self) -> bool {
self.pending.is_some()
}
pub(crate) fn cancel(&mut self) -> bool {
self.pending.take().is_some()
}
pub(crate) fn expire(&mut self, contexts: KeymapContextSet, now: Instant) -> bool {
if self.pending.is_some_and(|pending| {
pending.contexts != contexts
|| now.saturating_duration_since(pending.started_at) >= KEY_CHORD_TIMEOUT
}) {
self.pending = None;
return true;
}
false
}
pub(crate) fn advance(
&mut self,
key_event: KeyEvent,
keymap: &RuntimeChordKeymap,
contexts: KeymapContextSet,
now: Instant,
) -> KeyChordMatch {
if is_dispatch_token_event(key_event) {
return KeyChordMatch::Ignored;
}
self.expire(contexts, now);
if self.pending.is_some() && key_event.kind != KeyEventKind::Press {
return KeyChordMatch::Ignored;
}
if key_event.kind != KeyEventKind::Press {
return KeyChordMatch::PassThrough;
}
if let Some(pending) = self.pending.take() {
if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) {
return KeyChordMatch::Cancelled;
}
if let Some(binding) = keymap.bindings.iter().find(|binding| {
contexts.contains(binding.action.context)
&& binding.chord.prefix == pending.prefix
&& chord_stroke_matches(binding.chord.completion, key_event)
}) {
let Some(dispatch_event) = dispatch_event(binding.action) else {
return KeyChordMatch::Ignored;
};
return KeyChordMatch::Completed(dispatch_event);
}
}
if let Some(prefix) = keymap
.bindings
.iter()
.find(|binding| {
contexts.contains(binding.action.context)
&& chord_stroke_matches(binding.chord.prefix, key_event)
})
.map(|binding| binding.chord.prefix)
{
self.pending = Some(PendingChord {
prefix,
contexts,
started_at: now,
});
return KeyChordMatch::Pending(prefix);
}
KeyChordMatch::PassThrough
}
}
pub(super) fn install_dispatch_bindings(keymap: &mut RuntimeKeymap) -> Result<(), String> {
let actions = keymap
.chords
.bindings
.iter()
.map(|binding| binding.action)
.collect::<std::collections::HashSet<_>>();
for action in actions {
let binding = dispatch_binding(action).ok_or_else(|| {
format!(
"Cannot dispatch `{}`: the keymap action inventory exceeds {} internal tokens.",
action.config_path(),
LAST_DISPATCH_FUNCTION_KEY - FIRST_DISPATCH_FUNCTION_KEY + 1,
)
})?;
if !push_binding_for_action(keymap, action, binding) {
return Err(format!(
"Cannot dispatch unknown keymap action `{}`.",
action.config_path()
));
}
}
Ok(())
}
fn dispatch_binding(action: KeymapActionId) -> Option<KeyBinding> {
let index = keymap_action_ids().position(|candidate| candidate == action)?;
let function_key = FIRST_DISPATCH_FUNCTION_KEY.checked_add(u8::try_from(index).ok()?)?;
Some(KeyBinding::new(
KeyCode::F(function_key),
KeyModifiers::NONE,
))
}
fn dispatch_event(action: KeymapActionId) -> Option<KeyEvent> {
let (code, modifiers) = dispatch_binding(action)?.parts();
Some(KeyEvent::new(code, modifiers))
}
pub(crate) fn is_dispatch_token(binding: KeyBinding) -> bool {
matches!(
binding.parts(),
(
KeyCode::F(FIRST_DISPATCH_FUNCTION_KEY..=LAST_DISPATCH_FUNCTION_KEY),
_
)
)
}
fn is_dispatch_token_event(event: KeyEvent) -> bool {
matches!(
event.code,
KeyCode::F(FIRST_DISPATCH_FUNCTION_KEY..=LAST_DISPATCH_FUNCTION_KEY)
)
}
fn chord_stroke_matches(binding: KeyBinding, key_event: KeyEvent) -> bool {
let ctrl_slash = crate::key_hint::ctrl(KeyCode::Char('/'));
let ctrl_seven = crate::key_hint::ctrl(KeyCode::Char('7'));
binding.is_press(key_event)
|| binding.parts() == ctrl_slash.parts() && ctrl_seven.is_press(key_event)
|| binding.parts() == ctrl_seven.parts() && ctrl_slash.is_press(key_event)
}
fn effective_configured_binding(
keymap: &TuiKeymap,
action: KeymapActionId,
) -> Option<&KeybindingsSpec> {
if let Some(configured) = configured_binding_for_action(keymap, action)?.as_ref() {
return Some(configured);
}
if action.context != KeymapContext::Composer {
return None;
}
match action.action {
"submit" => keymap.global.submit.as_ref(),
"queue" => keymap.global.queue.as_ref(),
"toggle_shortcuts" => keymap.global.toggle_shortcuts.as_ref(),
_ => None,
}
}
pub(crate) fn normalize_chord_binding(binding: KeyBinding) -> KeyBinding {
if binding.parts() == crate::key_hint::ctrl(KeyCode::Char('7')).parts() {
crate::key_hint::ctrl(KeyCode::Char('/'))
} else {
binding
}
}
/// Reject chords that would shadow an active single binding or fixed shortcut.
pub(super) fn validate_chord_conflicts(keymap: &RuntimeKeymap) -> Result<(), String> {
for (index, binding) in keymap.chords.bindings.iter().enumerate() {
validate_binding_shape(binding)?;
validate_reserved_strokes(binding)?;
if let Some(conflict) = runtime_action_bindings(keymap)
.filter(|candidate| binding.action.context.overlaps(candidate.id.context))
.find(|candidate| {
candidate.bindings.iter().any(|single| {
normalize_chord_binding(*single).parts() == binding.chord.prefix.parts()
})
})
{
return Err(format!(
"Ambiguous `{}` = `{}`: its prefix shadows `{}`. \
Unbind or remap the existing shortcut before using it as a chord prefix.",
binding.action.config_path(),
binding.spec,
conflict.id.config_path(),
));
}
for previous in &keymap.chords.bindings[..index] {
if previous.action != binding.action
&& previous.action.context.overlaps(binding.action.context)
&& previous.chord == binding.chord
{
return Err(format!(
"Ambiguous `{}` = `{}`: the same chord is already assigned to `{}`. \
Choose a unique chord and retry.",
binding.action.config_path(),
binding.spec,
previous.action.config_path(),
));
}
}
}
Ok(())
}
fn validate_binding_shape(binding: &RuntimeChordBinding) -> Result<(), String> {
let path = binding.action.config_path();
let (prefix_key, prefix_modifiers) = binding.chord.prefix.parts();
if matches!(prefix_key, KeyCode::Char(_))
&& crate::key_hint::is_altgr(prefix_modifiers)
&& !binding.action.context.allows_plain_chord_prefix()
{
return Err(format!(
"Invalid `{path}` = `{}`: a ctrl-alt character prefix may be AltGr text input on \
Windows. Choose a different chord and retry.",
binding.spec
));
}
if matches!(prefix_key, KeyCode::Char(_))
&& !crate::key_hint::has_ctrl_or_alt(prefix_modifiers)
&& !binding.action.context.allows_plain_chord_prefix()
{
return Err(format!(
"Invalid `{path}` = `{}`: a chord prefix outside Vim must use ctrl, \
alt, or a non-character key so ordinary text input is not intercepted.",
binding.spec
));
}
Ok(())
}
fn validate_reserved_strokes(binding: &RuntimeChordBinding) -> Result<(), String> {
let path = binding.action.config_path();
let strokes = [
binding.chord.prefix.parts(),
binding.chord.completion.parts(),
];
if strokes.contains(&(KeyCode::Esc, KeyModifiers::NONE)) {
return Err(format!(
"Ambiguous `{path}` = `{}`: plain `esc` is reserved for cancelling a pending chord.",
binding.spec
));
}
#[cfg(unix)]
if strokes.contains(&crate::key_hint::ctrl(KeyCode::Char('z')).parts()) {
return Err(format!(
"Ambiguous `{path}` = `{}`: `ctrl-z` is reserved for suspending the terminal on Unix. \
Choose a different chord and retry.",
binding.spec
));
}
let reserved = match binding.action.context {
KeymapContext::Pager => TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
KeymapContext::Global
| KeymapContext::Chat
| KeymapContext::Composer
| KeymapContext::Editor
| KeymapContext::VimNormal
| KeymapContext::VimOperator
| KeymapContext::VimTextObject => MAIN_RESERVED_BINDINGS,
KeymapContext::List => LIST_RESERVED_BINDINGS,
KeymapContext::Approval => &LIST_RESERVED_BINDINGS[..1],
};
if let Some((reserved_action, _)) = reserved.iter().find(|(_, reserved)| {
binding.chord.prefix.parts() == reserved.parts()
|| binding.chord.completion.parts() == reserved.parts()
&& (binding.action.context != KeymapContext::List
|| reserved.parts() == ctrl(KeyCode::Char('c')).parts())
}) {
return Err(format!(
"Ambiguous `{path}` = `{}`: the chord uses the key reserved by \
`{reserved_action}`. Choose a different chord and retry.",
binding.spec
));
}
Ok(())
}
#[cfg(test)]
#[path = "chords_tests.rs"]
mod tests;
+400
View File
@@ -0,0 +1,400 @@
use super::KEY_CHORD_TIMEOUT;
use super::KeyChordMatch;
use super::KeyChordMatcher;
use super::KeymapContextSet;
use super::dispatch_binding;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::RuntimeKeymap;
use crate::keymap::bindings::KeymapContext;
use crate::keymap::bindings::keymap_action_ids;
use crate::keymap::keymap_action_id;
use crate::keymap::user_bindings;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use tokio::time::Instant;
fn binding(spec: &str) -> KeybindingsSpec {
KeybindingsSpec::One(KeybindingSpec(spec.to_string()))
}
fn bindings(specs: &[&str]) -> KeybindingsSpec {
KeybindingsSpec::Many(
specs
.iter()
.map(|spec| KeybindingSpec((*spec).to_string()))
.collect(),
)
}
#[test]
fn rejects_fixed_resume_picker_keys_inside_list_chords() {
for (chord, reserved_action) in [
("ctrl-c home", "cancel"),
("ctrl-t home", "resume_picker.toggle_transcript"),
("ctrl-e home", "resume_picker.toggle_expansion"),
("ctrl-o home", "resume_picker.toggle_density"),
] {
let mut config = TuiKeymap::default();
config.list.jump_top = Some(binding(chord));
let error = RuntimeKeymap::from_config(&config)
.expect_err("fixed picker actions must remain available");
assert!(error.contains(reserved_action), "{error}");
}
}
#[test]
fn rejects_ctrl_c_inside_approval_chords() {
let mut config = TuiKeymap::default();
config.approval.approve = Some(binding("ctrl-x ctrl-c"));
let error =
RuntimeKeymap::from_config(&config).expect_err("Ctrl+C must continue cancelling approvals");
assert!(error.contains("cancel"), "{error}");
}
#[test]
fn resolves_chords_for_actions_in_different_contexts() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(bindings(&["ctrl-t", "ctrl-x ctrl-t"]));
config.composer.submit = Some(binding("ctrl-x enter"));
config.list.jump_top = Some(binding("ctrl-x home"));
let actual = RuntimeKeymap::from_config(&config).expect("valid chord keymap");
assert_eq!(
user_bindings(&actual.app.open_transcript),
vec![key_hint::ctrl(KeyCode::Char('t'))]
);
for (context, action, expected) in [
(
"global",
"open_transcript",
vec!["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()],
),
("composer", "submit", vec!["ctrl-x enter".to_string()]),
("list", "jump_top", vec!["ctrl-x home".to_string()]),
] {
let action = keymap_action_id(context, action).expect("known action");
assert_eq!(
actual.chords.configured_specs(action),
Some(expected.as_slice())
);
}
}
#[test]
fn composer_chord_inherits_global_fallback() {
let mut config = TuiKeymap::default();
config.global.submit = Some(binding("ctrl-x enter"));
let actual = RuntimeKeymap::from_config(&config).expect("valid global fallback chord");
let action = keymap_action_id("composer", "submit").expect("known action");
assert_eq!(
actual.chords.configured_specs(action),
Some(["ctrl-x enter".to_string()].as_slice())
);
}
#[test]
fn allows_shared_prefixes_with_distinct_completions() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("ctrl-x ctrl-t"));
config.global.copy = Some(binding("ctrl-x ctrl-o"));
RuntimeKeymap::from_config(&config).expect("prefix trie can branch on the second stroke");
}
#[test]
fn allows_plain_prefixes_in_vim_and_repeated_chords_in_all_contexts() {
let mut config = TuiKeymap::default();
config.vim_normal.move_line_start = Some(binding("g g"));
config.vim_operator.motion_line_start = Some(binding("g g"));
config.global.open_transcript = Some(binding("ctrl-t ctrl-t"));
RuntimeKeymap::from_config(&config)
.expect("Vim owns printable prefixes and all contexts support repeated chords");
}
#[test]
fn rejects_plain_printable_prefixes_outside_vim() {
let mut config = TuiKeymap::default();
config.composer.submit = Some(binding("x enter"));
let error = RuntimeKeymap::from_config(&config).expect_err("typing must not start a chord");
assert!(error.contains("outside Vim"), "{error}");
assert!(error.contains("ordinary text input"), "{error}");
}
#[test]
fn rejects_prefixes_that_shadow_overlapping_single_bindings() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("ctrl-k ctrl-t"));
let error = RuntimeKeymap::from_config(&config).expect_err("ctrl-k already edits text");
assert!(error.contains("editor.kill_line_end"), "{error}");
assert!(error.contains("ctrl-k ctrl-t"), "{error}");
}
#[test]
fn permits_the_same_chord_in_non_overlapping_contexts() {
let mut config = TuiKeymap::default();
config.editor.move_line_start = Some(binding("ctrl-x home"));
config.list.jump_top = Some(binding("ctrl-x home"));
RuntimeKeymap::from_config(&config)
.expect("editor and list contexts are never active together");
}
#[test]
fn rejects_duplicate_chords_in_overlapping_contexts() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("ctrl-x ctrl-t"));
config.composer.submit = Some(binding("ctrl-x ctrl-t"));
let error =
RuntimeKeymap::from_config(&config).expect_err("one active chord must identify one action");
assert!(error.contains("global.open_transcript"), "{error}");
assert!(error.contains("composer.submit"), "{error}");
}
#[test]
fn rejects_duplicate_chat_and_list_chords_used_by_request_user_input() {
let mut config = TuiKeymap::default();
config.chat.interrupt_turn = Some(binding("ctrl-x home"));
config.list.jump_top = Some(binding("ctrl-x home"));
let error =
RuntimeKeymap::from_config(&config).expect_err("request input activates both contexts");
assert!(error.contains("chat.interrupt_turn"), "{error}");
assert!(error.contains("list.jump_top"), "{error}");
}
#[test]
fn normalizes_ctrl_slash_terminal_aliases() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("ctrl-7 ctrl-t"));
let error = RuntimeKeymap::from_config(&config)
.expect_err("Ctrl-7 must remain the Ctrl-/ side-conversation alias");
assert!(error.contains("global.toggle_side_conversation"), "{error}");
}
#[test]
fn rejects_alias_equivalent_duplicate_chords() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("ctrl-x ctrl-/"));
config.global.copy = Some(binding("ctrl-x ctrl-7"));
config.global.toggle_side_conversation = Some(KeybindingsSpec::Many(Vec::new()));
let error = RuntimeKeymap::from_config(&config).expect_err("aliases must identify one action");
assert!(error.contains("global.open_transcript"), "{error}");
assert!(error.contains("global.copy"), "{error}");
}
#[test]
fn rejects_plain_escape_inside_a_chord() {
let mut config = TuiKeymap::default();
config.list.cancel = Some(binding("ctrl-x esc"));
let error = RuntimeKeymap::from_config(&config).expect_err("Esc cancels pending chords");
assert!(error.contains("reserved for cancelling"), "{error}");
}
#[test]
fn rejects_reserved_interrupt_keys_inside_a_main_chord() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("ctrl-x ctrl-c"));
let error = RuntimeKeymap::from_config(&config).expect_err("ctrl-c must remain available");
assert!(error.contains("fixed.interrupt_or_quit"), "{error}");
}
#[cfg(unix)]
#[test]
fn rejects_unix_suspend_key_in_any_chord_stroke() {
for chord in ["ctrl-z ctrl-t", "ctrl-x ctrl-z"] {
let mut config = TuiKeymap::default();
config.list.jump_top = Some(binding(chord));
let error = RuntimeKeymap::from_config(&config)
.expect_err("the Unix suspend key must not be consumed");
assert!(error.contains("suspending"), "{error}");
}
}
#[cfg(windows)]
#[test]
fn rejects_windows_altgr_prefixes_outside_vim() {
let mut config = TuiKeymap::default();
config.composer.submit = Some(binding("ctrl-alt-x enter"));
let error =
RuntimeKeymap::from_config(&config).expect_err("AltGr may be ordinary Windows text input");
assert!(error.contains("AltGr"), "{error}");
}
#[test]
fn dispatch_tokens_are_unique_and_cover_the_action_inventory() {
let mut tokens = HashSet::new();
let actions = keymap_action_ids().collect::<Vec<_>>();
for action in &actions {
assert!(
tokens.insert(dispatch_binding(*action).expect("action inventory must fit token band")),
"duplicate token for {}",
action.config_path()
);
}
assert_eq!(tokens.len(), actions.len());
}
#[test]
fn physical_dispatch_tokens_do_not_clear_pending_chords() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("ctrl-x ctrl-t"));
let keymap = RuntimeKeymap::from_config(&config).expect("valid chord keymap");
let contexts = KeymapContextSet::new(KeymapContext::Global);
let now = Instant::now();
let mut matcher = KeyChordMatcher::default();
assert!(matches!(
matcher.advance(
key_event(key_hint::ctrl(KeyCode::Char('x'))),
&keymap.chords,
contexts,
now,
),
KeyChordMatch::Pending(_)
));
let action = keymap_action_id("global", "open_transcript").expect("known keymap action");
let token = dispatch_binding(action).expect("internal dispatch token");
assert_eq!(
matcher.advance(key_event(token), &keymap.chords, contexts, now),
KeyChordMatch::Ignored
);
assert!(matcher.is_pending());
assert!(matches!(
matcher.advance(
key_event(key_hint::ctrl(KeyCode::Char('t'))),
&keymap.chords,
contexts,
now,
),
KeyChordMatch::Completed(_)
));
}
#[test]
fn matching_chords_dispatches_the_active_context_action() {
let mut config = TuiKeymap::default();
config.global.open_transcript = Some(binding("ctrl-t ctrl-t"));
config.editor.move_line_start = Some(binding("ctrl-x home"));
config.list.jump_top = Some(binding("ctrl-x home"));
let runtime = RuntimeKeymap::from_config(&config).expect("non-overlapping chord contexts");
let now = Instant::now();
for (context, prefix, completion, target, other) in [
(
KeymapContext::Global,
key_event(key_hint::ctrl(KeyCode::Char('t'))),
key_event(key_hint::ctrl(KeyCode::Char('t'))),
runtime.app.open_transcript.as_slice(),
runtime.app.copy.as_slice(),
),
(
KeymapContext::Editor,
key_event(key_hint::ctrl(KeyCode::Char('x'))),
KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE),
runtime.editor.move_line_start.as_slice(),
runtime.list.jump_top.as_slice(),
),
(
KeymapContext::List,
key_event(key_hint::ctrl(KeyCode::Char('x'))),
KeyEvent::new(KeyCode::Home, crossterm::event::KeyModifiers::NONE),
runtime.list.jump_top.as_slice(),
runtime.editor.move_line_start.as_slice(),
),
] {
let mut matcher = KeyChordMatcher::default();
assert!(matches!(
matcher.advance(prefix, &runtime.chords, KeymapContextSet::new(context), now,),
KeyChordMatch::Pending(_)
));
let KeyChordMatch::Completed(dispatch_event) = matcher.advance(
completion,
&runtime.chords,
KeymapContextSet::new(context),
now,
) else {
panic!("second stroke must complete the active context chord");
};
assert!(target.is_pressed(dispatch_event));
assert!(!other.is_pressed(dispatch_event));
}
}
#[test]
fn matcher_cancels_on_escape_context_change_and_timeout() {
let mut config = TuiKeymap::default();
config.list.jump_top = Some(binding("ctrl-x ctrl-t"));
let runtime = RuntimeKeymap::from_config(&config).expect("valid list chord");
let list = KeymapContextSet::new(KeymapContext::List);
let pager = KeymapContextSet::new(KeymapContext::Pager);
let prefix = key_event(key_hint::ctrl(KeyCode::Char('x')));
let now = Instant::now();
let mut matcher = KeyChordMatcher::default();
assert!(matches!(
matcher.advance(prefix, &runtime.chords, list, now),
KeyChordMatch::Pending(_)
));
assert_eq!(
matcher.advance(
KeyEvent::new(KeyCode::Esc, crossterm::event::KeyModifiers::NONE),
&runtime.chords,
list,
now,
),
KeyChordMatch::Cancelled
);
assert!(matches!(
matcher.advance(prefix, &runtime.chords, list, now),
KeyChordMatch::Pending(_)
));
assert!(matcher.expire(pager, now));
assert!(matches!(
matcher.advance(prefix, &runtime.chords, list, now),
KeyChordMatch::Pending(_)
));
assert!(matcher.expire(list, now + KEY_CHORD_TIMEOUT));
}
fn key_event(binding: crate::key_hint::KeyBinding) -> KeyEvent {
let (code, modifiers) = binding.parts();
KeyEvent::new(code, modifiers)
}
+158 -170
View File
@@ -8,20 +8,22 @@
//! user.
//!
//! The flow is intentionally split into three steps: choose an action, choose
//! whether to replace/add/remove a binding, then capture exactly one terminal
//! key event. Validation happens after capture by reusing runtime keymap
//! resolution, so conflict rules stay centralized in `keymap.rs` instead of
//! being duplicated in the UI.
//! whether to replace/add/remove a binding, then capture either one terminal
//! key event or a bounded two-stroke key chord. Validation happens after
//! capture by reusing runtime keymap resolution, so conflict rules stay
//! centralized in `keymap.rs` instead of being duplicated in the UI.
//!
//! This module does not persist config files directly. It emits app events with
//! the edited config so the app layer can decide how to save, reload, and
//! surface errors.
mod actions;
mod capture;
mod debug;
mod picker;
pub(crate) use actions::KeymapActionFilter;
use capture::KeymapCaptureView;
pub(crate) use debug::build_keymap_debug_view;
pub(crate) use picker::KEYMAP_PICKER_VIEW_ID;
#[cfg(test)]
@@ -37,20 +39,14 @@ use codex_config::types::MAX_FUNCTION_KEY;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event::KeymapCaptureMode;
use crate::app_event::KeymapEditIntent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::ColumnWidthMode;
use crate::bottom_pane::SelectionDescriptionLayout;
use crate::bottom_pane::SelectionItem;
@@ -58,13 +54,13 @@ use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::key_hint::KeyBinding;
use crate::keymap::RuntimeKeymap;
use crate::keymap::bindings_for_action;
use crate::keymap::keymap_action_id;
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 actions::format_action_binding_summary;
#[cfg(test)]
use debug::KeymapDebugView;
@@ -105,12 +101,14 @@ fn open_capture_action(
context: String,
action: String,
intent: KeymapEditIntent,
capture_mode: KeymapCaptureMode,
) -> Box<dyn Fn(&AppEventSender) + Send + Sync> {
Box::new(move |tx| {
tx.send(AppEvent::OpenKeymapCapture {
context: context.clone(),
action: action.clone(),
intent: intent.clone(),
capture_mode,
});
})
}
@@ -122,6 +120,7 @@ fn action_menu_item(
context: &str,
action: &str,
intent: KeymapEditIntent,
capture_mode: KeymapCaptureMode,
) -> SelectionItem {
SelectionItem {
name: name.to_string(),
@@ -131,6 +130,7 @@ fn action_menu_item(
context.to_string(),
action.to_string(),
intent,
capture_mode,
)],
..Default::default()
}
@@ -208,6 +208,7 @@ pub(crate) fn build_keymap_action_menu_params(
&context,
&action,
KeymapEditIntent::ReplaceAll,
KeymapCaptureMode::SingleKey,
));
}
1 => {
@@ -218,6 +219,7 @@ pub(crate) fn build_keymap_action_menu_params(
&context,
&action,
KeymapEditIntent::ReplaceAll,
KeymapCaptureMode::SingleKey,
));
items.push(action_menu_item(
"Add alternate binding",
@@ -226,6 +228,7 @@ pub(crate) fn build_keymap_action_menu_params(
&context,
&action,
KeymapEditIntent::AddAlternate,
KeymapCaptureMode::SingleKey,
));
}
_ => {
@@ -252,6 +255,7 @@ pub(crate) fn build_keymap_action_menu_params(
&context,
&action,
KeymapEditIntent::ReplaceAll,
KeymapCaptureMode::SingleKey,
));
items.push(action_menu_item(
"Add alternate binding",
@@ -260,9 +264,40 @@ pub(crate) fn build_keymap_action_menu_params(
&context,
&action,
KeymapEditIntent::AddAlternate,
KeymapCaptureMode::SingleKey,
));
}
}
if active_binding_count == 0 {
items.push(action_menu_item(
"Set key chord",
"Capture two consecutive keys for this action.",
"Capture a two-stroke key chord.".to_string(),
&context,
&action,
KeymapEditIntent::ReplaceAll,
KeymapCaptureMode::Chord,
));
} else {
items.push(action_menu_item(
"Replace with key chord",
"Replace current bindings with a two-stroke key chord.",
format!("Capture two keys and replace `{current_binding}`."),
&context,
&action,
KeymapEditIntent::ReplaceAll,
KeymapCaptureMode::Chord,
));
items.push(action_menu_item(
"Add alternate key chord",
"Keep current bindings and add a two-stroke key chord.",
format!("Capture two keys and keep `{current_binding}`."),
&context,
&action,
KeymapEditIntent::AddAlternate,
KeymapCaptureMode::Chord,
));
}
items.push(SelectionItem {
name: "Remove custom binding".to_string(),
description: custom_binding.then(|| "Restore the default keymap binding.".to_string()),
@@ -321,26 +356,39 @@ pub(crate) fn build_keymap_replace_binding_menu_params(
let items = bindings
.into_iter()
.map(|binding| {
let capture_context = context.clone();
let capture_action = action.clone();
let old_key = binding.clone();
SelectionItem {
name: binding.clone(),
description: Some("Replace this binding.".to_string()),
selected_description: Some(format!("Capture a new key to replace `{binding}`.")),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenKeymapCapture {
context: capture_context.clone(),
action: capture_action.clone(),
intent: KeymapEditIntent::ReplaceOne {
old_key: old_key.clone(),
.flat_map(|binding| {
[
SelectionItem {
name: binding.clone(),
description: Some("Replace this binding.".to_string()),
selected_description: Some(format!(
"Capture a new key to replace `{binding}`."
)),
actions: vec![open_capture_action(
context.clone(),
action.clone(),
KeymapEditIntent::ReplaceOne {
old_key: binding.clone(),
},
});
})],
dismiss_on_select: true,
..Default::default()
}
KeymapCaptureMode::SingleKey,
)],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: format!("{binding} (key chord)"),
description: Some("Replace this binding with a key chord.".to_string()),
selected_description: Some(format!("Capture two keys to replace `{binding}`.")),
actions: vec![open_capture_action(
context.clone(),
action.clone(),
KeymapEditIntent::ReplaceOne { old_key: binding },
KeymapCaptureMode::Chord,
)],
dismiss_on_select: true,
..Default::default()
},
]
})
.collect();
@@ -361,9 +409,11 @@ pub(crate) fn build_keymap_conflict_params(
intent: KeymapEditIntent,
error: String,
) -> SelectionViewParams {
let retry_context = context.clone();
let retry_action = action.clone();
let retry_intent = intent;
let capture_mode = if key.contains(' ') {
KeymapCaptureMode::Chord
} else {
KeymapCaptureMode::SingleKey
};
SelectionViewParams {
title: Some("Shortcut Conflict".to_string()),
subtitle: Some(format!("{context}.{action} cannot use `{key}`.")),
@@ -373,13 +423,7 @@ pub(crate) fn build_keymap_conflict_params(
SelectionItem {
name: "Pick another key".to_string(),
description: Some("Return to key capture for this action.".to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenKeymapCapture {
context: retry_context.clone(),
action: retry_action.clone(),
intent: retry_intent.clone(),
});
})],
actions: vec![open_capture_action(context, action, intent, capture_mode)],
dismiss_on_select: true,
..Default::default()
},
@@ -405,17 +449,17 @@ pub(crate) fn build_keymap_capture_view(
context: String,
action: String,
intent: KeymapEditIntent,
capture_mode: KeymapCaptureMode,
runtime_keymap: &RuntimeKeymap,
app_event_tx: AppEventSender,
) -> KeymapCaptureView {
let current_binding = format_binding_summary(
bindings_for_action(runtime_keymap, &context, &action).unwrap_or(&[]),
);
let current_binding = format_action_binding_summary(runtime_keymap, &context, &action);
let label = action_label(&action);
KeymapCaptureView::new(
context,
action,
intent,
capture_mode,
label,
current_binding,
app_event_tx,
@@ -539,6 +583,12 @@ pub(crate) fn active_binding_specs(
context: &str,
action: &str,
) -> Result<Vec<String>, String> {
if let Some(action_id) = keymap_action_id(context, action)
&& let Some(specs) = runtime_keymap.chords.configured_specs(action_id)
{
return Ok(specs.to_vec());
}
let bindings = bindings_for_action(runtime_keymap, context, action).ok_or_else(|| {
format!("Unknown keymap action `{context}.{action}`. Reopen /keymap and choose an action.")
})?;
@@ -583,125 +633,6 @@ fn has_custom_binding(keymap: &TuiKeymap, context: &str, action: &str) -> Result
Ok(slot.is_some())
}
/// Bottom-pane view that captures a single key event for a pending `/keymap` edit.
///
/// The view is deliberately transient: it renders instructions, accepts one
/// keypress, and emits the captured key to the app layer. It does not mutate
/// config itself, because mutation needs the latest runtime keymap to detect
/// conflicts and stale selections.
pub(crate) struct KeymapCaptureView {
context: String,
action: String,
intent: KeymapEditIntent,
label: String,
current_binding: String,
app_event_tx: AppEventSender,
complete: bool,
error_message: Option<String>,
}
impl KeymapCaptureView {
fn new(
context: String,
action: String,
intent: KeymapEditIntent,
label: String,
current_binding: String,
app_event_tx: AppEventSender,
) -> Self {
Self {
context,
action,
intent,
label,
current_binding,
app_event_tx,
complete: false,
error_message: None,
}
}
fn lines(&self, width: u16) -> Vec<Line<'static>> {
let wrap_width = usize::from(width.max(1));
let mut lines = vec![
Line::from("Remap Shortcut".bold()),
Line::from(vec![
"Action: ".dim(),
self.label.clone().into(),
" ".into(),
format!("{}.{}", self.context, self.action).dim(),
]),
Line::from(vec!["Current: ".dim(), self.current_binding.clone().cyan()]),
Line::from("Press the new key now. Esc cancels.".dim()),
];
if let Some(error) = &self.error_message {
lines.push(Line::from(""));
let options = textwrap::Options::new(wrap_width)
.initial_indent("Error: ")
.subsequent_indent(" ");
lines.extend(
textwrap::wrap(error, options)
.into_iter()
.map(|line| Line::from(line.into_owned().red())),
);
}
lines
}
}
impl Renderable for KeymapCaptureView {
fn render(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new(self.lines(area.width)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.lines(width).len() as u16
}
}
impl BottomPaneView for KeymapCaptureView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if key_event.code == KeyCode::Esc {
self.complete = true;
return;
}
match key_event_to_config_key_spec(key_event) {
Ok(key) => {
self.app_event_tx.send(AppEvent::KeymapCaptured {
context: self.context.clone(),
action: self.action.clone(),
key,
intent: self.intent.clone(),
});
self.complete = true;
}
Err(error) => {
self.error_message = Some(error);
}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
}
fn key_event_to_config_key_spec(key_event: KeyEvent) -> Result<String, String> {
binding_to_config_key_spec(KeyBinding::from_event(key_event))
}
@@ -792,12 +723,15 @@ mod tests {
use super::*;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::ListSelectionView;
use crate::bottom_pane::SelectionTab;
use crate::render::renderable::Renderable;
use crate::tui::FrameRequester;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
@@ -1225,6 +1159,45 @@ mod tests {
assert_snapshot!("keymap_picker_custom", render_picker(params, /*width*/ 120));
}
#[test]
fn picker_global_chord_render_snapshot() {
let keymap = keymap_with_bindings(
&TuiKeymap::default(),
"global",
"open_transcript",
&["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()],
)
.expect("global chord binding");
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
let params = build_keymap_picker_params(&runtime, &keymap);
assert_snapshot!(
"keymap_picker_global_chord",
render_picker(params, /*width*/ 120)
);
}
#[test]
fn configured_global_chord_alternatives_remain_in_order() {
let keymap = keymap_with_bindings(
&TuiKeymap::default(),
"global",
"open_transcript",
&["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()],
)
.expect("global chord bindings");
let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
assert_eq!(
active_binding_specs(&runtime, "global", "open_transcript"),
Ok(vec!["ctrl-t".to_string(), "ctrl-x ctrl-t".to_string()])
);
assert_eq!(
format_action_binding_summary(&runtime, "global", "open_transcript"),
"ctrl-t, ctrl-x ctrl-t"
);
}
#[test]
fn picker_narrow_uses_compact_tabs() {
let runtime = RuntimeKeymap::defaults();
@@ -1365,6 +1338,7 @@ mod tests {
"composer".to_string(),
"submit".to_string(),
KeymapEditIntent::ReplaceAll,
KeymapCaptureMode::SingleKey,
"Submit".to_string(),
"enter".to_string(),
app_event_sender(),
@@ -1454,15 +1428,22 @@ mod tests {
context,
action,
intent,
capture_mode,
} = rx.try_recv().expect("open capture event")
else {
panic!("expected OpenKeymapCapture event");
};
assert_eq!(intent, KeymapEditIntent::ReplaceAll);
assert_eq!(capture_mode, KeymapCaptureMode::SingleKey);
assert_eq!(pane.active_view_id(), Some(KEYMAP_ACTION_MENU_VIEW_ID));
pane.show_view(Box::new(build_keymap_capture_view(
context, action, intent, &runtime, tx,
context,
action,
intent,
capture_mode,
&runtime,
tx,
)));
pane.handle_key_event(KeyEvent::new(KeyCode::Char('K'), KeyModifiers::CONTROL));
@@ -1519,15 +1500,22 @@ mod tests {
.unwrap();
let runtime = RuntimeKeymap::from_config(&keymap).unwrap();
pane.show_selection_view(build_keymap_picker_params(&runtime, &keymap));
pane.show_selection_view(build_keymap_action_menu_params(
let action_menu = build_keymap_action_menu_params(
"composer".to_string(),
"submit".to_string(),
&runtime,
&keymap,
));
);
let remove_idx = action_menu
.items
.iter()
.position(|item| item.name == "Remove custom binding")
.expect("remove custom binding menu item");
pane.show_selection_view(action_menu);
pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
for _ in 0..remove_idx {
pane.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
}
pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let AppEvent::KeymapCleared { context, action } =
+20 -139
View File
@@ -5,11 +5,9 @@
//! context label, stable action name, and short description used by the picker
//! and action menu.
//!
//! The accessors below deliberately mirror the descriptor table for both the
//! editable root config and the resolved runtime keymap. Keeping those matches
//! in one module makes it easier to audit a new action: if it appears in the
//! catalog, it must also be readable from runtime state and writable in
//! `TuiKeymap`.
//! Root-config accessors mirror the descriptor table, while runtime lookups
//! reuse the inventory owned by [`crate::keymap`]. A catalog action must remain
//! both writable in `TuiKeymap` and readable from the shared runtime inventory.
use std::collections::BTreeSet;
@@ -17,8 +15,8 @@ use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyEvent;
use crate::key_hint::KeyBinding;
use crate::keymap::RuntimeKeymap;
use crate::keymap::bindings_for_action;
#[derive(Clone, Copy, Debug)]
pub(super) struct KeymapActionDescriptor {
@@ -344,142 +342,25 @@ pub(super) fn binding_slot<'a>(
}
}
#[rustfmt::skip]
/// Return the resolved runtime bindings for one catalog action.
///
/// This reads from [`RuntimeKeymap`] rather than root config so UI labels show
/// the actual active binding after defaults, global fallback, explicit
/// unbinding, and duplicate-key validation have already been applied.
pub(super) fn bindings_for_action<'a>(
runtime_keymap: &'a RuntimeKeymap,
context: &str,
action: &str,
) -> Option<&'a [KeyBinding]> {
match (context, action) {
("global", "open_transcript") => Some(runtime_keymap.app.open_transcript.as_slice()),
("global", "open_external_editor") => Some(runtime_keymap.app.open_external_editor.as_slice()),
("global", "copy") => Some(runtime_keymap.app.copy.as_slice()),
("global", "clear_terminal") => Some(runtime_keymap.app.clear_terminal.as_slice()),
("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()),
("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()),
("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()),
("global", "toggle_side_conversation") => Some(runtime_keymap.app.toggle_side_conversation.as_slice()),
("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()),
("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()),
("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()),
("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()),
("composer", "submit") => Some(runtime_keymap.composer.submit.as_slice()),
("composer", "queue") => Some(runtime_keymap.composer.queue.as_slice()),
("composer", "toggle_shortcuts") => Some(runtime_keymap.composer.toggle_shortcuts.as_slice()),
("composer", "history_search_previous") => Some(runtime_keymap.composer.history_search_previous.as_slice()),
("composer", "history_search_next") => Some(runtime_keymap.composer.history_search_next.as_slice()),
("editor", "insert_newline") => Some(runtime_keymap.editor.insert_newline.as_slice()),
("editor", "move_left") => Some(runtime_keymap.editor.move_left.as_slice()),
("editor", "move_right") => Some(runtime_keymap.editor.move_right.as_slice()),
("editor", "move_up") => Some(runtime_keymap.editor.move_up.as_slice()),
("editor", "move_down") => Some(runtime_keymap.editor.move_down.as_slice()),
("editor", "move_word_left") => Some(runtime_keymap.editor.move_word_left.as_slice()),
("editor", "move_word_right") => Some(runtime_keymap.editor.move_word_right.as_slice()),
("editor", "move_line_start") => Some(runtime_keymap.editor.move_line_start.as_slice()),
("editor", "move_line_end") => Some(runtime_keymap.editor.move_line_end.as_slice()),
("editor", "delete_backward") => Some(runtime_keymap.editor.delete_backward.as_slice()),
("editor", "delete_forward") => Some(runtime_keymap.editor.delete_forward.as_slice()),
("editor", "delete_backward_word") => Some(runtime_keymap.editor.delete_backward_word.as_slice()),
("editor", "delete_forward_word") => Some(runtime_keymap.editor.delete_forward_word.as_slice()),
("editor", "kill_line_start") => Some(runtime_keymap.editor.kill_line_start.as_slice()),
("editor", "kill_whole_line") => Some(runtime_keymap.editor.kill_whole_line.as_slice()),
("editor", "kill_line_end") => Some(runtime_keymap.editor.kill_line_end.as_slice()),
("editor", "yank") => Some(runtime_keymap.editor.yank.as_slice()),
("vim_normal", "enter_insert") => Some(runtime_keymap.vim_normal.enter_insert.as_slice()),
("vim_normal", "append_after_cursor") => Some(runtime_keymap.vim_normal.append_after_cursor.as_slice()),
("vim_normal", "append_line_end") => Some(runtime_keymap.vim_normal.append_line_end.as_slice()),
("vim_normal", "insert_line_start") => Some(runtime_keymap.vim_normal.insert_line_start.as_slice()),
("vim_normal", "open_line_below") => Some(runtime_keymap.vim_normal.open_line_below.as_slice()),
("vim_normal", "open_line_above") => Some(runtime_keymap.vim_normal.open_line_above.as_slice()),
("vim_normal", "move_left") => Some(runtime_keymap.vim_normal.move_left.as_slice()),
("vim_normal", "move_right") => Some(runtime_keymap.vim_normal.move_right.as_slice()),
("vim_normal", "move_up") => Some(runtime_keymap.vim_normal.move_up.as_slice()),
("vim_normal", "move_down") => Some(runtime_keymap.vim_normal.move_down.as_slice()),
("vim_normal", "move_word_forward") => Some(runtime_keymap.vim_normal.move_word_forward.as_slice()),
("vim_normal", "move_word_backward") => Some(runtime_keymap.vim_normal.move_word_backward.as_slice()),
("vim_normal", "move_word_end") => Some(runtime_keymap.vim_normal.move_word_end.as_slice()),
("vim_normal", "move_line_start") => Some(runtime_keymap.vim_normal.move_line_start.as_slice()),
("vim_normal", "move_line_end") => Some(runtime_keymap.vim_normal.move_line_end.as_slice()),
("vim_normal", "delete_char") => Some(runtime_keymap.vim_normal.delete_char.as_slice()),
("vim_normal", "substitute_char") => Some(runtime_keymap.vim_normal.substitute_char.as_slice()),
("vim_normal", "delete_to_line_end") => Some(runtime_keymap.vim_normal.delete_to_line_end.as_slice()),
("vim_normal", "change_to_line_end") => Some(runtime_keymap.vim_normal.change_to_line_end.as_slice()),
("vim_normal", "yank_line") => Some(runtime_keymap.vim_normal.yank_line.as_slice()),
("vim_normal", "paste_after") => Some(runtime_keymap.vim_normal.paste_after.as_slice()),
("vim_normal", "start_delete_operator") => Some(runtime_keymap.vim_normal.start_delete_operator.as_slice()),
("vim_normal", "start_yank_operator") => Some(runtime_keymap.vim_normal.start_yank_operator.as_slice()),
("vim_normal", "start_change_operator") => Some(runtime_keymap.vim_normal.start_change_operator.as_slice()),
("vim_normal", "cancel_operator") => Some(runtime_keymap.vim_normal.cancel_operator.as_slice()),
("vim_operator", "delete_line") => Some(runtime_keymap.vim_operator.delete_line.as_slice()),
("vim_operator", "yank_line") => Some(runtime_keymap.vim_operator.yank_line.as_slice()),
("vim_operator", "motion_left") => Some(runtime_keymap.vim_operator.motion_left.as_slice()),
("vim_operator", "motion_right") => Some(runtime_keymap.vim_operator.motion_right.as_slice()),
("vim_operator", "motion_up") => Some(runtime_keymap.vim_operator.motion_up.as_slice()),
("vim_operator", "motion_down") => Some(runtime_keymap.vim_operator.motion_down.as_slice()),
("vim_operator", "motion_word_forward") => Some(runtime_keymap.vim_operator.motion_word_forward.as_slice()),
("vim_operator", "motion_word_backward") => Some(runtime_keymap.vim_operator.motion_word_backward.as_slice()),
("vim_operator", "motion_word_end") => Some(runtime_keymap.vim_operator.motion_word_end.as_slice()),
("vim_operator", "motion_line_start") => Some(runtime_keymap.vim_operator.motion_line_start.as_slice()),
("vim_operator", "motion_line_end") => Some(runtime_keymap.vim_operator.motion_line_end.as_slice()),
("vim_operator", "select_inner_text_object") => Some(runtime_keymap.vim_operator.select_inner_text_object.as_slice()),
("vim_operator", "select_around_text_object") => Some(runtime_keymap.vim_operator.select_around_text_object.as_slice()),
("vim_operator", "cancel") => Some(runtime_keymap.vim_operator.cancel.as_slice()),
("vim_text_object", "word") => Some(runtime_keymap.vim_text_object.word.as_slice()),
("vim_text_object", "big_word") => Some(runtime_keymap.vim_text_object.big_word.as_slice()),
("vim_text_object", "parentheses") => Some(runtime_keymap.vim_text_object.parentheses.as_slice()),
("vim_text_object", "brackets") => Some(runtime_keymap.vim_text_object.brackets.as_slice()),
("vim_text_object", "braces") => Some(runtime_keymap.vim_text_object.braces.as_slice()),
("vim_text_object", "double_quote") => Some(runtime_keymap.vim_text_object.double_quote.as_slice()),
("vim_text_object", "single_quote") => Some(runtime_keymap.vim_text_object.single_quote.as_slice()),
("vim_text_object", "backtick") => Some(runtime_keymap.vim_text_object.backtick.as_slice()),
("vim_text_object", "cancel") => Some(runtime_keymap.vim_text_object.cancel.as_slice()),
("pager", "scroll_up") => Some(runtime_keymap.pager.scroll_up.as_slice()),
("pager", "scroll_down") => Some(runtime_keymap.pager.scroll_down.as_slice()),
("pager", "page_up") => Some(runtime_keymap.pager.page_up.as_slice()),
("pager", "page_down") => Some(runtime_keymap.pager.page_down.as_slice()),
("pager", "half_page_up") => Some(runtime_keymap.pager.half_page_up.as_slice()),
("pager", "half_page_down") => Some(runtime_keymap.pager.half_page_down.as_slice()),
("pager", "jump_top") => Some(runtime_keymap.pager.jump_top.as_slice()),
("pager", "jump_bottom") => Some(runtime_keymap.pager.jump_bottom.as_slice()),
("pager", "close") => Some(runtime_keymap.pager.close.as_slice()),
("pager", "close_transcript") => Some(runtime_keymap.pager.close_transcript.as_slice()),
("list", "move_up") => Some(runtime_keymap.list.move_up.as_slice()),
("list", "move_down") => Some(runtime_keymap.list.move_down.as_slice()),
("list", "move_left") => Some(runtime_keymap.list.move_left.as_slice()),
("list", "move_right") => Some(runtime_keymap.list.move_right.as_slice()),
("list", "page_up") => Some(runtime_keymap.list.page_up.as_slice()),
("list", "page_down") => Some(runtime_keymap.list.page_down.as_slice()),
("list", "jump_top") => Some(runtime_keymap.list.jump_top.as_slice()),
("list", "jump_bottom") => Some(runtime_keymap.list.jump_bottom.as_slice()),
("list", "accept") => Some(runtime_keymap.list.accept.as_slice()),
("list", "cancel") => Some(runtime_keymap.list.cancel.as_slice()),
("approval", "open_fullscreen") => Some(runtime_keymap.approval.open_fullscreen.as_slice()),
("approval", "open_thread") => Some(runtime_keymap.approval.open_thread.as_slice()),
("approval", "approve") => Some(runtime_keymap.approval.approve.as_slice()),
("approval", "approve_for_session") => Some(runtime_keymap.approval.approve_for_session.as_slice()),
("approval", "approve_for_prefix") => Some(runtime_keymap.approval.approve_for_prefix.as_slice()),
("approval", "deny") => Some(runtime_keymap.approval.deny.as_slice()),
("approval", "decline") => Some(runtime_keymap.approval.decline.as_slice()),
("approval", "cancel") => Some(runtime_keymap.approval.cancel.as_slice()),
_ => None,
}
}
/// Format a resolved binding list for compact menu display.
/// Format an action's active single-key and chord alternatives in config order.
///
/// Duplicate runtime variants that normalize to the same config spec are shown
/// once so compatibility defaults, such as alternate SHIFT reporting forms, do
/// not look like separate user choices.
pub(super) fn format_binding_summary(bindings: &[KeyBinding]) -> String {
/// once so compatibility defaults do not appear as separate user choices.
pub(super) fn format_action_binding_summary(
runtime_keymap: &RuntimeKeymap,
context: &str,
action: &str,
) -> String {
let specs = super::active_binding_specs(runtime_keymap, context, action).unwrap_or_else(|_| {
bindings_for_action(runtime_keymap, context, action)
.unwrap_or_default()
.iter()
.filter_map(|binding| super::binding_to_config_key_spec(*binding).ok())
.collect()
});
let mut seen = BTreeSet::new();
let specs = bindings
.iter()
.filter_map(|binding| super::binding_to_config_key_spec(*binding).ok())
let specs = specs
.into_iter()
.filter(|spec| seen.insert(spec.clone()))
.collect::<Vec<_>>();
if specs.is_empty() {
+174
View File
@@ -0,0 +1,174 @@
//! Transient single-key and chord capture for the `/keymap` shortcut editor.
use super::key_event_to_config_key_spec;
use crate::app_event::AppEvent;
use crate::app_event::KeymapCaptureMode;
use crate::app_event::KeymapEditIntent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::render::renderable::Renderable;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
/// Bottom-pane view that captures one key or a two-stroke `/keymap` chord.
///
/// The view is deliberately transient: it renders instructions, captures the
/// requested number of strokes, and emits their canonical config representation
/// to the app layer. It does not mutate config itself, because mutation needs
/// the latest runtime keymap to detect conflicts and stale selections.
pub(crate) struct KeymapCaptureView {
context: String,
action: String,
intent: KeymapEditIntent,
capture_mode: KeymapCaptureMode,
first_stroke: Option<String>,
label: String,
current_binding: String,
app_event_tx: AppEventSender,
complete: bool,
error_message: Option<String>,
}
impl KeymapCaptureView {
pub(super) fn new(
context: String,
action: String,
intent: KeymapEditIntent,
capture_mode: KeymapCaptureMode,
label: String,
current_binding: String,
app_event_tx: AppEventSender,
) -> Self {
Self {
context,
action,
intent,
capture_mode,
first_stroke: None,
label,
current_binding,
app_event_tx,
complete: false,
error_message: None,
}
}
fn lines(&self, width: u16) -> Vec<Line<'static>> {
let wrap_width = usize::from(width.max(1));
let mut lines = vec![
Line::from("Remap Shortcut".bold()),
Line::from(vec![
"Action: ".dim(),
self.label.clone().into(),
" ".into(),
format!("{}.{}", self.context, self.action).dim(),
]),
Line::from(vec!["Current: ".dim(), self.current_binding.clone().cyan()]),
];
let instructions = match (self.capture_mode, self.first_stroke.as_deref()) {
(KeymapCaptureMode::SingleKey, _) => "Press the new key now. Esc cancels.".to_string(),
(KeymapCaptureMode::Chord, None) => {
"Press the first key, then the second. Esc cancels.".to_string()
}
(KeymapCaptureMode::Chord, Some(first)) => {
format!("First key: {first}. Press the second key. Esc cancels.")
}
};
lines.extend(
textwrap::wrap(&instructions, wrap_width)
.into_iter()
.map(|line| Line::from(line.into_owned().dim())),
);
if let Some(error) = &self.error_message {
lines.push(Line::from(""));
let options = textwrap::Options::new(wrap_width)
.initial_indent("Error: ")
.subsequent_indent(" ");
lines.extend(
textwrap::wrap(error, options)
.into_iter()
.map(|line| Line::from(line.into_owned().red())),
);
}
lines
}
}
impl Renderable for KeymapCaptureView {
fn render(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new(self.lines(area.width)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.lines(width).len() as u16
}
}
impl BottomPaneView for KeymapCaptureView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release
|| (self.capture_mode == KeymapCaptureMode::Chord
&& key_event.kind != KeyEventKind::Press)
{
return;
}
if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) {
self.complete = true;
return;
}
match key_event_to_config_key_spec(key_event) {
Ok(key) => {
self.error_message = None;
let key = if self.capture_mode == KeymapCaptureMode::Chord {
let Some(first_stroke) = self.first_stroke.take() else {
self.first_stroke = Some(key);
return;
};
format!("{first_stroke} {key}")
} else {
key
};
self.app_event_tx.send(AppEvent::KeymapCaptured {
context: self.context.clone(),
action: self.action.clone(),
key,
intent: self.intent.clone(),
});
self.complete = true;
}
Err(error) => {
self.error_message = Some(error);
}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
}
}
#[cfg(test)]
#[path = "capture_tests.rs"]
mod tests;
@@ -0,0 +1,242 @@
use super::super::KeymapEditOutcome;
use super::super::build_keymap_replace_binding_menu_params;
use super::super::keymap_with_bindings;
use super::super::keymap_with_edit;
use super::KeymapCaptureView;
use crate::app_event::AppEvent;
use crate::app_event::KeymapCaptureMode;
use crate::app_event::KeymapEditIntent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::Renderable;
use codex_config::types::TuiKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::UnboundedReceiver;
use tokio::sync::mpsc::unbounded_channel;
fn capture_view() -> (KeymapCaptureView, UnboundedReceiver<AppEvent>) {
let (tx, rx) = unbounded_channel();
let view = KeymapCaptureView::new(
"list".to_string(),
"jump_top".to_string(),
KeymapEditIntent::AddAlternate,
KeymapCaptureMode::Chord,
"Jump Top".to_string(),
"home".to_string(),
AppEventSender::new(tx),
);
(view, rx)
}
fn ctrl_key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn capture_lines_at_width(view: &KeymapCaptureView, width: u16) -> String {
view.lines(width)
.into_iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn chord_capture_instruction_snapshots() {
let (mut view, _rx) = capture_view();
insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 80), @r"
Remap Shortcut
Action: Jump Top list.jump_top
Current: home
Press the first key, then the second. Esc cancels.
");
view.handle_key_event(ctrl_key(KeyCode::Char('x')));
insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 80), @r"
Remap Shortcut
Action: Jump Top list.jump_top
Current: home
First key: ctrl-x. Press the second key. Esc cancels.
");
}
#[test]
fn chord_capture_instructions_wrap_to_narrow_panes() {
let (mut view, _rx) = capture_view();
insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 24), @r"
Remap Shortcut
Action: Jump Top list.jump_top
Current: home
Press the first key,
then the second. Esc
cancels.
");
assert_eq!(view.desired_height(/*width*/ 24), 6);
view.handle_key_event(KeyEvent::new(
KeyCode::F(/*n*/ 24),
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT,
));
insta::assert_snapshot!(capture_lines_at_width(&view, /*width*/ 24), @r"
Remap Shortcut
Action: Jump Top list.jump_top
Current: home
First key: ctrl-alt-
shift-f24. Press the
second key. Esc cancels.
");
assert_eq!(view.desired_height(/*width*/ 24), 6);
}
#[test]
fn captures_repeated_strokes_but_ignores_auto_repeat() {
let (mut view, mut rx) = capture_view();
view.handle_key_event(KeyEvent::from(KeyCode::F(/*n*/ 25)));
assert!(view.error_message.is_some());
view.handle_key_event(ctrl_key(KeyCode::Char('x')));
assert_eq!(view.error_message, None);
view.handle_key_event(KeyEvent {
kind: KeyEventKind::Repeat,
..ctrl_key(KeyCode::Char('x'))
});
assert!(!view.is_complete());
assert_eq!(view.first_stroke.as_deref(), Some("ctrl-x"));
assert!(rx.try_recv().is_err());
view.handle_key_event(ctrl_key(KeyCode::Char('x')));
let AppEvent::KeymapCaptured {
context,
action,
key,
intent,
} = rx.try_recv().expect("completed chord capture event")
else {
panic!("expected a captured key chord");
};
assert_eq!(context, "list");
assert_eq!(action, "jump_top");
assert_eq!(key, "ctrl-x ctrl-x");
assert_eq!(intent, KeymapEditIntent::AddAlternate);
assert!(view.is_complete());
}
#[test]
fn modified_escape_is_captured_as_a_chord_stroke() {
let (mut canceled_view, mut canceled_events) = capture_view();
canceled_view.handle_key_event(ctrl_key(KeyCode::Char('x')));
canceled_view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(canceled_view.is_complete());
assert!(canceled_events.try_recv().is_err());
let ctrl_x = ctrl_key(KeyCode::Char('x'));
let ctrl_home = ctrl_key(KeyCode::Home);
let ctrl_esc = ctrl_key(KeyCode::Esc);
let alt_esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::ALT);
for (first, second, expected) in [
(ctrl_x, ctrl_esc, "ctrl-x ctrl-esc"),
(ctrl_x, alt_esc, "ctrl-x alt-esc"),
(ctrl_esc, ctrl_home, "ctrl-esc ctrl-home"),
(alt_esc, ctrl_home, "alt-esc ctrl-home"),
] {
let (mut view, mut rx) = capture_view();
view.handle_key_event(first);
assert!(
!view.is_complete(),
"the prefix of `{expected}` must be captured"
);
assert!(rx.try_recv().is_err());
view.handle_key_event(second);
let AppEvent::KeymapCaptured { key, .. } =
rx.try_recv().expect("completed modified-Escape chord")
else {
panic!("expected a captured key chord");
};
assert_eq!(key, expected);
assert!(view.is_complete());
}
}
#[test]
fn replacing_one_list_binding_with_chord_preserves_other_alternatives() {
let config = keymap_with_bindings(
&TuiKeymap::default(),
"list",
"jump_top",
&["home".to_string(), "ctrl-x home".to_string()],
)
.expect("list chord bindings");
let runtime = RuntimeKeymap::from_config(&config).expect("valid list key chords");
let params = build_keymap_replace_binding_menu_params(
"list".to_string(),
"jump_top".to_string(),
&runtime,
);
let chord_item = params
.items
.iter()
.find(|item| item.name == "home (key chord)")
.expect("replace-one chord capture option");
let (tx, mut rx) = unbounded_channel();
let sender = AppEventSender::new(tx);
chord_item.actions[0](&sender);
let AppEvent::OpenKeymapCapture {
context,
action,
intent,
capture_mode,
} = rx.try_recv().expect("open replace-one chord capture")
else {
panic!("expected a chord capture event");
};
assert_eq!(capture_mode, KeymapCaptureMode::Chord);
assert_eq!(
intent,
KeymapEditIntent::ReplaceOne {
old_key: "home".to_string()
}
);
let outcome = keymap_with_edit(
&config,
&runtime,
&context,
&action,
"ctrl-x ctrl-h",
&intent,
)
.expect("replace one binding with a key chord");
let expected_bindings = vec!["ctrl-x ctrl-h".to_string(), "ctrl-x home".to_string()];
let expected_keymap = keymap_with_bindings(&config, "list", "jump_top", &expected_bindings)
.expect("updated list key chords");
RuntimeKeymap::from_config(&expected_keymap).expect("updated list chord keymap is valid");
assert_eq!(
outcome,
KeymapEditOutcome::Updated {
keymap_config: Box::new(expected_keymap),
bindings: expected_bindings,
message: "Replaced `home` with `ctrl-x ctrl-h` for `list.jump_top`.".to_string(),
}
);
}
+18 -20
View File
@@ -21,8 +21,7 @@ use crate::style::accent_style;
use super::actions::KEYMAP_ACTIONS;
use super::actions::KeymapActionFilter;
use super::actions::action_label;
use super::actions::bindings_for_action;
use super::actions::format_binding_summary;
use super::actions::format_action_binding_summary;
use super::has_custom_binding;
pub(crate) const KEYMAP_PICKER_VIEW_ID: &str = "keymap-picker";
@@ -335,24 +334,23 @@ fn build_keymap_rows(
.iter()
.copied()
.filter(|descriptor| descriptor.is_visible(action_filter))
.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),
}
.map(|descriptor| KeymapActionRow {
context: descriptor.context,
context_label: descriptor.context_label,
action: descriptor.action,
label: action_label(descriptor.action),
description: descriptor.description,
binding_summary: format_action_binding_summary(
runtime_keymap,
descriptor.context,
descriptor.action,
),
custom_binding: has_custom_binding(
keymap_config,
descriptor.context,
descriptor.action,
)
.unwrap_or(/*default*/ false),
})
.collect()
}
+73 -67
View File
@@ -24,6 +24,7 @@ use crate::history_cell::UserHistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::ShortcutHint;
use crate::keymap::PagerKeymap;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
@@ -90,12 +91,16 @@ impl Overlay {
}
}
fn first_or_empty(bindings: &[KeyBinding]) -> Vec<KeyBinding> {
bindings.first().copied().into_iter().collect()
fn first_or_empty(
keymap: &PagerKeymap,
action: &'static str,
bindings: &[KeyBinding],
) -> Vec<ShortcutHint> {
keymap.primary_hint(action, bindings).into_iter().collect()
}
// Render a single line of key hints from (key(s), description) pairs.
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &str)]) {
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<ShortcutHint>, &str)]) {
let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true;
for (keys, desc) in pairs {
@@ -106,7 +111,7 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &st
if i > 0 {
spans.push("/".into());
}
spans.push(Span::from(key));
spans.push(Span::from(*key));
}
spans.push(" ".into());
spans.push(Span::from(desc.to_string()));
@@ -115,6 +120,31 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &st
Paragraph::new(vec![Line::from(spans).dim()]).render(area, buf);
}
fn render_navigation_hints(area: Rect, buf: &mut Buffer, keymap: &PagerKeymap) {
let actions = [
("scroll_up", &keymap.scroll_up),
("scroll_down", &keymap.scroll_down),
("page_up", &keymap.page_up),
("page_down", &keymap.page_down),
("jump_top", &keymap.jump_top),
("jump_bottom", &keymap.jump_bottom),
];
let hints = actions
.chunks_exact(2)
.zip(["to scroll", "to page", "to jump"])
.map(|(actions, description)| {
(
actions
.iter()
.filter_map(|(action, bindings)| keymap.primary_hint(action, bindings))
.collect(),
description,
)
})
.collect::<Vec<_>>();
render_key_hints(area, buf, &hints);
}
/// Generic widget for rendering a pager view.
struct PagerView {
renderables: Vec<Box<dyn Renderable>>,
@@ -732,48 +762,27 @@ impl TranscriptOverlay {
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(
line1,
buf,
&[
(
first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect(),
"to scroll",
),
(
first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect(),
"to page",
),
(
first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect(),
"to jump",
),
],
);
render_navigation_hints(line1, buf, &self.view.keymap);
let mut pairs: Vec<(Vec<KeyBinding>, &str)> =
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
let mut pairs: Vec<(Vec<ShortcutHint>, &str)> = vec![(
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
"to quit",
)];
if self.highlight_cell.is_some() {
pairs.push((
vec![
key_hint::plain(KeyCode::Esc),
key_hint::plain(KeyCode::Left),
key_hint::plain(KeyCode::Esc).into(),
key_hint::plain(KeyCode::Left).into(),
],
"to edit prev",
));
pairs.push((vec![key_hint::plain(KeyCode::Right)], "to edit next"));
pairs.push((vec![key_hint::plain(KeyCode::Enter)], "to edit message"));
pairs.push((vec![key_hint::plain(KeyCode::Right).into()], "to edit next"));
pairs.push((
vec![key_hint::plain(KeyCode::Enter).into()],
"to edit message",
));
} else {
pairs.push((vec![key_hint::plain(KeyCode::Esc)], "to edit prev"));
pairs.push((vec![key_hint::plain(KeyCode::Esc).into()], "to edit prev"));
}
render_key_hints(line2, buf, &pairs);
}
@@ -846,35 +855,11 @@ impl StaticOverlay {
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(
line1,
buf,
&[
(
first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect(),
"to scroll",
),
(
first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect(),
"to page",
),
(
first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect(),
"to jump",
),
],
);
let pairs: Vec<(Vec<KeyBinding>, &str)> =
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
render_navigation_hints(line1, buf, &self.view.keymap);
let pairs: Vec<(Vec<ShortcutHint>, &str)> = vec![(
first_or_empty(&self.view.keymap, "close", &self.view.keymap.close),
"to quit",
)];
render_key_hints(line2, buf, &pairs);
}
@@ -1035,6 +1020,27 @@ mod tests {
)
}
#[test]
fn footer_hints_display_chords_without_internal_dispatch_keys() {
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
let mut config = TuiKeymap::default();
config.pager.page_up = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x page-up".to_string(),
)));
let keymap = crate::keymap::RuntimeKeymap::from_config(&config).expect("valid pager chord");
assert_eq!(
first_or_empty(&keymap.pager, "page_up", &keymap.pager.page_up),
vec![ShortcutHint::Chord {
prefix: key_hint::ctrl(KeyCode::Char('x')),
completion: key_hint::plain(KeyCode::PageUp),
}]
);
}
#[test]
fn edit_prev_hint_is_visible() {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
+95 -26
View File
@@ -12,8 +12,10 @@ use crate::git_action_directives::parse_assistant_markdown;
use crate::inline_visualization::InlineVisualizationContext;
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::PagerKeymap;
use crate::keymap::RuntimeChordKeymap;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
@@ -292,6 +294,7 @@ struct SessionPickerRunOptions {
pager_keymap: PagerKeymap,
list_keymap: ListKeymap,
initial_page_mode: PageLoadMode,
chord_keymap: Arc<RuntimeChordKeymap>,
}
/// Interactive session picker that lists app-server threads with simple search,
@@ -383,6 +386,7 @@ async fn run_resume_picker_with_launch_context(
} else {
PageLoadMode::StateDbOnly
},
chord_keymap: runtime_keymap.chords,
};
run_session_picker_with_loader(
tui,
@@ -434,6 +438,7 @@ pub async fn run_fork_picker_with_app_server(
} else {
PageLoadMode::StateDbOnly
},
chord_keymap: runtime_keymap.chords,
};
run_session_picker_with_loader(
tui,
@@ -470,6 +475,7 @@ async fn run_session_picker_with_loader(
state.view_persistence = options.view_persistence;
state.pager_keymap = options.pager_keymap;
state.list_keymap = options.list_keymap;
state.chord_keymap = options.chord_keymap;
state.launch_context = options.launch_context;
state.initial_page_mode = options.initial_page_mode;
state.start_initial_load();
@@ -482,6 +488,14 @@ async fn run_session_picker_with_loader(
tokio::select! {
Some(ev) = tui_events.next() => {
let screen_size = alt.tui.screen_size_for_event(&ev)?;
let ev = if let TuiEvent::Key(key) = ev {
let Some(key) = state.route_key_chord(key) else {
continue;
};
TuiEvent::Key(key)
} else {
ev
};
if state.overlay.is_some() {
state.handle_overlay_event(alt.tui, ev)?;
continue;
@@ -697,6 +711,8 @@ struct PickerState {
pager_keymap: PagerKeymap,
list_keymap: ListKeymap,
initial_page_mode: PageLoadMode,
chord_keymap: Arc<RuntimeChordKeymap>,
chord_matcher: crate::keymap::KeyChordMatcher,
}
#[derive(Clone, Copy, Debug)]
@@ -967,6 +983,28 @@ impl PickerState {
pager_keymap: RuntimeKeymap::defaults().pager,
list_keymap: RuntimeKeymap::defaults().list,
initial_page_mode: PageLoadMode::StoreDefault,
chord_keymap: Arc::default(),
chord_matcher: crate::keymap::KeyChordMatcher::default(),
}
}
fn route_key_chord(&mut self, key: KeyEvent) -> Option<KeyEvent> {
let context = if self.overlay.is_some() {
crate::keymap::KeymapContext::Pager
} else {
crate::keymap::KeymapContext::List
};
match self.chord_matcher.advance(
key,
&self.chord_keymap,
crate::keymap::KeymapContextSet::new(context),
tokio::time::Instant::now(),
) {
crate::keymap::KeyChordMatch::PassThrough => Some(key),
crate::keymap::KeyChordMatch::Completed(dispatch_event) => Some(dispatch_event),
crate::keymap::KeyChordMatch::Pending(_)
| crate::keymap::KeyChordMatch::Cancelled
| crate::keymap::KeyChordMatch::Ignored => None,
}
}
@@ -2052,7 +2090,7 @@ fn filter_mode_label(filter_mode: SessionFilterMode) -> &'static str {
}
struct PickerFooterHint {
key: &'static str,
key: String,
wide_label: String,
compact_label: String,
priority: u8,
@@ -2176,13 +2214,13 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
if state.is_transcript_loading() {
let hints = [
PickerFooterHint {
key: "loading",
key: "loading".to_string(),
wide_label: String::from("transcript"),
compact_label: String::from("transcript"),
priority: 0,
},
PickerFooterHint {
key: "ctrl+c",
key: "ctrl+c".to_string(),
wide_label: String::from("quit"),
compact_label: String::from("quit"),
priority: 1,
@@ -2216,64 +2254,85 @@ fn footer_hint_lines(state: &PickerState, width: u16) -> Vec<Line<'static>> {
SessionListDensity::Comfortable => "dense",
SessionListDensity::Dense => "comfy",
};
let first_row_hints = vec![
PickerFooterHint {
key: "enter",
let mut first_row_hints = Vec::new();
if let Some(accept) = state.list_keymap.primary_hint(ListAction::Accept) {
first_row_hints.push(PickerFooterHint {
key: accept.display_label(),
wide_label: action_label.to_string(),
compact_label: action_label.to_string(),
priority: 0,
},
PickerFooterHint {
key: "esc",
});
}
if let Some(cancel) = state.list_keymap.primary_hint(ListAction::Cancel) {
first_row_hints.push(PickerFooterHint {
key: cancel.display_label(),
wide_label: esc_label.to_string(),
compact_label: esc_compact_label.to_string(),
priority: 1,
},
});
}
first_row_hints.extend([
PickerFooterHint {
key: "ctrl+c",
key: "ctrl+c".to_string(),
wide_label: ctrl_c_label.to_string(),
compact_label: ctrl_c_label.to_string(),
priority: 2,
},
PickerFooterHint {
key: "tab",
key: "tab".to_string(),
wide_label: String::from("focus sort/filter"),
compact_label: String::from("focus"),
priority: 7,
},
PickerFooterHint {
key: "←/→",
]);
let option_keys = [ListAction::MoveLeft, ListAction::MoveRight]
.into_iter()
.filter_map(|action| state.list_keymap.primary_hint(action))
.map(super::key_hint::ShortcutHint::display_label)
.collect::<Vec<_>>()
.join("/");
if !option_keys.is_empty() {
first_row_hints.push(PickerFooterHint {
key: option_keys,
wide_label: String::from("change option"),
compact_label: String::from("option"),
priority: 8,
},
];
let second_row_hints = vec![
});
}
let mut second_row_hints = vec![
PickerFooterHint {
key: "ctrl+o",
key: "ctrl+o".to_string(),
wide_label: density_label.to_string(),
compact_label: density_compact_label.to_string(),
priority: 3,
},
PickerFooterHint {
key: "ctrl+t",
key: "ctrl+t".to_string(),
wide_label: String::from("transcript"),
compact_label: String::from("preview"),
priority: 4,
},
PickerFooterHint {
key: "ctrl+e",
key: "ctrl+e".to_string(),
wide_label: String::from("expand"),
compact_label: String::from("exp"),
priority: 6,
},
PickerFooterHint {
key: "↑/↓",
];
let browse_keys = [ListAction::MoveUp, ListAction::MoveDown]
.into_iter()
.filter_map(|action| state.list_keymap.primary_hint(action))
.map(super::key_hint::ShortcutHint::display_label)
.collect::<Vec<_>>()
.join("/");
if !browse_keys.is_empty() {
second_row_hints.push(PickerFooterHint {
key: browse_keys,
wide_label: String::from("browse"),
compact_label: String::from("browse"),
priority: 5,
},
];
});
}
vec![
hint_line_for_row(&first_row_hints, width),
@@ -2393,7 +2452,7 @@ fn fit_footer_hint_refs(
if idx > 0 {
spans.push(" ".repeat(gap_width).set_style(footer_hint_label_style()));
}
spans.push(hint.key.set_style(footer_hint_key_style()));
spans.push(hint.key.clone().set_style(footer_hint_key_style()));
let label = match mode {
FooterHintLabelMode::Wide => Some(hint.wide_label.as_str()),
FooterHintLabelMode::Compact => Some(hint.compact_label.as_str()),
@@ -2442,7 +2501,7 @@ fn footer_hints_width(
}
FooterHintLabelMode::KeyOnly => 0,
};
let hint_width = UnicodeWidthStr::width(hint.key) + label_width;
let hint_width = UnicodeWidthStr::width(hint.key.as_str()) + label_width;
if idx == 0 {
hint_width
} else {
@@ -4033,6 +4092,16 @@ mod tests {
assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+o dense view"));
assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+t transcript"));
assert!(footer_lines_text(&state, /*width*/ 220).contains("ctrl+e expand"));
state.list_keymap.move_left = vec![crate::key_hint::ctrl(KeyCode::Char('h'))];
state.list_keymap.move_right = vec![crate::key_hint::ctrl(KeyCode::Char('l'))];
let remapped_footer = footer_lines_text(&state, /*width*/ 220);
assert!(
remapped_footer.contains("ctrl + h/ctrl + l change option"),
"{remapped_footer}"
);
state.list_keymap.move_left.clear();
state.list_keymap.move_right.clear();
assert!(!footer_lines_text(&state, /*width*/ 220).contains("change option"));
state.density = SessionListDensity::Dense;
@@ -4,12 +4,15 @@ expression: snapshot
---
unbound:
Set key | Capture a key for this unbound action. | enabled
Set key chord | Capture two consecutive keys for this action. | enabled
Remove custom binding | Restore the default keymap binding. | enabled
Back to shortcuts | Return to the shortcut list. | enabled
single:
Replace binding | Capture a replacement key. | enabled
Add alternate binding | Keep the current binding and add another key. | enabled
Replace with key chord | Replace current bindings with a two-stroke key chord. | enabled
Add alternate key chord | Keep current bindings and add a two-stroke key chord. | enabled
Remove custom binding | Restore the default keymap binding. | enabled
Back to shortcuts | Return to the shortcut list. | enabled
@@ -17,9 +20,13 @@ multi:
Replace one binding... | Choose which existing binding to replace. | enabled
Replace all bindings | Replace every current binding with one key. | enabled
Add alternate binding | Keep current bindings and add another key. | enabled
Replace with key chord | Replace current bindings with a two-stroke key chord. | enabled
Add alternate key chord | Keep current bindings and add a two-stroke key chord. | enabled
Remove custom binding | Restore the default keymap binding. | enabled
Back to shortcuts | Return to the shortcut list. | enabled
replace picker:
ctrl-enter | Replace this binding. | enabled
ctrl-enter (key chord) | Replace this binding with a key chord. | enabled
alt-shift-enter | Replace this binding. | enabled
alt-shift-enter (key chord) | Replace this binding with a key chord. | enabled
@@ -15,9 +15,15 @@ expression: snapshot
2. Add alternate binding
Keep the current binding and add another
key.
3. Replace with key chord
Replace current bindings with a two-
stroke key chord.
4. Add alternate key chord
Keep current bindings and add a two-
stroke key chord.
– Remove custom binding (disabled)
No custom root override to remove.
3. Back to shortcuts
5. Back to shortcuts
Return to the shortcut list.
Changes write the root `tui.keymap.*`
@@ -36,9 +42,13 @@ expression: snapshot
Capture one key and replace `ctrl-t`.
2. Add alternate binding
Keep the current binding and add another key.
3. Replace with key chord
Replace current bindings with a two-stroke key chord.
4. Add alternate key chord
Keep current bindings and add a two-stroke key chord.
– Remove custom binding (disabled)
No custom root override to remove.
3. Back to shortcuts
5. Back to shortcuts
Return to the shortcut list.
Changes write the root `tui.keymap.*` override.
@@ -54,8 +64,10 @@ expression: snapshot
› 1. Replace binding Capture one key and replace `ctrl-t`.
2. Add alternate binding Keep the current binding and add another key.
3. Replace with key chord Replace current bindings with a two-stroke key chord.
4. Add alternate key chord Keep current bindings and add a two-stroke key chord.
– Remove custom binding (disabled) No custom root override to remove.
3. Back to shortcuts Return to the shortcut list.
5. Back to shortcuts Return to the shortcut list.
Changes write the root `tui.keymap.*` override.
enter select · esc back
@@ -0,0 +1,22 @@
---
source: tui/src/keymap_setup.rs
expression: "render_picker(params, 120)"
---
Keymap
All configurable shortcuts.
109 actions, 1 customized, 2 unbound.
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
Type to search shortcuts
› Global * Open Transcript ctrl-t, ctrl-x ctrl-t
Global Open External Editor ctrl-g
Global Copy ctrl-o
Global Clear Terminal ctrl-l
Global - Toggle Vim Mode unbound
Global Toggle Raw Output alt-r
Global Toggle Side Conversation ctrl-/
Chat Interrupt Turn esc
left/right group · enter edit shortcut · * custom · - unbound · esc close
+13
View File
@@ -98,6 +98,7 @@ async fn run_startup_hooks_review_app(
app_event_tx.clone(),
&keymap,
);
let mut chord_matcher = crate::keymap::KeyChordMatcher::default();
draw_view(tui, &view)?;
let tui_events = tui.event_stream();
@@ -110,6 +111,18 @@ async fn run_startup_hooks_review_app(
tui.screen_size_for_event(&event)?;
match event {
TuiEvent::Key(key_event) => {
let key_event = match chord_matcher.advance(
key_event,
&keymap.chords,
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List),
tokio::time::Instant::now(),
) {
crate::keymap::KeyChordMatch::PassThrough => key_event,
crate::keymap::KeyChordMatch::Completed(dispatch_event) => dispatch_event,
crate::keymap::KeyChordMatch::Pending(_)
| crate::keymap::KeyChordMatch::Cancelled
| crate::keymap::KeyChordMatch::Ignored => continue,
};
if matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
view.handle_key_event(key_event);
}
+5 -5
View File
@@ -20,7 +20,7 @@ use unicode_width::UnicodeWidthStr;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::ShortcutHint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
@@ -50,7 +50,7 @@ pub(crate) struct StatusIndicatorWidget {
/// Optional suffix rendered after the elapsed/interrupt segment.
inline_message: Option<String>,
show_interrupt_hint: bool,
interrupt_binding: Option<KeyBinding>,
interrupt_binding: Option<ShortcutHint>,
elapsed_running: Duration,
last_resume_at: Instant,
@@ -89,7 +89,7 @@ impl StatusIndicatorWidget {
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
inline_message: None,
show_interrupt_hint: true,
interrupt_binding: Some(key_hint::plain(KeyCode::Esc)),
interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()),
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
is_paused: false,
@@ -152,7 +152,7 @@ impl StatusIndicatorWidget {
self.show_interrupt_hint = visible;
}
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<KeyBinding>) {
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<ShortcutHint>) {
self.interrupt_binding = binding;
}
@@ -422,7 +422,7 @@ mod tests {
crate::tui::FrameRequester::test_dummy(),
/*animations_enabled*/ false,
);
w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12))));
w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into()));
w.is_paused = true;
w.elapsed_running = Duration::ZERO;