Add directional conversation focus shortcuts in workspace mode (#59287)

## What changed

- Add configurable `focus_pane_left` and `focus_pane_right` actions, defaulting to `leader left` / `leader h` and `leader right` / `leader l`.
- Move focus to the parent or open side conversation without reopening a hidden pane. Preserve the current interaction when no neighbor is available, and accept fresh typing after a no-op shortcut while suppressing reported key repeats.
- Expose the actions in the keymap picker and configuration schema, and prevent workspace-only pane bindings from claiming keys in classic mode.

## Testing

Add regression coverage for pane navigation, preserved transcript selection and search, fresh typing after a no-op, key capture, and binding conflicts and limits.

GitOrigin-RevId: 7f7629d15cffcd35d1353b36c5c9208dbb1034e8
This commit is contained in:
Felipe Coury
2026-11-04 01:40:12 +00:00
committed by copyberry
parent 0a944f8ce2
commit db3c44414c
24 changed files with 401 additions and 74 deletions
+7 -1
View File
@@ -107,7 +107,11 @@ impl KeymapActionId {
self.context == KeymapContext::Global
&& matches!(
self.action,
"focus_main_composer" | "focus_sidebar_content" | "hide_pane"
"focus_main_composer"
| "focus_sidebar_content"
| "hide_pane"
| "focus_pane_left"
| "focus_pane_right"
)
}
@@ -306,6 +310,8 @@ define_runtime_action_bindings! {
focus_main_composer,
focus_sidebar_content,
hide_pane,
focus_pane_left,
focus_pane_right,
open_warnings => runtime_only,
open_external_editor,
copy,
+9 -4
View File
@@ -56,6 +56,7 @@ const TRANSCRIPT_ACTIONS: u32 = 1 << 20;
const BACKEND_CYCLING: u32 = 1 << 21;
const SIDE_PANE: u32 = 1 << 22;
const MAIN_COMPOSER_FOCUS: u32 = 1 << 23;
const PANE_DIRECTION: u32 = 1 << 24;
const TRANSCRIPT_BROWSING: u32 = 1 << 14;
@@ -158,6 +159,10 @@ impl KeymapContextSet {
}
}
pub(crate) const fn with_pane_directions(self) -> Self {
Self(self.0 | PANE_DIRECTION)
}
pub(crate) const fn with_main_composer_focus(self) -> Self {
Self(self.0 | MAIN_COMPOSER_FOCUS)
}
@@ -172,10 +177,10 @@ impl KeymapContextSet {
return self.0 & BACKEND_CYCLING != 0;
}
if action.is_side_pane() {
let required = if action.action == "focus_main_composer" {
MAIN_COMPOSER_FOCUS
} else {
SIDE_PANE
let required = match action.action {
"focus_pane_left" | "focus_pane_right" => PANE_DIRECTION,
"focus_main_composer" => MAIN_COMPOSER_FOCUS,
_ => SIDE_PANE,
};
return self.0 & required != 0;
}
@@ -26,15 +26,20 @@ pub(in crate::keymap) fn register(
.filter(|binding| binding.is_leader())
.count(),
);
use KeyCode::Char;
use KeymapContext::Chat;
use KeymapContext::Global;
for (context, name, completion) in [
(Global, "change_host", 'b'),
(Global, "select_transcript", '['),
(Chat, "open_presets", 'p'),
(Global, "focus_main_composer", 'c'),
(Global, "focus_sidebar_content", 's'),
(Global, "hide_pane", '-'),
for (context, name, completion, spec) in [
(Global, "change_host", Char('b'), "b"),
(Global, "select_transcript", Char('['), "["),
(Chat, "open_presets", Char('p'), "p"),
(Global, "focus_main_composer", Char('c'), "c"),
(Global, "focus_sidebar_content", Char('s'), "s"),
(Global, "hide_pane", Char('-'), "-"),
(Global, "focus_pane_left", KeyCode::Left, "left"),
(Global, "focus_pane_left", Char('h'), "h"),
(Global, "focus_pane_right", KeyCode::Right, "right"),
(Global, "focus_pane_right", Char('l'), "l"),
] {
if remaining == 0 {
break;
@@ -56,9 +61,9 @@ pub(in crate::keymap) fn register(
action,
chord: KeyChord {
prefix,
completion: crate::key_hint::plain(KeyCode::Char(completion)),
completion: crate::key_hint::plain(completion),
},
spec: format!("leader {completion}"),
spec: format!("leader {spec}"),
};
if !resolved.chords.bindings.iter().any(|existing| {
binding.overlaps(existing.action) && existing.chord == binding.chord
+36
View File
@@ -187,6 +187,42 @@ fn allows_shared_prefixes_with_distinct_completions() {
config.global.copy = Some(binding("ctrl-x ctrl-o"));
RuntimeKeymap::from_config(&config).expect("prefix trie can branch on the second stroke");
// With 16 leaders and 56 explicit completions, only eight more aliases fit per leader.
let mut config = TuiKeymap::default();
config.global.leader = Some(KeybindingsSpec::Many(
(1..=16)
.map(|n| KeybindingSpec(format!("super-f{n}")))
.collect(),
));
config.global.open_transcript = Some(KeybindingsSpec::Many(
["", "shift-", "alt-"]
.into_iter()
.flat_map(|modifier| {
(1..=19).map(move |n| KeybindingSpec(format!("leader {modifier}f{n}")))
})
.take(56)
.collect(),
));
let runtime = RuntimeKeymap::for_session(&config, /*workspace*/ true).unwrap();
assert_eq!(
runtime
.chords
.bindings
.iter()
.filter(|b| b.is_leader())
.count(),
super::MAX_LEADER_CHORD_BINDINGS
);
assert_eq!(
crate::keymap_setup::active_binding_specs(&runtime, "global", "focus_pane_left").unwrap(),
["leader left", "leader h"]
);
assert!(
runtime
.primary_hint(KeymapContext::Global, "focus_pane_right")
.is_none()
);
}
#[test]