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:
Felipe Coury
2026-10-08 21:01:06 +00:00
committed by copyberry
parent 39f4277087
commit a3f3b44cd8
42 changed files with 438 additions and 100 deletions
+13
View File
@@ -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)
+10
View File
@@ -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);
+3 -1
View File
@@ -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();
}
+18 -6
View File
@@ -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
+6
View File
@@ -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);
+1 -1
View File
@@ -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.
@@ -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
+2 -1
View File
@@ -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) {
+10
View File
@@ -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
");
}
@@ -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
+5 -1
View File
@@ -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();
}
@@ -10,7 +10,7 @@ expression: rendered
" "
" "
" "
" voice ● listening ⌃x mute /voice stop "
" voice ● listening f9 mute /voice stop "
" "
"› Ask Codex to do anything "
" "
@@ -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 "
" "
+7
View File
@@ -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)
{
+6 -5
View File
@@ -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![
+1
View File
@@ -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)) => {
+121 -15
View File
@@ -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!(
+70
View File
@@ -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(())
}
+4 -4
View File
@@ -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");
+37 -5
View File
@@ -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
+19
View File
@@ -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()
+14 -9
View File
@@ -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(),
+8
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+5 -2
View File
@@ -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(());
}
+5
View File
@@ -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(