mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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}");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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![
|
||||
|
||||
+21
@@ -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 "
|
||||
+13
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
+10
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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`. \
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 } =
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
-3
@@ -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
|
||||
|
||||
+22
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user