From db3c44414c2cd4ae33adaf4fa33382cb3dde6bd5 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 4 Nov 2026 01:27:49 +0000 Subject: [PATCH] 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 --- codex-rs/config/src/tui_keymap.rs | 4 + codex-rs/core/config.schema.json | 20 ++++ codex-rs/tui/src/activation_key_guard.rs | 19 ++-- .../tui/src/activation_key_guard_tests.rs | 6 +- codex-rs/tui/src/app/input.rs | 9 +- codex-rs/tui/src/app/side.rs | 18 ++-- codex-rs/tui/src/app/side_focus.rs | 55 ++++++++--- codex-rs/tui/src/app/tests.rs | 67 ++++++++++++- codex-rs/tui/src/app/tests/key_chords.rs | 45 +++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 22 +++-- codex-rs/tui/src/chatwidget/config_panel.rs | 7 +- codex-rs/tui/src/chatwidget/interaction.rs | 4 +- codex-rs/tui/src/chatwidget/quick_dials.rs | 2 +- codex-rs/tui/src/keymap.rs | 96 ++++++++++++++++++- codex-rs/tui/src/keymap/bindings.rs | 8 +- codex-rs/tui/src/keymap/chords.rs | 13 ++- .../tui/src/keymap/chords/leader_defaults.rs | 23 +++-- codex-rs/tui/src/keymap/chords_tests.rs | 36 +++++++ codex-rs/tui/src/keymap_setup/actions.rs | 2 + codex-rs/tui/src/onboarding/auth.rs | 2 +- ...ts__hide_and_reopen_side_conversation.snap | 6 +- ...dex_tui__app__tests__single_side_chat.snap | 2 +- ...__tests__keymap_picker_all_tab_search.snap | 4 +- codex-rs/tui/src/transcript_view_tests.rs | 5 +- 24 files changed, 401 insertions(+), 74 deletions(-) diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index ee4dcd6c7c..b9e508fb30 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -143,6 +143,10 @@ pub struct TuiGlobalKeymap { pub focus_sidebar_content: Option, /// Hide the focused sidebar pane. pub hide_pane: Option, + /// Focus the parent conversation. + pub focus_pane_left: Option, + /// Focus the open side conversation. + pub focus_pane_right: Option, /// Open the shared agent-session overview. pub open_agents: Option, /// Open the transcript overlay. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bb7c7553a4..8edec6531b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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, diff --git a/codex-rs/tui/src/activation_key_guard.rs b/codex-rs/tui/src/activation_key_guard.rs index 80de51f14b..dc44415ea3 100644 --- a/codex-rs/tui/src/activation_key_guard.rs +++ b/codex-rs/tui/src/activation_key_guard.rs @@ -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; } diff --git a/codex-rs/tui/src/activation_key_guard_tests.rs b/codex-rs/tui/src/activation_key_guard_tests.rs index 4dc347dbaf..5912352c96 100644 --- a/codex-rs/tui/src/activation_key_guard_tests.rs +++ b/codex-rs/tui/src/activation_key_guard_tests.rs @@ -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)); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index aaae88b7bd..df71a2a166 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -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 diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index e23614ede4..cc89a77464 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -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 { + 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 { diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs index a5a2303401..ffaeae3455 100644 --- a/codex-rs/tui/src/app/side_focus.rs +++ b/codex-rs/tui/src/app/side_focus.rs @@ -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 } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d6e186086a..b698b65d91 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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 { diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 5258bc23b7..9b5acde43e 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -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(()) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7b21db9d3a..5f5e90dd14 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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::(); 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)); diff --git a/codex-rs/tui/src/chatwidget/config_panel.rs b/codex-rs/tui/src/chatwidget/config_panel.rs index c204965d1e..1fce6907c5 100644 --- a/codex-rs/tui/src/chatwidget/config_panel.rs +++ b/codex-rs/tui/src/chatwidget/config_panel.rs @@ -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(); } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 6fa70a9cfb..a3f261616b 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -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 { diff --git a/codex-rs/tui/src/chatwidget/quick_dials.rs b/codex-rs/tui/src/chatwidget/quick_dials.rs index cf702a39d2..69f0bdbf31 100644 --- a/codex-rs/tui/src/chatwidget/quick_dials.rs +++ b/codex-rs/tui/src/chatwidget/quick_dials.rs @@ -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 diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index db01960a61..a40247c562 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -107,6 +107,8 @@ pub(crate) struct AppKeymap { pub(crate) focus_main_composer: Vec, pub(crate) focus_sidebar_content: Vec, pub(crate) hide_pane: Vec, + pub(crate) focus_pane_left: Vec, + pub(crate) focus_pane_right: Vec, /// Open the agent command center. pub(crate) open_agents: Vec, /// Open transcript overlay. @@ -690,6 +692,19 @@ impl RuntimeKeymap { } fn resolve(keymap: &TuiKeymap, workspace: bool) -> Result { + // 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) diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 3334e60b68..11548c0c33 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -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, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index dbf5960137..af8c80dd9e 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -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; } diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index c9c828944d..5f86e184c7 100644 --- a/codex-rs/tui/src/keymap/chords/leader_defaults.rs +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -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 diff --git a/codex-rs/tui/src/keymap/chords_tests.rs b/codex-rs/tui/src/keymap/chords_tests.rs index 3ba4131e7e..bb6667c8be 100644 --- a/codex-rs/tui/src/keymap/chords_tests.rs +++ b/codex-rs/tui/src/keymap/chords_tests.rs @@ -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] diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 8fea3a6d17..4534f281b1 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -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.", diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 22f9f19591..bb87405cc7 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -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); diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap index 8978c14e5e..1b3820bc4e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap index 13997c1f74..43d690e352 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap @@ -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 diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap index d53eac5ebe..ef2e1eb7c8 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_all_tab_search.snap @@ -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 diff --git a/codex-rs/tui/src/transcript_view_tests.rs b/codex-rs/tui/src/transcript_view_tests.rs index ad5499824f..ff9f1fbd54 100644 --- a/codex-rs/tui/src/transcript_view_tests.rs +++ b/codex-rs/tui/src/transcript_view_tests.rs @@ -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 ) );