mirror of
https://github.com/openai/codex.git
synced 2026-10-16 16:58:46 +00:00
Add configurable persistent leader shortcuts to the TUI (#52273)
## What changed - Add `tui.keymap.global.leader`, defaulting to `ctrl-x`, and symbolic bindings such as `leader c` that follow the configured prefix. The default prefix yields to existing custom shortcuts and chord prefixes. - Keep leader shortcut hints open until completion or cancellation, while ordinary chords retain their one-second timeout. Pressing `esc` or repeating the leader cancels the pending shortcut. - Add leader configuration and a `Use leader...` capture option to `/keymap`, with resolved shortcut labels and conflict validation. - Cancel pending chords when input targets change, on paste or focus loss, and on mouse actions other than movement. - Move the default voice mute shortcut from `ctrl-x` to `f9` and update its tests and displayed hints. GitOrigin-RevId: 35b964cccd2812c73200f0c26b0f19506821df92
This commit is contained in:
@@ -134,6 +134,8 @@ impl KeybindingsSpec {
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiGlobalKeymap {
|
||||
/// Prefix for persistent, context-aware shortcut menus.
|
||||
pub leader: Option<KeybindingsSpec>,
|
||||
/// Open the shared agent-session overview.
|
||||
pub open_agents: Option<KeybindingsSpec>,
|
||||
/// Open the transcript overlay.
|
||||
@@ -594,6 +596,17 @@ fn normalize_keybinding_spec(raw: &str) -> Result<String, String> {
|
||||
));
|
||||
}
|
||||
|
||||
if strokes[0].eq_ignore_ascii_case("leader") {
|
||||
let [_, completion] = strokes.as_slice() else {
|
||||
return Err(format!(
|
||||
"invalid keybinding `{raw}`: `leader` must be followed by one key \
|
||||
(for example `leader c`)."
|
||||
));
|
||||
};
|
||||
return normalize_keybinding_stroke(completion)
|
||||
.map(|completion| format!("leader {completion}"));
|
||||
}
|
||||
|
||||
strokes
|
||||
.into_iter()
|
||||
.map(normalize_keybinding_stroke)
|
||||
|
||||
@@ -4712,6 +4712,7 @@
|
||||
"copy": null,
|
||||
"find_transcript": null,
|
||||
"focus_activity": null,
|
||||
"leader": null,
|
||||
"open_agents": null,
|
||||
"open_external_editor": null,
|
||||
"open_transcript": null,
|
||||
@@ -5515,6 +5516,14 @@
|
||||
],
|
||||
"description": "Focus activity groups in the owned transcript to inspect their details."
|
||||
},
|
||||
"leader": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Prefix for persistent, context-aware shortcut menus."
|
||||
},
|
||||
"open_agents": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -5710,6 +5719,7 @@
|
||||
"copy": null,
|
||||
"find_transcript": null,
|
||||
"focus_activity": null,
|
||||
"leader": null,
|
||||
"open_agents": null,
|
||||
"open_external_editor": null,
|
||||
"open_transcript": null,
|
||||
|
||||
@@ -104,6 +104,7 @@ async fn thread_projection_preserves_executor_order_and_unavailable_selections()
|
||||
vec![unavailable.clone(), ready.clone()],
|
||||
vec![ready.clone(), unavailable.clone()],
|
||||
] {
|
||||
// These synthetic executor selections have no capability roots.
|
||||
let selections = requests
|
||||
.iter()
|
||||
.cloned()
|
||||
|
||||
@@ -79,6 +79,7 @@ fn mcp_context_preserves_selected_executor_order_and_unavailability() {
|
||||
workspace_roots: Vec::new(),
|
||||
selected_capability_roots: Default::default(),
|
||||
config: EnvironmentConfigState::Pending,
|
||||
selected_capability_roots: Default::default(),
|
||||
},
|
||||
TurnEnvironmentSelection {
|
||||
environment_id: "ready-secondary".to_owned(),
|
||||
@@ -86,6 +87,7 @@ fn mcp_context_preserves_selected_executor_order_and_unavailability() {
|
||||
workspace_roots: Vec::new(),
|
||||
selected_capability_roots: Default::default(),
|
||||
config: EnvironmentConfigState::FromThread,
|
||||
selected_capability_roots: Default::default(),
|
||||
},
|
||||
];
|
||||
let context = McpServerContributionContext::global(&config);
|
||||
|
||||
@@ -907,7 +907,9 @@ impl App {
|
||||
self.handle_draw_pre_render(tui, screen_size)?;
|
||||
}
|
||||
|
||||
if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost) {
|
||||
if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost)
|
||||
|| matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved)
|
||||
{
|
||||
self.cancel_pending_key_chord();
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ use super::agents_overview_view::AgentsOverviewView;
|
||||
use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
use crate::app_event::AgentsOverviewThreadRefresh;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::bottom_pane::SelectionDescriptionLayout;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
@@ -281,6 +282,11 @@ impl App {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let previous_thread_id = self
|
||||
.agents_overview
|
||||
.visible_thread_ids
|
||||
.get(selected)
|
||||
.copied();
|
||||
let selected_thread_id = self
|
||||
.agents_overview
|
||||
.view_state
|
||||
@@ -292,12 +298,7 @@ impl App {
|
||||
&& !self.agents_overview.hidden_threads.contains(id)
|
||||
})
|
||||
.or(self.agents_overview.selection_after_removal.take())
|
||||
.or_else(|| {
|
||||
self.agents_overview
|
||||
.visible_thread_ids
|
||||
.get(selected)
|
||||
.copied()
|
||||
});
|
||||
.or(previous_thread_id);
|
||||
let threads = self
|
||||
.agents_overview
|
||||
.threads
|
||||
@@ -307,6 +308,17 @@ impl App {
|
||||
.collect();
|
||||
let view = self.agents_overview_view(threads, selected_thread_id);
|
||||
self.agents_overview.visible_thread_ids = view.thread_ids();
|
||||
let next_thread_id = view
|
||||
.selected_index()
|
||||
.and_then(|index| self.agents_overview.visible_thread_ids.get(index).copied());
|
||||
if (previous_thread_id.is_none() || previous_thread_id != next_thread_id)
|
||||
&& self
|
||||
.chat_widget
|
||||
.selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID)
|
||||
.is_some()
|
||||
{
|
||||
self.cancel_pending_key_chord();
|
||||
}
|
||||
if let Ok(mut state) = self.agents_overview.view_state.lock()
|
||||
&& state
|
||||
.rename_target
|
||||
|
||||
@@ -69,6 +69,9 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
key_event: KeyEvent,
|
||||
) -> Option<KeyEvent> {
|
||||
if self.chat_widget.take_key_chord_reset() {
|
||||
self.cancel_pending_key_chord();
|
||||
}
|
||||
self.transcript_view.set_keymap_bindings(&self.keymap);
|
||||
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.set_keymap_bindings(&self.keymap);
|
||||
@@ -174,6 +177,9 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn expire_pending_key_chord(&mut self) {
|
||||
if self.chat_widget.take_key_chord_reset() {
|
||||
self.cancel_pending_key_chord();
|
||||
}
|
||||
let contexts = self.active_keymap_contexts();
|
||||
if self.key_chord_matcher.expire(contexts) {
|
||||
self.set_key_chord_hint_override(/*items*/ None);
|
||||
|
||||
@@ -221,7 +221,7 @@ async fn voice_mute_shortcut_reaches_the_active_widget() -> Result<()> {
|
||||
app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
tui::TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
|
||||
tui::TuiEvent::Key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)),
|
||||
)
|
||||
.await?;
|
||||
// The fixture has no microphone handle, so reaching mute reports that limitation.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ Other task:
|
||||
|
||||
gpt-test default · /tmp/project
|
||||
Restored owner:
|
||||
voice ● listening ⌃x mute /voice stop
|
||||
voice ● listening f9 mute /voice stop
|
||||
mic ▁▁▁▁▁▁ codex ▁▁▁▁▁▁
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
@@ -240,11 +240,12 @@ pub(crate) enum KeymapEditIntent {
|
||||
ReplaceOne { old_key: String },
|
||||
}
|
||||
|
||||
/// Number of key strokes recorded by one `/keymap` capture.
|
||||
/// Kind of shortcut recorded by one `/keymap` capture.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum KeymapCaptureMode {
|
||||
SingleKey,
|
||||
Chord,
|
||||
LeaderChord,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -149,7 +149,8 @@ impl AsyncQuestions {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_answers(&mut self, question_ids: &[String]) {
|
||||
/// Return whether resolving these answers changed the expanded question's input target.
|
||||
pub(crate) fn resolve_answers(&mut self, question_ids: &[String]) -> bool {
|
||||
// History can arrive before live questions or before restoring local drafts.
|
||||
self.state.answered_ids.extend(question_ids.iter().cloned());
|
||||
// Older desktop replies identify the whole source message instead of one question.
|
||||
@@ -158,9 +159,10 @@ impl AsyncQuestions {
|
||||
|| question_ids.contains(&question.message_id)
|
||||
};
|
||||
if !self.state.pending.iter().any(answered) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let current_answered = self.current_answer().is_some_and(answered);
|
||||
let displayed_answer_changed = self.expanded && current_answered;
|
||||
if current_answered {
|
||||
self.composer.flush_pending_input();
|
||||
}
|
||||
@@ -183,6 +185,7 @@ impl AsyncQuestions {
|
||||
self.restore_current_draft();
|
||||
self.composer.reset_vim_mode();
|
||||
}
|
||||
displayed_answer_changed
|
||||
}
|
||||
|
||||
pub(crate) fn accept_answer(&mut self) {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused
|
||||
//! interactions like selection lists. Centered views retain earlier views as a backdrop,
|
||||
//! while input remains routed exclusively to the top of the stack.
|
||||
//! Modal insertion, replacement, and dismissal invalidate pending key chords, including when a
|
||||
//! deferred prompt appears or an approval queue advances without changing keymap contexts.
|
||||
//!
|
||||
//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs
|
||||
//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent
|
||||
@@ -288,6 +290,7 @@ pub(crate) struct BottomPane {
|
||||
|
||||
/// Stack of views displayed instead of the composer (e.g. popups/modals).
|
||||
view_stack: Vec<Box<dyn BottomPaneView>>,
|
||||
pub(crate) key_chord_reset_requested: bool,
|
||||
warnings_view: Option<warnings_view::WarningsView>,
|
||||
/// A keep press can close the viewer; its remaining repeats must not edit the draft.
|
||||
pub(crate) suppress_warning_keep_repeat: bool,
|
||||
@@ -376,6 +379,7 @@ impl BottomPane {
|
||||
Self {
|
||||
composer,
|
||||
view_stack: Vec::new(),
|
||||
key_chord_reset_requested: false,
|
||||
warnings_view: None,
|
||||
suppress_warning_keep_repeat: false,
|
||||
questions: None,
|
||||
@@ -715,6 +719,7 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
fn push_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.key_chord_reset_requested = true;
|
||||
self.view_stack.push(view);
|
||||
self.schedule_active_view_frame();
|
||||
self.request_redraw();
|
||||
@@ -744,6 +749,7 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
fn on_view_stack_depth_decreased(&mut self) {
|
||||
self.key_chord_reset_requested = true;
|
||||
if self.view_stack.is_empty() {
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
@@ -1061,6 +1067,7 @@ impl BottomPane {
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
if needs_redraw || view_complete {
|
||||
self.key_chord_reset_requested = true;
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -1510,6 +1517,7 @@ impl BottomPane {
|
||||
view.dismiss_after_child_accept = self.view_stack[index].dismiss_after_child_accept();
|
||||
self.view_stack[index] = Box::new(view);
|
||||
if replaces_active_view {
|
||||
self.key_chord_reset_requested = true;
|
||||
self.schedule_active_view_frame();
|
||||
}
|
||||
self.request_redraw();
|
||||
@@ -1618,6 +1626,7 @@ impl BottomPane {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.key_chord_reset_requested = true;
|
||||
self.view_stack.pop();
|
||||
self.request_redraw();
|
||||
true
|
||||
@@ -1636,6 +1645,7 @@ impl BottomPane {
|
||||
let removed_active_view = index + 1 == self.view_stack.len();
|
||||
self.view_stack.remove(index);
|
||||
if removed_active_view {
|
||||
self.key_chord_reset_requested = true;
|
||||
self.schedule_active_view_frame();
|
||||
}
|
||||
self.request_redraw();
|
||||
|
||||
@@ -55,7 +55,7 @@ fn active_dashboard_preserves_live_colors_controls_and_width() {
|
||||
assert!(actual.starts_with(" voice "));
|
||||
assert!(actual.lines().nth(1).unwrap().starts_with(" mic "));
|
||||
insta::assert_snapshot!(actual, @"
|
||||
voice ● listening ⌃x mute /voice stop
|
||||
voice ● listening f9 mute /voice stop
|
||||
mic ▆▅▄▃▂▁ codex ▁▃▄▅▆█
|
||||
");
|
||||
assert_eq!(buffer[(7, 0)].fg, Color::Red);
|
||||
@@ -63,7 +63,7 @@ fn active_dashboard_preserves_live_colors_controls_and_width() {
|
||||
assert_eq!(buffer[(22, 1)].fg, Color::Magenta);
|
||||
assert!(actual.contains("/voice stop"));
|
||||
let (compact, buffer) = rows(&strip, /*width*/ 37);
|
||||
assert!(compact.contains("⌃x mute") && compact.contains("/voice stop"));
|
||||
assert!(compact.contains("f9 mute") && compact.contains("/voice stop"));
|
||||
assert_eq!(buffer[(36, 0)].symbol(), " ");
|
||||
let (narrow, buffer) = rows(&strip, /*width*/ 22);
|
||||
assert!(narrow.contains("/voice stop"));
|
||||
@@ -89,7 +89,7 @@ fn connecting_and_muted_indicators_follow_actual_capture() {
|
||||
strip.state.animations = false;
|
||||
let (connecting, buffer) = rows(&strip, /*width*/ 45);
|
||||
assert!(connecting.contains("voice ◌ connecting"));
|
||||
assert!(!connecting.contains("⌃x mute"));
|
||||
assert!(!connecting.contains("f9 mute"));
|
||||
assert_ne!(buffer[(7, 0)].fg, Color::Red);
|
||||
strip.state.microphone_live = true;
|
||||
let (_, buffer) = rows(&strip, /*width*/ 45);
|
||||
|
||||
@@ -61,6 +61,10 @@ impl ChatWidget {
|
||||
self.bottom_pane.keymap_contexts()
|
||||
}
|
||||
|
||||
pub(crate) fn take_key_chord_reset(&mut self) -> bool {
|
||||
std::mem::take(&mut self.bottom_pane.key_chord_reset_requested)
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventAction {
|
||||
if self.handle_startup_submission_key(key_event) {
|
||||
return KeyEventAction::None;
|
||||
|
||||
@@ -72,7 +72,7 @@ async fn voice_composer_preserves_normal_colors_across_microphone_states() {
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(rows, @"
|
||||
0:
|
||||
1: voice ● listening ⌃x mute /voice stop
|
||||
1: voice ● listening f9 mute /voice stop
|
||||
2: mic ▁▁▁▁▁▁ codex ▁▁▁▁▁▁
|
||||
3:
|
||||
4: › typed
|
||||
@@ -357,7 +357,7 @@ async fn narrow_voice_footer_keeps_the_stop_control_before_meters() {
|
||||
|
||||
let footer = render_bottom_popup(&chat, /*width*/ 46);
|
||||
assert!(footer.contains("voice ● speaking"));
|
||||
assert!(footer.contains("⌃x mute"));
|
||||
assert!(footer.contains("f9 mute"));
|
||||
assert!(footer.contains("/voice stop"));
|
||||
chat.realtime_conversation.speaker_active_until = None;
|
||||
chat.realtime_conversation.speaker_level = 1;
|
||||
@@ -375,7 +375,7 @@ async fn narrow_voice_footer_keeps_the_stop_control_before_meters() {
|
||||
assert_eq!(chat.realtime_conversation.speaker_active_until, None);
|
||||
let interrupted = render_bottom_popup(&chat, /*width*/ 45);
|
||||
assert!(interrupted.contains("voice ● heard"));
|
||||
assert!(interrupted.contains("⌃x mute"));
|
||||
assert!(interrupted.contains("f9 mute"));
|
||||
assert!(interrupted.contains("/voice stop"));
|
||||
chat.realtime_conversation.interruption_acknowledged_until =
|
||||
Some(std::time::Instant::now() - super::super::INTERRUPTION_ACKNOWLEDGMENT);
|
||||
@@ -605,15 +605,15 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() {
|
||||
|
||||
insta::assert_snapshot!(layouts.join("\n\n"), @"
|
||||
5 rows:
|
||||
voice ● listening ⌃x mute /voice stop
|
||||
voice ● listening f9 mute /voice stop
|
||||
› typed
|
||||
|
||||
6 rows:
|
||||
voice ● listening ⌃x mute /voice stop
|
||||
voice ● listening f9 mute /voice stop
|
||||
› typed
|
||||
|
||||
8 rows:
|
||||
voice ● listening ⌃x mute /voice stop
|
||||
voice ● listening f9 mute /voice stop
|
||||
› typed
|
||||
");
|
||||
}
|
||||
|
||||
+5
-5
@@ -10,7 +10,7 @@ connecting:
|
||||
status line stays visible
|
||||
|
||||
listening:
|
||||
voice ● listening ⌃x mute /voice stop
|
||||
voice ● listening f9 mute /voice stop
|
||||
mic ▁▁▁▁▁▁ codex ▁▁▁▁▁▁
|
||||
|
||||
› Ask Codex to do anything
|
||||
@@ -18,7 +18,7 @@ listening:
|
||||
status line stays visible
|
||||
|
||||
speaking:
|
||||
voice ● speaking ⌃x mute /voice stop
|
||||
voice ● speaking f9 mute /voice stop
|
||||
mic ▁▁▇▇▇▇ codex ▁▁████
|
||||
|
||||
› Ask Codex to do anything
|
||||
@@ -26,7 +26,7 @@ speaking:
|
||||
status line stays visible
|
||||
|
||||
muted:
|
||||
voice ◌ muted ⌃x unmute /voice stop
|
||||
voice ◌ muted f9 unmute /voice stop
|
||||
mic ▁▁▁▁▁▁ codex ▁▁▇▇▇▇
|
||||
|
||||
› Ask Codex to do anything
|
||||
@@ -34,7 +34,7 @@ muted:
|
||||
status line stays visible
|
||||
|
||||
transcript:
|
||||
voice ● speaking ⌃x mute /voice stop
|
||||
voice ● speaking f9 mute /voice stop
|
||||
mic ▁▁▄▄▄▄ codex ▁▁▄▄▄▄
|
||||
|
||||
› Ask Codex to do anything
|
||||
@@ -42,7 +42,7 @@ transcript:
|
||||
status line stays visible
|
||||
|
||||
interrupted:
|
||||
voice ● heard ⌃x mute /voice stop
|
||||
voice ● heard f9 mute /voice stop
|
||||
mic ▁▁▄▄▄▄ codex ▁▁▄▄▄▄
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
@@ -281,7 +281,11 @@ impl ChatWidget {
|
||||
.into_iter()
|
||||
.map(|reply| reply.question_item_id)
|
||||
.collect::<Vec<_>>();
|
||||
self.bottom_pane.question_editor().resolve_answers(&ids);
|
||||
let displayed_answer_changed =
|
||||
self.bottom_pane.question_editor().resolve_answers(&ids);
|
||||
if displayed_answer_changed && !self.bottom_pane.has_active_view() {
|
||||
self.bottom_pane.key_chord_reset_requested = true;
|
||||
}
|
||||
self.refresh_pending_input_preview();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ expression: rendered
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" voice ● listening ⌃x mute /voice stop "
|
||||
" voice ● listening f9 mute /voice stop "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ expression: normalized_backend_snapshot(terminal.backend())
|
||||
"• Agent answer arriving "
|
||||
" "
|
||||
" "
|
||||
" voice ● listening ⌃x mute /voice stop "
|
||||
" voice ● listening f9 mute /voice stop "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
|
||||
@@ -61,6 +61,13 @@ pub(super) async fn check(
|
||||
break None;
|
||||
};
|
||||
tui.screen_size_for_event(&event)?;
|
||||
if matches!(
|
||||
&event,
|
||||
crate::tui::TuiEvent::Paste(_) | crate::tui::TuiEvent::FocusLost
|
||||
) || matches!(&event, crate::tui::TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved)
|
||||
{
|
||||
chord_matcher.cancel();
|
||||
}
|
||||
if let crate::tui::TuiEvent::Key(key) = event
|
||||
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ use std::sync::Arc;
|
||||
|
||||
mod bindings;
|
||||
mod chords;
|
||||
mod leader;
|
||||
mod vim_search;
|
||||
pub(crate) use vim_search::VimSearchKeymap;
|
||||
|
||||
@@ -646,13 +647,13 @@ impl RuntimeKeymap {
|
||||
|| configured_context_alias_is_used(&keymap.approval, alias)
|
||||
});
|
||||
|
||||
// Preserve existing Ctrl+X shortcuts and chord prefixes when adding this default.
|
||||
// Voice mute yields to existing shortcuts and chord prefixes.
|
||||
let voice_mute_default_is_shadowed = keymap.chat.toggle_voice_mute.is_none()
|
||||
&& (configured_main_surface_alias_is_used(keymap, "ctrl-x")
|
||||
&& (configured_main_surface_alias_is_used(keymap, "f9")
|
||||
|| configured_context_alias_is_used(&keymap.vim_search, "f9")
|
||||
|| chords.bindings.iter().any(|binding| {
|
||||
binding.action.context.overlaps(KeymapContext::Voice)
|
||||
&& binding.chord.prefix.parts()
|
||||
== key_hint::ctrl(KeyCode::Char('x')).parts()
|
||||
&& binding.chord.prefix.parts() == key_hint::plain(KeyCode::F(9)).parts()
|
||||
}));
|
||||
|
||||
// New activity defaults yield to existing custom keys and chord prefixes.
|
||||
@@ -1662,7 +1663,7 @@ impl RuntimeKeymap {
|
||||
chords: Arc::default(),
|
||||
chat: ChatKeymap {
|
||||
toggle_voice: default_bindings![plain(KeyCode::F(8))],
|
||||
toggle_voice_mute: default_bindings![ctrl(KeyCode::Char('x'))],
|
||||
toggle_voice_mute: default_bindings![plain(KeyCode::F(9))],
|
||||
chord_hints: Arc::default(),
|
||||
interrupt_turn: default_bindings![plain(KeyCode::Esc)],
|
||||
decrease_reasoning_effort: default_bindings![
|
||||
|
||||
@@ -211,6 +211,7 @@ macro_rules! define_runtime_action_bindings {
|
||||
action: &str,
|
||||
) -> Option<&'a [KeyBinding]> {
|
||||
match (context, action) {
|
||||
("global", "leader") => Some(runtime_keymap.chords.leader.as_slice()),
|
||||
$(
|
||||
$(
|
||||
($context, stringify!($action)) => {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! 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.
|
||||
//! Pending chords expire after one second or when their active context changes.
|
||||
//! Ordinary chords expire after one second. Shared leader prefixes stay pending until
|
||||
//! completion or cancellation; both kinds cancel when their active context changes.
|
||||
//! Symbolic leader expansion is capped before quadratic conflict validation.
|
||||
|
||||
use super::MAIN_RESERVED_BINDINGS;
|
||||
use super::RuntimeKeymap;
|
||||
@@ -30,6 +32,7 @@ pub(crate) const KEY_CHORD_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1);
|
||||
|
||||
const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY + 1;
|
||||
const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX;
|
||||
const MAX_LEADER_CHORD_BINDINGS: usize = 1024;
|
||||
const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
|
||||
("cancel", ctrl(KeyCode::Char('c'))),
|
||||
("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))),
|
||||
@@ -199,9 +202,32 @@ pub(crate) struct RuntimeChordBinding {
|
||||
spec: String,
|
||||
}
|
||||
|
||||
impl RuntimeChordBinding {
|
||||
fn is_leader(&self) -> bool {
|
||||
self.spec.starts_with("leader ")
|
||||
}
|
||||
|
||||
/// Leader consumers also remain active beside pager actions on native transcript surfaces.
|
||||
fn overlaps(&self, action: KeymapActionId) -> bool {
|
||||
self.action.overlaps(action)
|
||||
|| self.is_leader()
|
||||
&& [
|
||||
KeymapContextSet::browsing(),
|
||||
KeymapContextSet::new(KeymapContext::Global)
|
||||
.with(KeymapContext::Chat)
|
||||
.with_transcript_close(),
|
||||
]
|
||||
.iter()
|
||||
.any(|contexts| {
|
||||
contexts.contains_action(self.action) && contexts.contains_action(action)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Chord bindings plus configured alternatives in declaration order.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct RuntimeChordKeymap {
|
||||
pub(crate) leader: Vec<KeyBinding>,
|
||||
pub(crate) bindings: Vec<RuntimeChordBinding>,
|
||||
configured_specs: Vec<(KeymapActionId, Vec<String>)>,
|
||||
}
|
||||
@@ -209,6 +235,7 @@ pub(crate) struct RuntimeChordKeymap {
|
||||
impl RuntimeChordKeymap {
|
||||
pub(super) fn from_config(keymap: &TuiKeymap) -> Result<Self, String> {
|
||||
let mut keymap_chords = Self::default();
|
||||
let mut leader_bindings = Vec::new();
|
||||
for action in keymap_action_ids() {
|
||||
let Some(configured) = effective_configured_binding(keymap, action) else {
|
||||
continue;
|
||||
@@ -219,7 +246,22 @@ impl RuntimeChordKeymap {
|
||||
if configured_specs.iter().any(|configured| configured == raw) {
|
||||
continue;
|
||||
}
|
||||
if let Some((prefix, completion)) = raw.split_once(' ') {
|
||||
if let Some(completion) = raw.strip_prefix("leader ") {
|
||||
let invalid_binding = || {
|
||||
format!(
|
||||
"Invalid `{}` = `{raw}`. Use `leader` followed by one key, such as \
|
||||
`leader c`.",
|
||||
action.config_path()
|
||||
)
|
||||
};
|
||||
leader_bindings.push((
|
||||
action,
|
||||
normalize_chord_binding(
|
||||
parse_keybinding(completion).ok_or_else(invalid_binding)?,
|
||||
),
|
||||
raw.to_string(),
|
||||
));
|
||||
} else if let Some((prefix, completion)) = raw.split_once(' ') {
|
||||
let invalid_binding = || {
|
||||
format!(
|
||||
"Invalid `{}` = `{raw}`. Use a single key such as `ctrl-a` \
|
||||
@@ -282,6 +324,35 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.",
|
||||
spec: "g g".to_string(),
|
||||
});
|
||||
}
|
||||
super::leader::resolve(keymap, &mut keymap_chords)?;
|
||||
if leader_bindings
|
||||
.len()
|
||||
.saturating_mul(keymap_chords.leader.len())
|
||||
> MAX_LEADER_CHORD_BINDINGS
|
||||
{
|
||||
return Err(format!(
|
||||
"Too many leader shortcut combinations (maximum {MAX_LEADER_CHORD_BINDINGS}). \
|
||||
Reduce `tui.keymap.global.leader` alternatives or `leader ...` bindings."
|
||||
));
|
||||
}
|
||||
for (action, completion, spec) in leader_bindings {
|
||||
for &prefix in &keymap_chords.leader {
|
||||
let chord = KeyChord { prefix, completion };
|
||||
if let Some(existing) = keymap_chords
|
||||
.bindings
|
||||
.iter_mut()
|
||||
.find(|existing| existing.action == action && existing.chord == chord)
|
||||
{
|
||||
existing.spec.clone_from(&spec);
|
||||
} else {
|
||||
keymap_chords.bindings.push(RuntimeChordBinding {
|
||||
action,
|
||||
chord,
|
||||
spec: spec.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(keymap_chords)
|
||||
}
|
||||
|
||||
@@ -317,19 +388,24 @@ or a two-stroke chord such as `ctrl-x ctrl-t`.",
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the user's first configured shortcut without exposing dispatch tokens.
|
||||
/// Return the user's first active 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())
|
||||
{
|
||||
if let Some(spec) = self.configured_specs(action).and_then(|specs| {
|
||||
specs
|
||||
.iter()
|
||||
.find(|spec| !spec.starts_with("leader ") || !self.leader.is_empty())
|
||||
}) {
|
||||
return if let Some((prefix, completion)) = spec.split_once(' ') {
|
||||
Some(crate::key_hint::ShortcutHint::Chord {
|
||||
prefix: parse_keybinding(prefix)?,
|
||||
prefix: if prefix == "leader" {
|
||||
*self.leader.first()?
|
||||
} else {
|
||||
parse_keybinding(prefix)?
|
||||
},
|
||||
completion: parse_keybinding(completion)?,
|
||||
})
|
||||
} else {
|
||||
@@ -363,12 +439,13 @@ pub(crate) enum KeyChordMatch {
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PendingChord {
|
||||
started_at: Instant,
|
||||
// Persistent leader menus have no deadline.
|
||||
started_at: Option<Instant>,
|
||||
prefix: KeyBinding,
|
||||
contexts: KeymapContextSet,
|
||||
}
|
||||
|
||||
/// Tracks one pending two-stroke chord without buffering ordinary input.
|
||||
/// Tracks one pending chord or leader menu without buffering ordinary input.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct KeyChordMatcher {
|
||||
pending: Option<PendingChord>,
|
||||
@@ -444,7 +521,10 @@ impl KeyChordMatcher {
|
||||
|
||||
pub(crate) fn expire(&mut self, contexts: KeymapContextSet) -> bool {
|
||||
if self.pending.is_some_and(|pending| {
|
||||
pending.contexts != contexts || pending.started_at.elapsed() >= KEY_CHORD_TIMEOUT
|
||||
pending.contexts != contexts
|
||||
|| pending
|
||||
.started_at
|
||||
.is_some_and(|started_at| started_at.elapsed() >= KEY_CHORD_TIMEOUT)
|
||||
}) {
|
||||
self.pending = None;
|
||||
return true;
|
||||
@@ -472,7 +552,9 @@ impl KeyChordMatcher {
|
||||
}
|
||||
|
||||
if let Some(pending) = self.pending.take() {
|
||||
if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) {
|
||||
if crate::key_hint::plain(KeyCode::Esc).is_press(key_event)
|
||||
|| (pending.started_at.is_none() && chord_stroke_matches(pending.prefix, key_event))
|
||||
{
|
||||
return KeyChordMatch::Cancelled;
|
||||
}
|
||||
if let Some(binding) =
|
||||
@@ -494,8 +576,17 @@ impl KeyChordMatcher {
|
||||
})
|
||||
.map(|binding| binding.chord.prefix)
|
||||
{
|
||||
let persistent = keymap.bindings.iter().any(|binding| {
|
||||
binding.chord.prefix == prefix
|
||||
&& contexts.contains_action(binding.action)
|
||||
&& (binding.is_leader()
|
||||
|| (keymap.leader.contains(&prefix)
|
||||
&& contexts.contains(KeymapContext::Global)
|
||||
&& !contexts.contains(KeymapContext::List)
|
||||
&& binding.action.context.overlaps(KeymapContext::Global)))
|
||||
});
|
||||
self.pending = Some(PendingChord {
|
||||
started_at: Instant::now(),
|
||||
started_at: (!persistent).then(Instant::now),
|
||||
prefix,
|
||||
contexts,
|
||||
});
|
||||
@@ -604,9 +695,24 @@ pub(super) fn validate_chord_conflicts(keymap: &RuntimeKeymap) -> Result<(), Str
|
||||
for (index, binding) in keymap.chords.bindings.iter().enumerate() {
|
||||
validate_binding_shape(binding)?;
|
||||
validate_reserved_strokes(binding)?;
|
||||
if binding.chord.completion == binding.chord.prefix
|
||||
&& ((keymap.chords.leader.contains(&binding.chord.prefix)
|
||||
&& binding.action.context.overlaps(KeymapContext::Global))
|
||||
|| keymap.chords.bindings.iter().any(|leader| {
|
||||
leader.is_leader()
|
||||
&& leader.chord.prefix == binding.chord.prefix
|
||||
&& leader.overlaps(binding.action)
|
||||
}))
|
||||
{
|
||||
return Err(format!(
|
||||
"Ambiguous `{}` = `{}`: repeating the leader is reserved for cancellation.",
|
||||
binding.action.config_path(),
|
||||
binding.spec,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(conflict) = runtime_action_bindings(keymap)
|
||||
.filter(|candidate| binding.action.overlaps(candidate.id))
|
||||
.filter(|candidate| binding.overlaps(candidate.id))
|
||||
.find(|candidate| {
|
||||
candidate.bindings.iter().any(|single| {
|
||||
normalize_chord_binding(*single).parts() == binding.chord.prefix.parts()
|
||||
@@ -624,7 +730,7 @@ Unbind or remap the existing shortcut before using it as a chord prefix.",
|
||||
|
||||
for previous in &keymap.chords.bindings[..index] {
|
||||
if previous.action != binding.action
|
||||
&& previous.action.overlaps(binding.action)
|
||||
&& (previous.overlaps(binding.action) || binding.overlaps(previous.action))
|
||||
&& previous.chord == binding.chord
|
||||
{
|
||||
return Err(format!(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Resolves and validates the shared shortcut prefix without assigning action bindings.
|
||||
//! The default yields to explicit shortcuts; active consumers determine whether a menu opens.
|
||||
|
||||
use super::MAIN_RESERVED_BINDINGS;
|
||||
use super::chords::RuntimeChordKeymap;
|
||||
use super::chords::normalize_chord_binding;
|
||||
use super::configured_context_alias_is_used;
|
||||
use super::resolve_bindings;
|
||||
use crate::key_hint;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
use itertools::Itertools;
|
||||
|
||||
pub(super) fn resolve(config: &TuiKeymap, chords: &mut RuntimeChordKeymap) -> Result<(), String> {
|
||||
let default = key_hint::ctrl(KeyCode::Char('x'));
|
||||
let shadowed = config.global.leader.is_none()
|
||||
&& (configured_context_alias_is_used(config, "ctrl-x")
|
||||
|| chords
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.chord.prefix == default));
|
||||
if config.global.leader.as_ref().is_some_and(|bindings| {
|
||||
bindings
|
||||
.specs()
|
||||
.iter()
|
||||
.any(|spec| spec.as_str().contains(' '))
|
||||
}) {
|
||||
return Err("Invalid `tui.keymap.global.leader`: use a single prefix key.".into());
|
||||
}
|
||||
let defaults = [default];
|
||||
chords.leader = resolve_bindings(
|
||||
config.global.leader.as_ref(),
|
||||
if shadowed { &[] } else { &defaults },
|
||||
"tui.keymap.global.leader",
|
||||
)?
|
||||
.into_iter()
|
||||
.map(normalize_chord_binding)
|
||||
.unique()
|
||||
.collect();
|
||||
|
||||
for prefix in &chords.leader {
|
||||
let (key, modifiers) = prefix.parts();
|
||||
let label = prefix.display_label();
|
||||
if matches!(key, KeyCode::Char(_))
|
||||
&& (!key_hint::has_ctrl_or_alt(modifiers) || key_hint::is_altgr(modifiers))
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid `tui.keymap.global.leader` = `{label}`: use ctrl, alt, or a non-character \
|
||||
key so ordinary text input is not intercepted. Ctrl-alt characters may be AltGr input on Windows."
|
||||
));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
if *prefix == key_hint::ctrl(KeyCode::Char('z')) {
|
||||
return Err(format!(
|
||||
"Invalid `tui.keymap.global.leader` = `{label}`: ctrl-z is reserved for suspending \
|
||||
the terminal on Unix."
|
||||
));
|
||||
}
|
||||
if let Some((reserved_action, _)) = MAIN_RESERVED_BINDINGS
|
||||
.iter()
|
||||
.find(|(_, reserved)| prefix.parts() == reserved.parts())
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid `tui.keymap.global.leader` = `{label}`: this key is reserved by \
|
||||
`{reserved_action}`."
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use pretty_assertions::assert_eq;
|
||||
#[test]
|
||||
fn voice_mute_resolves_custom_bindings_unbinding_and_visible_hints() {
|
||||
for (config, expected) in [
|
||||
("", Some("⌃x")),
|
||||
("", Some("f9")),
|
||||
("[chat]\ntoggle_voice_mute = 'f8'", Some("f8")),
|
||||
("[chat]\ntoggle_voice_mute = []", None),
|
||||
("[chat]\ntoggle_voice_mute = 'ctrl-x m'", Some("⌃x m")),
|
||||
@@ -27,9 +27,9 @@ fn voice_mute_resolves_custom_bindings_unbinding_and_visible_hints() {
|
||||
#[test]
|
||||
fn voice_mute_default_yields_to_existing_shortcuts_and_chord_prefixes() {
|
||||
for config in [
|
||||
"[editor]\nkill_line_end = 'ctrl-x'",
|
||||
"[global]\ncopy = 'ctrl-x'",
|
||||
"[global]\nopen_transcript = 'ctrl-x ctrl-t'",
|
||||
"[editor]\nkill_line_end = 'f9'",
|
||||
"[global]\ncopy = 'f9'",
|
||||
"[global]\nopen_transcript = 'f9 ctrl-t'",
|
||||
] {
|
||||
let config = toml::from_str::<TuiKeymap>(config).unwrap();
|
||||
let runtime = RuntimeKeymap::from_config(&config).expect("existing binding stays valid");
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//!
|
||||
//! The flow is intentionally split into three steps: choose an action, choose
|
||||
//! 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
|
||||
//! key event, a bounded two-stroke chord, or the key following the configured
|
||||
//! leader. Validation reuses 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
|
||||
@@ -249,7 +249,35 @@ pub(crate) fn build_keymap_action_menu_params(
|
||||
));
|
||||
}
|
||||
}
|
||||
if active_binding_count == 0 {
|
||||
if action != "leader" {
|
||||
let leader_available = !runtime_keymap.chords.leader.is_empty();
|
||||
items.push(SelectionItem {
|
||||
name: "Use leader...".to_string(),
|
||||
description: Some("Capture the key that follows the configured leader".to_string()),
|
||||
disabled_reason: (!leader_available).then(|| "The leader is unbound".to_string()),
|
||||
actions: vec![open_capture_action(
|
||||
context.clone(),
|
||||
action.clone(),
|
||||
KeymapEditIntent::ReplaceAll,
|
||||
KeymapCaptureMode::LeaderChord,
|
||||
)],
|
||||
..Default::default()
|
||||
});
|
||||
if !leader_available {
|
||||
items.push(SelectionItem {
|
||||
name: "Configure leader...".to_string(),
|
||||
description: Some("Set a leader before assigning this shortcut".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenKeymapActionMenu {
|
||||
context: "global".to_string(),
|
||||
action: "leader".to_string(),
|
||||
});
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
if action != "leader" && active_binding_count == 0 {
|
||||
items.push(action_menu_item(
|
||||
"Set key chord",
|
||||
"Capture two consecutive keys for this action".to_string(),
|
||||
@@ -258,7 +286,7 @@ pub(crate) fn build_keymap_action_menu_params(
|
||||
KeymapEditIntent::ReplaceAll,
|
||||
KeymapCaptureMode::Chord,
|
||||
));
|
||||
} else {
|
||||
} else if action != "leader" {
|
||||
items.push(action_menu_item(
|
||||
"Replace with key chord",
|
||||
format!("Replace `{current_binding}` with a two-stroke key chord"),
|
||||
@@ -358,6 +386,8 @@ pub(crate) fn build_keymap_replace_binding_menu_params(
|
||||
..Default::default()
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.take(if action == "leader" { 1 } else { 2 })
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -378,7 +408,9 @@ pub(crate) fn build_keymap_conflict_params(
|
||||
error: String,
|
||||
runtime_keymap: &RuntimeKeymap,
|
||||
) -> SelectionViewParams {
|
||||
let capture_mode = if key.contains(' ') {
|
||||
let capture_mode = if key.starts_with("leader ") {
|
||||
KeymapCaptureMode::LeaderChord
|
||||
} else if key.contains(' ') {
|
||||
KeymapCaptureMode::Chord
|
||||
} else {
|
||||
KeymapCaptureMode::SingleKey
|
||||
|
||||
@@ -97,6 +97,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode),
|
||||
action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."),
|
||||
action("global", "Global", "toggle_side_conversation", "Switch between a side conversation and its parent."),
|
||||
action("global", "Global", "leader", "Set the prefix used by shared leader shortcuts."),
|
||||
action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."),
|
||||
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
||||
@@ -272,6 +273,7 @@ pub(super) fn binding_slot<'a>(
|
||||
action: &str,
|
||||
) -> Option<&'a mut Option<KeybindingsSpec>> {
|
||||
match (context, action) {
|
||||
("global", "leader") => Some(&mut keymap.global.leader),
|
||||
("global", "open_agents") => Some(&mut keymap.global.open_agents),
|
||||
("global", "open_transcript") => Some(&mut keymap.global.open_transcript),
|
||||
("global", "find_transcript") => Some(&mut keymap.global.find_transcript),
|
||||
@@ -448,6 +450,23 @@ pub(super) fn format_action_binding_summary(
|
||||
let specs = specs
|
||||
.into_iter()
|
||||
.filter(|spec| seen.insert(spec.clone()))
|
||||
.map(|spec| {
|
||||
let Some(completion) = spec.strip_prefix("leader ") else {
|
||||
return spec;
|
||||
};
|
||||
let resolved = runtime_keymap
|
||||
.chords
|
||||
.leader
|
||||
.iter()
|
||||
.filter_map(|prefix| super::binding_to_config_key_spec(*prefix).ok())
|
||||
.map(|prefix| format!("{prefix} {completion}"))
|
||||
.collect::<Vec<_>>();
|
||||
if resolved.is_empty() {
|
||||
format!("{spec} (inactive)")
|
||||
} else {
|
||||
format!("{spec} ({})", resolved.join(", "))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if specs.is_empty() {
|
||||
"unbound".to_string()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Transient single-key and chord capture inside the shared `/keymap` panel.
|
||||
//! Captures single keys, literal chords, and leader suffixes inside `/keymap`.
|
||||
//!
|
||||
//! Content is measured at the inset width, including wrapped action identifiers.
|
||||
//! Raw key capture remains independent of list navigation bindings.
|
||||
@@ -94,6 +94,9 @@ impl KeymapCaptureView {
|
||||
(KeymapCaptureMode::Chord, Some(first)) => {
|
||||
format!("First key: {first}. Press the second key. Esc cancels.")
|
||||
}
|
||||
(KeymapCaptureMode::LeaderChord, _) => {
|
||||
"Press the key that follows the configured leader. Esc cancels.".to_string()
|
||||
}
|
||||
};
|
||||
lines.extend(
|
||||
textwrap::wrap(&instructions, wrap_width)
|
||||
@@ -150,14 +153,16 @@ impl BottomPaneView for KeymapCaptureView {
|
||||
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
|
||||
let key = match self.capture_mode {
|
||||
KeymapCaptureMode::SingleKey => key,
|
||||
KeymapCaptureMode::LeaderChord => format!("leader {key}"),
|
||||
KeymapCaptureMode::Chord => {
|
||||
let Some(first_stroke) = self.first_stroke.take() else {
|
||||
self.first_stroke = Some(key);
|
||||
return;
|
||||
};
|
||||
format!("{first_stroke} {key}")
|
||||
}
|
||||
};
|
||||
self.app_event_tx.send(AppEvent::KeymapCaptured {
|
||||
context: self.context.clone(),
|
||||
|
||||
@@ -596,6 +596,11 @@ async fn run_session_picker_with_loader(
|
||||
Some(ev) = tui_events.next() => {
|
||||
alt.tui.clipboard.poll();
|
||||
let screen_size = alt.tui.screen_size_for_event(&ev)?;
|
||||
if matches!(&ev, TuiEvent::Paste(_) | TuiEvent::FocusLost)
|
||||
|| matches!(&ev, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved)
|
||||
{
|
||||
state.chord_matcher.cancel();
|
||||
}
|
||||
let ev = if let TuiEvent::Key(key) = ev {
|
||||
let Some(key) = state.route_key_chord(key) else {
|
||||
continue;
|
||||
@@ -1156,6 +1161,7 @@ impl PickerState {
|
||||
if let Overlay::Transcript(view) = &mut overlay {
|
||||
view.set_keymap_bindings(&self.keymap);
|
||||
}
|
||||
self.chord_matcher.cancel();
|
||||
self.overlay = Some(overlay);
|
||||
self.pending_transcript_open = None;
|
||||
self.transcript_loading_frame_shown = false;
|
||||
@@ -1576,6 +1582,7 @@ impl PickerState {
|
||||
self.transcript_cells
|
||||
.insert(thread_id, SessionTranscriptState::Failed);
|
||||
if self.pending_transcript_open == Some(thread_id) {
|
||||
self.chord_matcher.cancel();
|
||||
self.pending_transcript_cancellation = None;
|
||||
self.pending_transcript_open = None;
|
||||
self.transcript_loading_frame_shown = false;
|
||||
@@ -1652,6 +1659,7 @@ impl PickerState {
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
self.chord_matcher.cancel();
|
||||
let base_iter = self
|
||||
.all_rows
|
||||
.iter()
|
||||
|
||||
@@ -83,6 +83,7 @@ impl PickerState {
|
||||
if self.archive_state != (ArchiveState::Pending { thread_id }) {
|
||||
return;
|
||||
}
|
||||
self.chord_matcher.cancel();
|
||||
self.archive_state = ArchiveState::Idle;
|
||||
|
||||
if let Err(error) = result {
|
||||
@@ -154,6 +155,7 @@ impl PickerState {
|
||||
if self.archive_state != (ArchiveState::Restoring { thread_id }) {
|
||||
return None;
|
||||
}
|
||||
self.chord_matcher.cancel();
|
||||
self.archive_state = ArchiveState::Idle;
|
||||
|
||||
match result {
|
||||
|
||||
@@ -4,6 +4,7 @@ expression: snapshot
|
||||
---
|
||||
unbound:
|
||||
Set key | Capture a key for this unbound action | enabled
|
||||
Use leader... | Capture the key that follows the configured leader | 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
|
||||
@@ -11,6 +12,7 @@ Back to shortcuts | Return to the shortcut list | enabled
|
||||
single:
|
||||
Replace binding | Capture a replacement key for `ctrl-enter` | enabled
|
||||
Add alternate binding | Keep `ctrl-enter` and add another key | enabled
|
||||
Use leader... | Capture the key that follows the configured leader | enabled
|
||||
Replace with key chord | Replace `ctrl-enter` with a two-stroke key chord | enabled
|
||||
Add alternate key chord | Keep `ctrl-enter` and add a two-stroke key chord | enabled
|
||||
Remove custom binding | Restore the default keymap binding | enabled
|
||||
@@ -20,6 +22,7 @@ multi:
|
||||
Replace one binding... | Choose which existing binding to replace | enabled
|
||||
Replace all bindings | Replace `ctrl-enter, alt-shift-enter` with one key | enabled
|
||||
Add alternate binding | Keep `ctrl-enter, alt-shift-enter` and add another key | enabled
|
||||
Use leader... | Capture the key that follows the configured leader | enabled
|
||||
Replace with key chord | Replace `ctrl-enter, alt-shift-enter` with a two-stroke key chord | enabled
|
||||
Add alternate key chord | Keep `ctrl-enter, alt-shift-enter` and add a two-stroke key chord | enabled
|
||||
Remove custom binding | Restore the default keymap binding | enabled
|
||||
|
||||
+14
-9
@@ -13,10 +13,11 @@ expression: snapshot
|
||||
|
||||
› 1. Replace binding
|
||||
2. Add alternate binding
|
||||
3. Replace with key chord
|
||||
4. Add alternate key chord
|
||||
3. Use leader...
|
||||
4. Replace with key chord
|
||||
5. Add alternate key chord
|
||||
– Remove custom binding (disabled)
|
||||
5. Back to shortcuts
|
||||
6. Back to shortcuts
|
||||
|
||||
Changes write the root `tui.keymap.*`
|
||||
override.
|
||||
@@ -35,13 +36,16 @@ expression: snapshot
|
||||
for `ctrl-t`
|
||||
2. Add alternate binding Keep `ctrl-t` and add
|
||||
another key
|
||||
3. Replace with key chord Replace `ctrl-t` with a
|
||||
3. Use leader... Capture the key that
|
||||
follows the configured
|
||||
leader
|
||||
4. Replace with key chord Replace `ctrl-t` with a
|
||||
two-stroke key chord
|
||||
4. Add alternate key chord Keep `ctrl-t` and add a
|
||||
5. Add alternate key chord Keep `ctrl-t` and add a
|
||||
two-stroke key chord
|
||||
– Remove custom binding (disabled) No custom root override
|
||||
to remove
|
||||
5. Back to shortcuts Return to the shortcut
|
||||
6. Back to shortcuts Return to the shortcut
|
||||
list
|
||||
|
||||
Changes write the root `tui.keymap.*` override.
|
||||
@@ -58,10 +62,11 @@ expression: snapshot
|
||||
|
||||
› 1. Replace binding Capture a replacement key for `ctrl-t`
|
||||
2. Add alternate binding Keep `ctrl-t` and add another key
|
||||
3. Replace with key chord Replace `ctrl-t` with a two-stroke key chord
|
||||
4. Add alternate key chord Keep `ctrl-t` and add a two-stroke key chord
|
||||
3. Use leader... Capture the key that follows the configured leader
|
||||
4. Replace with key chord Replace `ctrl-t` with a two-stroke key chord
|
||||
5. Add alternate key chord Keep `ctrl-t` and add a two-stroke key chord
|
||||
– Remove custom binding (disabled) No custom root override to remove
|
||||
5. Back to shortcuts Return to the shortcut list
|
||||
6. Back to shortcuts Return to the shortcut list
|
||||
|
||||
Changes write the root `tui.keymap.*` override.
|
||||
enter select · esc back
|
||||
|
||||
+1
-1
@@ -12,5 +12,5 @@ Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the termina
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default
|
||||
Leader | ctrl-x | Global leader Leader Set the prefix used by shared leader shortcuts. ctrl-x Default
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
151 actions, 1 customized, 5 unbound.
|
||||
152 actions, 1 customized, 5 unbound.
|
||||
|
||||
All Common Customized (1) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -21,6 +21,7 @@ expression: "render_picker(params, 120)"
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
Global Leader ctrl-x
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,, shift-down
|
||||
Chat Increase Reasoning Effort alt-., shift-up
|
||||
@@ -30,10 +31,9 @@ expression: "render_picker(params, 120)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat Toggle Voice Mute ctrl-x
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer * Submit ctrl-enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
152 actions, 0 customized, 6 unbound.
|
||||
153 actions, 0 customized, 6 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -22,6 +22,7 @@ expression: "render_picker(params, 120)"
|
||||
Global - Toggle Fast Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
Global Leader ctrl-x
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,, shift-down
|
||||
Chat Increase Reasoning Effort alt-., shift-up
|
||||
@@ -31,9 +32,8 @@ expression: "render_picker(params, 120)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat Toggle Voice Mute ctrl-x
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+3
-3
@@ -2,11 +2,11 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (151 selectable)
|
||||
tab: All (152 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (5) (5 selectable)
|
||||
tab: App (20 selectable)
|
||||
tab: App (21 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (69 selectable)
|
||||
@@ -24,6 +24,6 @@ Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the termina
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default
|
||||
Leader | ctrl-x | Global leader Leader Set the prefix used by shared leader shortcuts. ctrl-x Default
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default
|
||||
Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
151 actions, 1 customized, 6 unbound.
|
||||
152 actions, 1 customized, 6 unbound.
|
||||
|
||||
All Common Customized (1) Unbound (6) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -21,6 +21,7 @@ expression: "render_picker(params, 120)"
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
Global - Leader unbound
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,, shift-down
|
||||
Chat Increase Reasoning Effort alt-., shift-up
|
||||
@@ -30,10 +31,9 @@ expression: "render_picker(params, 120)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat - Toggle Voice Mute unbound
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
151 actions, 0 customized, 5 unbound.
|
||||
152 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor ›
|
||||
|
||||
@@ -21,6 +21,7 @@ expression: "render_picker(params, 78)"
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
Global Leader ctrl-x
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,, shift-down
|
||||
Chat Increase Reasoning Effort alt-., shift-up
|
||||
@@ -30,10 +31,9 @@ expression: "render_picker(params, 78)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat Toggle Voice Mute ctrl-x
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
151 actions, 0 customized, 5 unbound.
|
||||
152 actions, 0 customized, 5 unbound.
|
||||
|
||||
All Common Customized (0) Unbound (5) App Composer Editor Vim Navigation Agents Approval ›
|
||||
|
||||
@@ -21,6 +21,7 @@ expression: "render_picker(params, 120)"
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Global Toggle Side Conversation ctrl-/
|
||||
Global Leader ctrl-x
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,, shift-down
|
||||
Chat Increase Reasoning Effort alt-., shift-up
|
||||
@@ -30,10 +31,9 @@ expression: "render_picker(params, 120)"
|
||||
Chat Prompt Stack Back shift-right, alt-down
|
||||
Chat Skip Question ctrl-]
|
||||
Chat Toggle Voice f8
|
||||
Chat Toggle Voice Mute ctrl-x
|
||||
Chat Toggle Voice Mute f9
|
||||
Composer Submit enter
|
||||
Composer Queue tab
|
||||
Composer Toggle Shortcuts ?, shift-?
|
||||
Composer History Search Previous ctrl-r
|
||||
↓
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: "render_picker(build_keymap_action_menu_params(\"chat\".into(),\n\"toggle_voice\".into(), &RuntimeKeymap::defaults(), &TuiKeymap::default(),),\n80,)"
|
||||
---
|
||||
|
||||
Edit Shortcut
|
||||
Toggle Voice · Chat
|
||||
Current f8 · Default keymap
|
||||
@@ -11,10 +12,12 @@ expression: "render_picker(build_keymap_action_menu_params(\"chat\".into(),\n\"t
|
||||
|
||||
› 1. Replace binding Capture a replacement key for `f8`
|
||||
2. Add alternate binding Keep `f8` and add another key
|
||||
3. Replace with key chord Replace `f8` with a two-stroke key chord
|
||||
4. Add alternate key chord Keep `f8` and add a two-stroke key chord
|
||||
3. Use leader... Capture the key that follows the
|
||||
configured leader
|
||||
4. Replace with key chord Replace `f8` with a two-stroke key chord
|
||||
5. Add alternate key chord Keep `f8` and add a two-stroke key chord
|
||||
– Remove custom binding (disabled) No custom root override to remove
|
||||
5. Back to shortcuts Return to the shortcut list
|
||||
6. Back to shortcuts Return to the shortcut list
|
||||
|
||||
Changes write the root `tui.keymap.*` override.
|
||||
enter select · esc back
|
||||
|
||||
@@ -23,6 +23,11 @@ use crate::tui::TuiEvent;
|
||||
impl StartupDraftPump {
|
||||
pub(super) fn handle_event(&mut self, tui: &mut Tui, event: TuiEvent) -> io::Result<()> {
|
||||
let screen_size = tui.screen_size_for_event(&event)?;
|
||||
if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost)
|
||||
|| matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved)
|
||||
{
|
||||
self.key_chord_matcher.cancel();
|
||||
}
|
||||
if let Some((started_at, mut newlines)) = self.pending_paste_newline.take() {
|
||||
let continues_paste = match &event {
|
||||
TuiEvent::Key(KeyEvent {
|
||||
@@ -151,7 +156,6 @@ impl StartupDraftPump {
|
||||
}
|
||||
}
|
||||
TuiEvent::Paste(text) => {
|
||||
self.key_chord_matcher.cancel();
|
||||
if self.initial_screen == StartupDraftInitialScreen::Composer {
|
||||
self.cancel_submission();
|
||||
self.bottom_pane.flush_composer_paste_burst();
|
||||
@@ -160,7 +164,6 @@ impl StartupDraftPump {
|
||||
}
|
||||
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::Resume | TuiEvent::FocusGained => {}
|
||||
TuiEvent::FocusLost => {
|
||||
self.key_chord_matcher.cancel();
|
||||
self.blossom.borrow_mut().cancel_replay();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -113,6 +113,11 @@ async fn run_startup_hooks_review_app(
|
||||
return Ok(StartupHooksReviewOutcome::Continue);
|
||||
};
|
||||
tui.screen_size_for_event(&event)?;
|
||||
if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost)
|
||||
|| matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved)
|
||||
{
|
||||
chord_matcher.cancel();
|
||||
}
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => {
|
||||
let key_event = match chord_matcher.advance(
|
||||
|
||||
Reference in New Issue
Block a user