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
+4
View File
@@ -143,6 +143,10 @@ pub struct TuiGlobalKeymap {
pub focus_sidebar_content: Option<KeybindingsSpec>,
/// Hide the focused sidebar pane.
pub hide_pane: Option<KeybindingsSpec>,
/// Focus the parent conversation.
pub focus_pane_left: Option<KeybindingsSpec>,
/// Focus the open side conversation.
pub focus_pane_right: Option<KeybindingsSpec>,
/// Open the shared agent-session overview.
pub open_agents: Option<KeybindingsSpec>,
/// Open the transcript overlay.
+20
View File
@@ -5284,6 +5284,8 @@
"find_transcript": null,
"focus_activity": null,
"focus_main_composer": null,
"focus_pane_left": null,
"focus_pane_right": null,
"focus_sidebar_content": null,
"hide_pane": null,
"leader": null,
@@ -6123,6 +6125,22 @@
],
"description": "Focus the main conversation composer."
},
"focus_pane_left": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Focus the parent conversation."
},
"focus_pane_right": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Focus the open side conversation."
},
"focus_sidebar_content": {
"allOf": [
{
@@ -6353,6 +6371,8 @@
"find_transcript": null,
"focus_activity": null,
"focus_main_composer": null,
"focus_pane_left": null,
"focus_pane_right": null,
"focus_sidebar_content": null,
"hide_pane": null,
"leader": null,
+12 -7
View File
@@ -1,7 +1,7 @@
//! Suppress a key held across a view transition from activating the child view.
//! Keep a held activation key from triggering another action.
//!
//! Terminals with enhanced keyboard reporting distinguish a held `Repeat` from a fresh `Press`.
//! Press-only terminals use a bounded window that extends while repeat events continue to arrive.
//! A view transition on a press-only terminal can use a bounded window to block ambiguous presses.
use crate::key_hint::KeyBinding;
use crossterm::event::KeyEventKind;
@@ -11,20 +11,25 @@ use std::time::Instant;
const INITIAL_GUARD: Duration = Duration::from_secs(/*secs*/ 1);
const LEGACY_REPEAT_GUARD: Duration = Duration::from_secs(/*secs*/ 1);
pub(crate) enum ActivationKeySuppression {
ViewTransition,
ReportedRepeats,
}
#[derive(Clone, Copy)]
pub(crate) struct ActivationKeyGuard {
key: KeyBinding,
block_until: Instant,
key_releases_reported: bool,
legacy_repeat_fallback: bool,
active: bool,
}
impl ActivationKeyGuard {
pub(crate) fn new_at(key: KeyBinding, now: Instant, key_releases_reported: bool) -> Self {
pub(crate) fn new_at(key: KeyBinding, now: Instant, reported_repeats_only: bool) -> Self {
Self {
key,
block_until: now.checked_add(INITIAL_GUARD).unwrap_or(now),
key_releases_reported,
legacy_repeat_fallback: !reported_repeats_only,
active: true,
}
}
@@ -43,8 +48,8 @@ impl ActivationKeyGuard {
self.active = false;
return true;
}
// Enhanced keyboard reporting labels a held key as Repeat, so a new Press is fresh.
if self.key_releases_reported && kind == KeyEventKind::Press {
// Without the legacy fallback, only a reported Repeat is treated as held.
if !self.legacy_repeat_fallback && kind == KeyEventKind::Press {
self.active = false;
return false;
}
@@ -6,13 +6,13 @@ use crossterm::event::KeyCode;
fn suppresses_activation_until_release_and_bounds_legacy_presses() {
let start = Instant::now();
let key = key_hint::plain(KeyCode::Enter);
let mut guard = ActivationKeyGuard::new_at(key, start, /*key_releases_reported*/ true);
let mut guard = ActivationKeyGuard::new_at(key, start, /*reported_repeats_only*/ true);
assert!(guard.consume_at(key, KeyEventKind::Repeat, start));
assert!(guard.consume_at(key, KeyEventKind::Release, start));
assert!(!guard.consume_at(key, KeyEventKind::Press, start));
let mut guard = ActivationKeyGuard::new_at(key, start, /*key_releases_reported*/ false);
let mut guard = ActivationKeyGuard::new_at(key, start, /*reported_repeats_only*/ false);
for elapsed in [900, 1_300] {
assert!(guard.consume_at(
key,
@@ -30,7 +30,7 @@ fn suppresses_activation_until_release_and_bounds_legacy_presses() {
let mut guard = ActivationKeyGuard::new_at(
key_hint::plain(KeyCode::BackTab),
start,
/*key_releases_reported*/ true,
/*reported_repeats_only*/ true,
);
let tab = key_hint::plain(KeyCode::Tab);
assert!(guard.consume_at(tab, KeyEventKind::Repeat, start));
+6 -3
View File
@@ -239,11 +239,14 @@ impl App {
fn key_chord_contexts(&self, tui: &tui::Tui) -> crate::keymap::KeymapContextSet {
let contexts = self.active_keymap_contexts();
if !self.main_composer_shortcut_available(tui) {
if !self.conversation_focus_available(tui) {
return contexts;
}
let contexts = contexts.with_main_composer_focus();
if self.side_pane_switch_available() {
let mut contexts = contexts.with_pane_directions();
if self.main_composer_shortcut_available() {
contexts = contexts.with_main_composer_focus();
}
if self.switchable_side_child().is_some() {
contexts.with_side_pane()
} else {
contexts
+10 -8
View File
@@ -322,19 +322,21 @@ impl App {
self.local_settings.workspace_enabled(&self.config) && tui.is_owned_screen()
}
pub(super) fn main_composer_shortcut_available(&self, tui: &tui::Tui) -> bool {
pub(super) fn conversation_focus_available(&self, tui: &tui::Tui) -> bool {
self.side_retention_enabled(tui)
&& self.overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
&& (!self.reconnect.offline || self.active_side_parent_thread_id().is_none())
}
pub(super) fn side_pane_switch_available(&self) -> bool {
!self.reconnect.offline
&& self
.current_displayed_thread_id()
.and_then(|id| self.side_pair_key(id))
.is_some()
pub(super) fn main_composer_shortcut_available(&self) -> bool {
!self.reconnect.offline || self.active_side_parent_thread_id().is_none()
}
pub(super) fn switchable_side_child(&self) -> Option<ThreadId> {
if self.reconnect.offline {
return None;
}
self.side_pair_key(self.current_displayed_thread_id()?)
}
pub(super) fn side_pair_key(&self, thread_id: ThreadId) -> Option<ThreadId> {
+41 -14
View File
@@ -2,6 +2,7 @@
//! immediately, but Park and Finish transfer transcript views through the app event queue.
use super::*;
use crate::activation_key_guard::ActivationKeySuppression;
use crate::transcript_view::TranscriptView;
impl App {
@@ -12,42 +13,68 @@ impl App {
key: KeyEvent,
) -> bool {
let bindings = &self.keymap.app;
let main = bindings.focus_main_composer.is_pressed(key);
let left = bindings.focus_pane_left.is_pressed(key);
let right = bindings.focus_pane_right.is_pressed(key);
let main =
bindings.focus_main_composer.is_pressed(key) && self.main_composer_shortcut_available();
let sidebar = bindings.focus_sidebar_content.is_pressed(key);
if !(main || sidebar || bindings.hide_pane.is_pressed(key))
|| !self.main_composer_shortcut_available(tui)
|| (!main && !self.side_pane_switch_available())
{
let hide = bindings.hide_pane.is_pressed(key);
if !(left || right || main || sidebar || hide) || !self.conversation_focus_available(tui) {
return false;
}
let child = self.switchable_side_child();
if (sidebar || hide) && child.is_none() {
return false;
}
if key.kind != KeyEventKind::Press {
// A matching repeat still belongs to the shortcut. Keep it out of the focused editor.
return true;
}
let activation = self
.routed_key_activation
.take()
.unwrap_or_else(|| KeyBinding::from_event(key));
let result = if main {
let previous_thread = self.current_displayed_thread_id();
let on_child = child.is_some_and(|id| previous_thread == Some(id));
let leaves_transcript = main
&& !on_child
&& (self.transcript_view.has_active_interaction()
|| self.backtrack.overlay_preview_active);
let result = if main || (left && on_child) {
self.focus_main_composer(tui, app_server).await
} else if sidebar
&& let Some(parent) = self.current_displayed_thread_id()
&& let Some(child) = self.side_pair_key(parent)
&& child != parent
} else if (sidebar || right)
&& !on_child
&& let Some(child) = child
&& (sidebar || self.side_threads[&child].pane_open)
{
let result = self.toggle_side_conversation(tui, app_server).await;
let result = self.select_agent_thread(tui, app_server, child).await;
if result.is_ok() && self.current_displayed_thread_id() == Some(child) {
// The outgoing parent's view is still here until Park runs.
self.focus_current_parent_view(tui);
}
result
} else {
} else if hide || sidebar {
self.hide_focused_side_pane(tui, app_server).await
} else {
// A direction with no reachable neighbor leaves the current interaction intact.
Ok(())
};
if let Err(err) = result {
self.chat_widget.add_error_message(err.to_string());
}
self.chat_widget
.suppress_current_view_key_until_release(activation, Instant::now());
let view_changed =
leaves_transcript || self.current_displayed_thread_id() != previous_thread;
let suppression = if view_changed {
ActivationKeySuppression::ViewTransition
} else {
// A no-op leaves the editor in place, so prefer accepting fresh input.
ActivationKeySuppression::ReportedRepeats
};
self.chat_widget.suppress_current_view_key_until_release(
activation,
Instant::now(),
suppression,
);
true
}
+63 -4
View File
@@ -3323,6 +3323,11 @@ async fn docked_replay_finishes_while_skills_refresh_is_pending() -> Result<()>
#[tokio::test]
async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
// Both pane footers should fit the same way on Unix and Windows.
std::fs::write(
&app.local_settings.user_config_path,
"[tui]\nstatus_line = ['model-with-reasoning', 'thread-name']\n",
)?;
let config = app.chat_widget.config_ref().clone();
let parent_thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
@@ -3652,8 +3657,10 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
if width == 100 {
app.pending_server_profiles
.insert(side_thread_id, read_only.clone());
keys!(prefix, main);
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
for completion in [main, KeyCode::Left.into()] {
keys!(prefix, completion);
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
}
assert!(app.transcript_view.is_select_mode());
assert!(
app.side_companions[&parent_thread_id]
@@ -3684,6 +3691,17 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
app.chat_widget.composer_text_with_pending(),
"parent draft-"
);
// With the pane hidden, right must leave both the Find text and its caret alone.
app.transcript_view.begin_search();
assert!(app.transcript_view.paste_search(parent_row));
let (_, end) = app.transcript_view.search_footer(width).unwrap();
keys!(KeyCode::Left.into());
let footer = app.transcript_view.search_footer(width).unwrap();
assert!(footer.1 < end);
keys!(prefix, KeyCode::Right.into());
assert_eq!(app.transcript_view.search_footer(width), Some(footer));
assert!(!app.side_threads[&side_thread_id].pane_open);
app.transcript_view.cancel_search();
boxed_future!(app.handle_start_side(
&mut tui,
&mut app_server,
@@ -3739,8 +3757,10 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
app.pending_server_profiles
.insert(parent_thread_id, read_only);
keys!(prefix, side);
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
for completion in [side, KeyCode::Right.into()] {
keys!(prefix, completion);
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
}
assert!(app.transcript_view.is_select_mode());
app.pending_server_profiles.remove(&parent_thread_id);
keys!(prefix, side);
@@ -3753,6 +3773,45 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
);
app.transcript_view.end_select_mode();
// At 99 columns only the focused conversation is painted, but both panes are still open.
side_frame(&mut app, &mut tui, Size::new(/*width*/ 99, /*height*/ 18))?;
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
keys!(KeyCode::Char('v').into(), KeyCode::Right.into());
let selected = app.transcript_view.selected_text(&app.transcript_cells);
assert!(selected.as_deref().is_some_and(|text| !text.is_empty()));
keys!(prefix, KeyCode::Right.into());
assert_eq!(
app.transcript_view.selected_text(&app.transcript_cells),
selected
);
keys!(prefix, KeyCode::Char('h').into());
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
assert!(app.side_threads[&side_thread_id].pane_open);
side_frame(&mut app, &mut tui, Size::new(/*width*/ 99, /*height*/ 18))?;
assert!(app.transcript_view.begin_select_mode(&app.transcript_cells));
keys!(KeyCode::Char('v').into(), KeyCode::Right.into());
let parent_text = app.transcript_view.selected_text(&app.transcript_cells);
assert!(parent_text.as_deref().is_some_and(|text| !text.is_empty()));
keys!(prefix, KeyCode::Left.into());
assert_eq!(
app.transcript_view.selected_text(&app.transcript_cells),
parent_text
);
keys!(prefix, KeyCode::Char('l').into());
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
assert_eq!(
app.transcript_view.selected_text(&app.transcript_cells),
selected
);
app.transcript_view.end_select_mode();
// Ctrl-/ preserves interaction, so it exposes whether right actually cleaned up the parent.
keys!(toggle);
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
assert!(!app.transcript_view.has_active_interaction());
keys!(toggle);
read_side_history(&mut app, &mut tui, "child")?;
// The upload finishes before the app event loop can park the now-inactive child.
app.app_event_tx.send(AppEvent::FeedbackSubmitted {
+45
View File
@@ -1164,6 +1164,37 @@ async fn selection_after_live_commit_uses_the_refreshed_frame() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn pane_direction_without_neighbor_preserves_fresh_typing() -> Result<()> {
let (mut app, mut tui, mut app_server) = chord_app().await?;
super::enable_side_retention(&mut app)?;
tui.set_owned_screen(/*owned*/ true)?;
press(&mut app, &mut tui, &mut app_server, ctrl('x')).await?;
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char('h').into(),
)
.await?;
for ch in "hello".chars() {
press(
&mut app,
&mut tui,
&mut app_server,
KeyCode::Char(ch).into(),
)
.await?;
}
// Flush the burst of typed characters before inspecting the draft.
press(&mut app, &mut tui, &mut app_server, KeyCode::End.into()).await?;
assert_eq!(app.chat_widget.composer_text_with_pending(), "hello");
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> {
let (mut app, mut rx, _op_rx) = super::make_test_app_with_channels().await;
@@ -1182,6 +1213,8 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> {
("[global]\nhide_pane = 'f12'", 12),
("[global]\nfocus_main_composer = 'f12'", 12),
("[global]\nfocus_sidebar_content = 'f12 s'", 12),
("[global]\nfocus_pane_left = 'f12 h'", 12),
("[global]\nfocus_pane_right = 'f12 l'", 12),
] {
app.keymap =
RuntimeKeymap::for_session(&toml::from_str(config)?, /*workspace*/ true).unwrap();
@@ -1242,5 +1275,17 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> {
press(&mut app, &mut tui, &mut stopped, key.into()).await?;
}
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft!%");
// Bare letters and arrows still edit the draft.
for key in [
KeyCode::Char('h'),
KeyCode::Left,
KeyCode::Char('l'),
KeyCode::Right,
KeyCode::Char('!'),
KeyCode::Right,
] {
press(&mut app, &mut tui, &mut stopped, key.into()).await?;
}
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft!%lh!");
Ok(())
}
+15 -7
View File
@@ -29,6 +29,8 @@ pub(crate) use footer::inset_footer_hint_area;
use std::collections::VecDeque;
use std::path::PathBuf;
use crate::activation_key_guard::ActivationKeyGuard;
use crate::activation_key_guard::ActivationKeySuppression;
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
@@ -2025,13 +2027,15 @@ impl BottomPane {
&mut self,
key: KeyBinding,
activated_at: Instant,
suppression: ActivationKeySuppression,
) {
self.view_transition_key_guard =
Some(crate::activation_key_guard::ActivationKeyGuard::new_at(
key,
activated_at,
self.enhanced_keys_supported,
));
let reported_repeats_only = self.enhanced_keys_supported
|| matches!(suppression, ActivationKeySuppression::ReportedRepeats);
self.view_transition_key_guard = Some(ActivationKeyGuard::new_at(
key,
activated_at,
reported_repeats_only,
));
}
pub(crate) fn consume_view_transition_key_event(&mut self, key_event: KeyEvent) -> bool {
@@ -2969,7 +2973,11 @@ mod tests {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let mut pane = test_pane(AppEventSender::new(tx_raw));
let activation_key = crate::key_hint::plain(KeyCode::Enter);
pane.suppress_current_view_key_until_release(activation_key, Instant::now());
pane.suppress_current_view_key_until_release(
activation_key,
Instant::now(),
ActivationKeySuppression::ViewTransition,
);
pane.push_view(Box::new(DismissibleView::default()));
assert!(pane.consume_view_transition_key(activation_key, KeyEventKind::Repeat));
+6 -1
View File
@@ -1,6 +1,7 @@
//! Entry points for the configuration browser and its existing child editors.
use super::ChatWidget;
use crate::activation_key_guard::ActivationKeySuppression;
use crate::config_panel::ConfigEditor;
use crate::config_panel::ConfigEditorHandoff;
use crate::config_panel::ConfigPanel;
@@ -69,7 +70,11 @@ impl ChatWidget {
}
}
if let Some(activated_at) = handoff.active_at() {
self.suppress_current_view_key_until_release(handoff.activation_key, activated_at);
self.suppress_current_view_key_until_release(
handoff.activation_key,
activated_at,
ActivationKeySuppression::ViewTransition,
);
}
self.bottom_pane.config_editor_opened();
}
+3 -1
View File
@@ -2,6 +2,7 @@
use super::clipboard::PendingCopy;
use super::*;
use crate::activation_key_guard::ActivationKeySuppression;
use crate::bottom_pane::BottomPaneView;
use crate::clipboard_copy::CopyStatus;
use crate::clipboard_copy::worker::CopyResult;
@@ -91,9 +92,10 @@ impl ChatWidget {
&mut self,
key: KeyBinding,
activated_at: Instant,
suppression: ActivationKeySuppression,
) {
self.bottom_pane
.suppress_current_view_key_until_release(key, activated_at);
.suppress_current_view_key_until_release(key, activated_at, suppression);
}
pub(crate) fn consume_view_transition_key_event(&mut self, key_event: KeyEvent) -> bool {
+1 -1
View File
@@ -587,7 +587,7 @@ impl ChatWidget {
Some(crate::activation_key_guard::ActivationKeyGuard::new_at(
KeyBinding::from_event(key),
Instant::now(),
/*key_releases_reported*/ false,
/*reported_repeats_only*/ false,
));
}
consumed
+94 -2
View File
@@ -107,6 +107,8 @@ pub(crate) struct AppKeymap {
pub(crate) focus_main_composer: Vec<KeyBinding>,
pub(crate) focus_sidebar_content: Vec<KeyBinding>,
pub(crate) hide_pane: Vec<KeyBinding>,
pub(crate) focus_pane_left: Vec<KeyBinding>,
pub(crate) focus_pane_right: Vec<KeyBinding>,
/// Open the agent command center.
pub(crate) open_agents: Vec<KeyBinding>,
/// Open transcript overlay.
@@ -690,6 +692,19 @@ impl RuntimeKeymap {
}
fn resolve(keymap: &TuiKeymap, workspace: bool) -> Result<Self, String> {
// Inactive pane shortcuts must not claim keys or chord prefixes in classic mode.
let mut classic_keymap;
let keymap = if workspace {
keymap
} else {
classic_keymap = keymap.clone();
classic_keymap.global.focus_main_composer = None;
classic_keymap.global.focus_sidebar_content = None;
classic_keymap.global.hide_pane = None;
classic_keymap.global.focus_pane_left = None;
classic_keymap.global.focus_pane_right = None;
&classic_keymap
};
let defaults = Self::built_in_defaults();
let chords = Arc::new(RuntimeChordKeymap::from_config(keymap)?);
let side_toggle_default_is_shadowed = keymap.global.toggle_side_conversation.is_none()
@@ -756,6 +771,16 @@ impl RuntimeKeymap {
&[],
"tui.keymap.global.hide_pane",
)?,
focus_pane_left: resolve_bindings(
keymap.global.focus_pane_left.as_ref(),
&[],
"tui.keymap.global.focus_pane_left",
)?,
focus_pane_right: resolve_bindings(
keymap.global.focus_pane_right.as_ref(),
&[],
"tui.keymap.global.focus_pane_right",
)?,
open_agents: resolve_bindings(
keymap.global.open_agents.as_ref(),
&defaults.app.open_agents,
@@ -1734,6 +1759,8 @@ impl RuntimeKeymap {
focus_main_composer: Vec::new(),
focus_sidebar_content: Vec::new(),
hide_pane: Vec::new(),
focus_pane_left: Vec::new(),
focus_pane_right: Vec::new(),
open_agents: default_bindings![],
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
find_transcript: default_bindings![plain(KeyCode::F(3))],
@@ -2090,6 +2117,8 @@ impl RuntimeKeymap {
"global.focus_sidebar_content",
&self.app.focus_sidebar_content,
),
("global.focus_pane_left", &self.app.focus_pane_left),
("global.focus_pane_right", &self.app.focus_pane_right),
("global.hide_pane", &self.app.hide_pane),
("chat.open_presets", &self.chat.open_presets),
("chat.toggle_voice", &self.chat.toggle_voice),
@@ -2128,6 +2157,8 @@ impl RuntimeKeymap {
"focus_sidebar_content",
self.app.focus_sidebar_content.as_slice(),
),
("focus_pane_left", self.app.focus_pane_left.as_slice()),
("focus_pane_right", self.app.focus_pane_right.as_slice()),
("hide_pane", self.app.hide_pane.as_slice()),
("open_agents", self.app.open_agents.as_slice()),
("open_transcript", self.app.open_transcript.as_slice()),
@@ -2305,6 +2336,8 @@ impl RuntimeKeymap {
"focus_sidebar_content",
self.app.focus_sidebar_content.as_slice(),
),
("focus_pane_left", self.app.focus_pane_left.as_slice()),
("focus_pane_right", self.app.focus_pane_right.as_slice()),
("hide_pane", self.app.hide_pane.as_slice()),
("open_agents", self.app.open_agents.as_slice()),
("open_transcript", self.app.open_transcript.as_slice()),
@@ -2439,6 +2472,14 @@ impl RuntimeKeymap {
"global.focus_sidebar_content",
self.app.focus_sidebar_content.as_slice(),
),
(
"global.focus_pane_left",
self.app.focus_pane_left.as_slice(),
),
(
"global.focus_pane_right",
self.app.focus_pane_right.as_slice(),
),
("global.hide_pane", self.app.hide_pane.as_slice()),
(
"global.select_transcript",
@@ -2480,6 +2521,14 @@ impl RuntimeKeymap {
"global.focus_sidebar_content",
self.app.focus_sidebar_content.as_slice(),
),
(
"global.focus_pane_left",
self.app.focus_pane_left.as_slice(),
),
(
"global.focus_pane_right",
self.app.focus_pane_right.as_slice(),
),
("global.hide_pane", self.app.hide_pane.as_slice()),
("global.focus_activity", self.app.focus_activity.as_slice()),
(
@@ -3947,6 +3996,8 @@ mod tests {
("hide_pane", "list", "move_down"),
("focus_main_composer", "pager", "page_up"),
("focus_sidebar_content", "list", "move_down"),
("focus_pane_left", "list", "move_down"),
("focus_pane_right", "pager", "page_up"),
] {
let config: TuiKeymap = toml::from_str(&format!(
"[global]\n{pane} = 'f8'\n[{section}]\n{action} = 'f8'"
@@ -3959,7 +4010,13 @@ mod tests {
);
}
#[cfg(unix)]
for action in ["hide_pane", "focus_main_composer", "focus_sidebar_content"] {
for action in [
"hide_pane",
"focus_main_composer",
"focus_sidebar_content",
"focus_pane_left",
"focus_pane_right",
] {
let config: TuiKeymap =
toml::from_str(&format!("[global]\n{action} = 'ctrl-z'")).unwrap();
assert!(
@@ -3997,6 +4054,24 @@ mod tests {
assert!(runtime.app.toggle_side_conversation.is_empty());
}
let mut keymap = TuiKeymap::default();
keymap.global.open_transcript = Some(one("leader left"));
keymap.global.focus_pane_right = Some(one("f12 b"));
let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap();
for (action, expected) in [
("focus_pane_left", "leader h"),
("focus_pane_right", "f12 b"),
] {
let installed: Vec<_> = runtime
.chords
.bindings
.iter()
.filter(|binding| binding.action.action == action)
.map(|binding| binding.spec.as_str())
.collect();
assert_eq!(installed, [expected], "{action}");
}
let mut keymap = TuiKeymap::default();
keymap.global.open_transcript = Some(one("ctrl-/"));
keymap.global.copy = Some(one("ctrl-7"));
@@ -4006,6 +4081,22 @@ mod tests {
assert!(runtime.app.toggle_side_conversation.is_empty());
}
#[test]
fn pane_binding_only_shadows_side_toggle_in_workspace_sessions() {
let keymap = toml::from_str("[global]\nfocus_pane_right = 'ctrl-/'").unwrap();
let classic = RuntimeKeymap::for_session(&keymap, /*workspace*/ false).unwrap();
assert_eq!(
classic.app.toggle_side_conversation,
RuntimeKeymap::defaults().app.toggle_side_conversation
);
let workspace = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap();
assert!(workspace.app.toggle_side_conversation.is_empty());
assert_eq!(
workspace.app.focus_pane_right,
vec![key_hint::ctrl(KeyCode::Char('/'))]
);
}
#[test]
fn explicit_side_toggle_rejects_legacy_ctrl_slash_alias_conflicts() {
let mut keymap = TuiKeymap::default();
@@ -4162,8 +4253,9 @@ mod tests {
assert!(runtime.composer.toggle_shortcuts.is_empty());
keymap.global.hide_pane = Some(KeybindingsSpec::Many(vec![]));
keymap.global.focus_sidebar_content = Some(KeybindingsSpec::Many(vec![]));
keymap.global.focus_pane_right = Some(KeybindingsSpec::Many(vec![]));
let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap();
for action in ["hide_pane", "focus_sidebar_content"] {
for action in ["hide_pane", "focus_sidebar_content", "focus_pane_right"] {
assert!(
runtime
.primary_hint(KeymapContext::Global, action)
+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]
+2
View File
@@ -121,6 +121,8 @@ define_keymap_actions! {
focus_main_composer [Workspace] => "Focus the main conversation composer.",
focus_sidebar_content [Workspace] => "Focus the side conversation, or hide it when already focused.",
hide_pane [Workspace] => "Hide the focused sidebar pane.",
focus_pane_left [Workspace] => "Focus the parent conversation.",
focus_pane_right [Workspace] => "Focus the open side conversation.",
open_agents => "Open the agent command center.",
open_transcript => "Open the transcript overlay.",
find_transcript => "Find text in the full transcript.",
+1 -1
View File
@@ -441,7 +441,7 @@ impl AuthModeWidget {
self.activation_key_guard = Some(ActivationKeyGuard::new_at(
KeyBinding::from_event(key_event),
Instant::now(),
/*key_releases_reported*/ false,
/*reported_repeats_only*/ false,
));
}
self.handle_sign_in_option(option);
@@ -19,7 +19,7 @@ parent-29
› parent draft
GPT-6.1-Sol default · /tmp/project · Main [default]
GPT-6.1-Sol default · Main [default]
New activity · esc latest
100 columns, reopened
@@ -39,7 +39,7 @@ parent-29 │ child-29
│
› parent draft- │ › child draft!
│
GPT-6.1-Sol default · /tmp/project │ GPT-6.1-Sol default · /tmp/projec…
GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent
│ Side from main thread · ⌃/ to swit
99 columns, reopened
@@ -59,5 +59,5 @@ child-29
› child draft!
GPT-6.1-Sol default · /tmp/project · Agent
GPT-6.1-Sol default · Agent
Side from main thread · ⌃/ to switch · ⌃c to close
@@ -18,4 +18,4 @@ parent-29 │ child-29
│
› parent draft │ › child draft!
│
GPT-6.1-Sol default · /tmp/project · Main [default] │ GPT-6.1-Sol default · /tmp/project
GPT-6.1-Sol default · Main [default] │ GPT-6.1-Sol default
@@ -5,6 +5,8 @@ expression: snapshot
Focus Main Composer | leader c (ctrl-x c) | Global focus_main_composer Focus Main Composer Focus the main conversation composer. leader c (ctrl-x c) Default
Focus Sidebar Content | leader s (ctrl-x s) | Global focus_sidebar_content Focus Sidebar Content Focus the side conversation, or hide it when already focused. leader s (ctrl-x s) Default
Hide Pane | leader - (ctrl-x -) | Global hide_pane Hide Pane Hide the focused sidebar pane. leader - (ctrl-x -) Default
Focus Pane Left | leader left (ctrl-x left), leader h (ctrl-x h) | Global focus_pane_left Focus Pane Left Focus the parent conversation. leader left (ctrl-x left), leader h (ctrl-x h) Default
Focus Pane Right | leader right (ctrl-x right), leader l (ctrl-x l) | Global focus_pane_right Focus Pane Right Focus the open side conversation. leader right (ctrl-x right), leader l (ctrl-x l) Default
Open Agents | unbound | Global open_agents Open Agents Open the agent command center. unbound Default
Open Transcript | ctrl-t | Global open_transcript Open Transcript Open the transcript overlay. ctrl-t Default
Find Transcript | f3 | Global find_transcript Find Transcript Find text in the full transcript. f3 Default
@@ -13,5 +15,3 @@ Open External Editor | ctrl-g | Global open_external_editor Open External Editor
Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. ctrl-o Default
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
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
+3 -2
View File
@@ -1314,6 +1314,7 @@ fn user_prompt_has_a_background_without_a_terminal_palette() {
#[test]
fn spoken_prompt_selection_keeps_its_fallback_contrast() {
use crate::history_cell::new_spoken_user_prompt;
use crate::terminal_palette::rgb_color;
use ratatui::style::Color;
use ratatui::style::Modifier;
@@ -1352,8 +1353,8 @@ fn spoken_prompt_selection_keeps_its_fallback_contrast() {
(
Some("one\n"),
Color::Red,
Color::Rgb(238, 238, 238),
Color::Rgb(68, 68, 68),
rgb_color((238, 238, 238)),
rgb_color((68, 68, 68)),
true
)
);