From 6b2a2757de696c2506ee460c49c66a8e2bed65e0 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 3 Nov 2026 18:30:11 +0000 Subject: [PATCH] Add shortcuts to focus the main composer and side conversation (#59208) ## What changed - Add configurable `focus_main_composer` and `focus_sidebar_content` actions, with default `leader c` and `leader s` bindings in workspace mode. - Let users return to the main composer while keeping the side pane open, or focus the side conversation and hide it when already focused. - Clear transcript search, selection, and activity focus when returning to the main composer, including when hiding the side pane. ## Testing Extend tests for focus switching, blocked thread switches, offline composer focus, key capture, and binding conflicts. GitOrigin-RevId: da624ce7e00a63c4f13870e55c4bc95f09b32cc4 --- codex-rs/config/src/tui_keymap.rs | 4 + codex-rs/core/config.schema.json | 20 ++++ codex-rs/tui/src/app.rs | 17 +--- codex-rs/tui/src/app/input.rs | 6 +- .../tui/src/app/owned_transcript_tests.rs | 16 ++++ codex-rs/tui/src/app/side.rs | 11 ++- codex-rs/tui/src/app/side_focus.rs | 93 +++++++++++++++++++ codex-rs/tui/src/app/tests.rs | 73 ++++++++++++++- codex-rs/tui/src/app/tests/key_chords.rs | 25 ++++- codex-rs/tui/src/keymap.rs | 81 +++++++++++++--- codex-rs/tui/src/keymap/bindings.rs | 8 +- codex-rs/tui/src/keymap/chords.rs | 12 ++- .../tui/src/keymap/chords/leader_defaults.rs | 2 + codex-rs/tui/src/keymap_setup/actions.rs | 2 + ...__tests__keymap_picker_all_tab_search.snap | 4 +- 15 files changed, 334 insertions(+), 40 deletions(-) create mode 100644 codex-rs/tui/src/app/side_focus.rs diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 796f74c617..ee4dcd6c7c 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -137,6 +137,10 @@ pub struct TuiGlobalKeymap { pub leader: Option, /// Change where a new conversation runs. pub change_host: Option, + /// Focus the main conversation composer. + pub focus_main_composer: Option, + /// Focus the side conversation, or hide it when already focused. + pub focus_sidebar_content: Option, /// Hide the focused sidebar pane. pub hide_pane: Option, /// Open the shared agent-session overview. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 33058b9e73..488041b826 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5279,6 +5279,8 @@ "copy": null, "find_transcript": null, "focus_activity": null, + "focus_main_composer": null, + "focus_sidebar_content": null, "hide_pane": null, "leader": null, "open_agents": null, @@ -6109,6 +6111,22 @@ ], "description": "Focus activity groups in the owned transcript to inspect their details." }, + "focus_main_composer": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Focus the main conversation composer." + }, + "focus_sidebar_content": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Focus the side conversation, or hide it when already focused." + }, "hide_pane": { "allOf": [ { @@ -6330,6 +6348,8 @@ "copy": null, "find_transcript": null, "focus_activity": null, + "focus_main_composer": null, + "focus_sidebar_content": null, "hide_pane": null, "leader": null, "open_agents": null, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7d385390e7..ed38e821b5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -270,6 +270,7 @@ mod server_version_notice; mod session_lifecycle; mod session_picker; mod side; +mod side_focus; mod side_pane; mod startup; pub(crate) mod startup_bootstrap; @@ -1011,24 +1012,12 @@ impl App { event }; - self.cancel_primed_browsing_for_event(&event); if let TuiEvent::Key(key) = &event - && self.keymap.app.hide_pane.is_pressed(*key) - && self.side_pane_shortcuts_available(tui) + && self.handle_side_focus_key(tui, app_server, *key).await { - if key.kind == KeyEventKind::Press { - let activation = self - .routed_key_activation - .take() - .unwrap_or_else(|| KeyBinding::from_event(*key)); - if let Err(err) = self.hide_focused_side_pane(tui, app_server).await { - self.chat_widget.add_error_message(err.to_string()); - } - self.chat_widget - .suppress_current_view_key_until_release(activation, Instant::now()); - } return Ok(AppRunControl::Continue); } + self.cancel_primed_browsing_for_event(&event); let voice_toggle = |app: &Self, key: KeyEvent| { key.kind == KeyEventKind::Press && app diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 67507bf891..aaae88b7bd 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -239,7 +239,11 @@ impl App { fn key_chord_contexts(&self, tui: &tui::Tui) -> crate::keymap::KeymapContextSet { let contexts = self.active_keymap_contexts(); - if self.side_pane_shortcuts_available(tui) { + if !self.main_composer_shortcut_available(tui) { + return contexts; + } + let contexts = contexts.with_main_composer_focus(); + if self.side_pane_switch_available() { contexts.with_side_pane() } else { contexts diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 422d53ff54..1ac530d82b 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -1664,6 +1664,22 @@ async fn find_refreshes_live_details_before_searching_the_first_query() -> Resul )) .contains("needle") ); + crate::app::test_support::enable_side_retention(&mut app)?; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::F(4).into()), + ) + .await?; + assert!(app.transcript_view.is_activity_focused()); + for key in [ + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyCode::Char('c').into(), + ] { + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key)) + .await?; + } + assert!(!app.transcript_view.is_activity_focused()); for event in [ TuiEvent::Key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)), TuiEvent::Paste("needle".into()), diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index f64a88e16a..e23614ede4 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -322,11 +322,15 @@ impl App { self.local_settings.workspace_enabled(&self.config) && tui.is_owned_screen() } - pub(super) fn side_pane_shortcuts_available(&self, tui: &tui::Tui) -> bool { + pub(super) fn main_composer_shortcut_available(&self, tui: &tui::Tui) -> bool { self.side_retention_enabled(tui) - && !self.reconnect.offline && 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)) @@ -483,8 +487,7 @@ impl App { { side.pane_open = false; tui.frame_requester().schedule_frame(); - self.surface_pending_inactive_thread_interactive_requests() - .await?; + self.focus_returned_parent_composer(parent).await?; } Ok(()) } diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs new file mode 100644 index 0000000000..a5a2303401 --- /dev/null +++ b/codex-rs/tui/src/app/side_focus.rs @@ -0,0 +1,93 @@ +//! Composer and side-conversation focus shortcuts. Thread selection replaces the widget +//! immediately, but Park and Finish transfer transcript views through the app event queue. + +use super::*; +use crate::transcript_view::TranscriptView; + +impl App { + pub(super) async fn handle_side_focus_key( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + key: KeyEvent, + ) -> bool { + let bindings = &self.keymap.app; + let main = bindings.focus_main_composer.is_pressed(key); + 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()) + { + return false; + } + if key.kind != KeyEventKind::Press { + return true; + } + let activation = self + .routed_key_activation + .take() + .unwrap_or_else(|| KeyBinding::from_event(key)); + let result = if main { + 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 + { + let result = self.toggle_side_conversation(tui, app_server).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 { + self.hide_focused_side_pane(tui, app_server).await + }; + 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()); + true + } + + async fn focus_main_composer( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + ) -> Result<()> { + if let Some(parent) = self.active_side_parent_thread_id() { + self.select_agent_thread(tui, app_server, parent).await?; + if self.current_displayed_thread_id() == Some(parent) { + self.focus_returned_parent_composer(parent).await?; + } + } else { + self.focus_current_parent_view(tui); + } + Ok(()) + } + + fn focus_current_parent_view(&mut self, tui: &mut tui::Tui) { + self.cancel_transcript_browsing(tui); + clear_transcript_interaction(&mut self.transcript_view, &self.transcript_cells); + self.cancel_pending_key_chord(); + } + + pub(super) async fn focus_returned_parent_composer(&mut self, parent: ThreadId) -> Result<()> { + // Finish will restore this view. The live transcript still belongs to the child. + if let Some(companion) = self.side_companions.get_mut(&parent) { + companion.view.set_highlight(/*index*/ None); + clear_transcript_interaction(&mut companion.view, &companion.cells); + } + self.cancel_pending_key_chord(); + self.surface_pending_inactive_thread_interactive_requests() + .await + } +} + +fn clear_transcript_interaction(view: &mut TranscriptView, cells: &[Arc]) { + view.end_select_mode(); + view.end_selection(cells); + view.cancel_search(); + view.clear_activity_focus(); +} diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 848bb809ce..d6e186086a 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3497,7 +3497,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() parent_thread_id, "background", "reply", - "still working\n\n", + &"still working\n\n".repeat(40), ), ) .await?; @@ -3543,7 +3543,8 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() KeyEventKind::Release, ] .map(|kind| KeyEvent::new_with_kind(KeyCode::Char('-'), KeyModifiers::NONE, kind)); - assert!(app.side_pane_shortcuts_available(&tui)); + let main = KeyEvent::from(KeyCode::Char('c')); + let side = KeyEvent::from(KeyCode::Char('s')); // Hide is consumed on the parent without closing the visible pane or typing its completion. keys!(leader, minus, repeat, release); assert!(!app.chat_widget.is_in_paste_burst()); @@ -3616,7 +3617,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() // The pending permission change refuses a parent switch even though selection returns Ok. app.pending_server_profiles - .insert(side_thread_id, read_only); + .insert(side_thread_id, read_only.clone()); keys!(leader, minus, repeat, release); assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); assert_eq!(app.chat_widget.composer_text_with_pending(), "child draft!"); @@ -3640,10 +3641,33 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() enable_side_retention(&mut app)?; KeyCode::F(12).into() }; + // Park an interaction on the parent so both hide shortcuts must clear it on return. + keys!(prefix, main); + app.transcript_view.begin_search(); + app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) + .await?; + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + side_frame(&mut app, &mut tui, size)?; assert!(app.transcript_view.begin_select_mode(&app.transcript_cells)); - keys!(prefix, minus); + 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)); + assert!(app.transcript_view.is_select_mode()); + assert!( + app.side_companions[&parent_thread_id] + .view + .is_search_editing() + ); + app.pending_server_profiles.remove(&side_thread_id); + } + keys!(prefix, if width == 100 { minus } else { side }); assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id)); - keys!(repeat, release); + assert!(!app.transcript_view.has_active_interaction()); + if width == 100 { + keys!(repeat, release); + } assert!(!app.chat_widget.is_in_paste_burst()); let hidden = side_frame(&mut app, &mut tui, size)?; assert!( @@ -3690,6 +3714,45 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() } assert_app_snapshot!("hide_and_reopen_side_conversation", frames.join("\n\n")); + // The parent is browsing a saved prompt and the child has a selection. + let prefix = KeyCode::F(12).into(); + keys!(toggle); + app.apply_backtrack_selection_internal(/*nth_user_message*/ 0); + let highlighted = side_frame(&mut app, &mut tui, size)?; + assert!( + highlighted.contains("Saved user message") && !highlighted.contains("Back to bottom"), + "{highlighted}" + ); + keys!(toggle); + side_frame(&mut app, &mut tui, size)?; + assert!(app.transcript_view.begin_select_mode(&app.transcript_cells)); + + keys!(prefix, main); + assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id)); + let restored = side_frame(&mut app, &mut tui, size)?; + assert!( + restored.contains("Saved user message") + && restored.contains("Back to bottom") + && restored.contains('│'), + "{restored}" + ); + 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)); + assert!(app.transcript_view.is_select_mode()); + app.pending_server_profiles.remove(&parent_thread_id); + keys!(prefix, side); + 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] + .view + .has_active_interaction() + ); + app.transcript_view.end_select_mode(); + 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 0290827512..5258bc23b7 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -1173,12 +1173,15 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> { tui.set_owned_screen(/*owned*/ true)?; let parent = ThreadId::new(); app.active_thread_id = Some(parent); + let child = ThreadId::new(); app.side_threads - .insert(ThreadId::new(), super::SideThreadState::new(parent)); + .insert(child, super::SideThreadState::new(parent)); for (config, key) in [ ("", 8), ("[chat]\ntoggle_voice = 'f8 v'", 8), ("[global]\nhide_pane = 'f12'", 12), + ("[global]\nfocus_main_composer = 'f12'", 12), + ("[global]\nfocus_sidebar_content = 'f12 s'", 12), ] { app.keymap = RuntimeKeymap::for_session(&toml::from_str(config)?, /*workspace*/ true).unwrap(); @@ -1219,5 +1222,25 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> { } assert_eq!(app.chat_widget.composer_text_with_pending(), draft); } + + // Offline, the same printable binding focuses the local composer but cannot switch threads. + let mut stopped = app_server.request_view(); + app_server.shutdown().await?; + app.local_settings.tui.keymap = toml::from_str("[global]\nfocus_main_composer = '%'")?; + super::enable_side_retention(&mut app)?; + app.active_thread_id = Some(ThreadId::new()); + app.reconnect.offline = true; + app.chat_widget.apply_external_edit("draft".into()); + app.transcript_view.begin_search(); + for key in [KeyCode::Char('%'), KeyCode::Char('!'), KeyCode::Right] { + press(&mut app, &mut tui, &mut stopped, key.into()).await?; + } + assert!(!app.transcript_view.has_active_interaction()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft!"); + app.active_thread_id = Some(child); + for key in [KeyCode::Char('%'), KeyCode::Right] { + press(&mut app, &mut tui, &mut stopped, key.into()).await?; + } + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft!%"); Ok(()) } diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 844670a50c..db01960a61 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -104,6 +104,8 @@ pub(crate) struct RuntimeKeymap { pub(crate) struct AppKeymap { /// Change where a new conversation runs. pub(crate) change_host: Vec, + pub(crate) focus_main_composer: Vec, + pub(crate) focus_sidebar_content: Vec, pub(crate) hide_pane: Vec, /// Open the agent command center. pub(crate) open_agents: Vec, @@ -739,6 +741,16 @@ impl RuntimeKeymap { &defaults.app.change_host, "tui.keymap.global.change_host", )?, + focus_main_composer: resolve_bindings( + keymap.global.focus_main_composer.as_ref(), + &[], + "tui.keymap.global.focus_main_composer", + )?, + focus_sidebar_content: resolve_bindings( + keymap.global.focus_sidebar_content.as_ref(), + &[], + "tui.keymap.global.focus_sidebar_content", + )?, hide_pane: resolve_bindings( keymap.global.hide_pane.as_ref(), &[], @@ -1719,6 +1731,8 @@ impl RuntimeKeymap { Self { app: AppKeymap { change_host: Vec::new(), + focus_main_composer: Vec::new(), + focus_sidebar_content: Vec::new(), hide_pane: Vec::new(), open_agents: default_bindings![], open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], @@ -2071,6 +2085,11 @@ impl RuntimeKeymap { for (action, bindings) in [ ("global.change_host", &self.app.change_host), ("global.open_agents", &self.app.open_agents), + ("global.focus_main_composer", &self.app.focus_main_composer), + ( + "global.focus_sidebar_content", + &self.app.focus_sidebar_content, + ), ("global.hide_pane", &self.app.hide_pane), ("chat.open_presets", &self.chat.open_presets), ("chat.toggle_voice", &self.chat.toggle_voice), @@ -2101,6 +2120,14 @@ impl RuntimeKeymap { let main_bindings = [ ("change_host", self.app.change_host.as_slice()), + ( + "focus_main_composer", + self.app.focus_main_composer.as_slice(), + ), + ( + "focus_sidebar_content", + self.app.focus_sidebar_content.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()), @@ -2270,6 +2297,14 @@ impl RuntimeKeymap { validate_no_shadow_with_allowed_overlaps( "main", [ + ( + "focus_main_composer", + self.app.focus_main_composer.as_slice(), + ), + ( + "focus_sidebar_content", + self.app.focus_sidebar_content.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()), @@ -2396,6 +2431,14 @@ impl RuntimeKeymap { validate_unique( "transcript", [ + ( + "global.focus_main_composer", + self.app.focus_main_composer.as_slice(), + ), + ( + "global.focus_sidebar_content", + self.app.focus_sidebar_content.as_slice(), + ), ("global.hide_pane", self.app.hide_pane.as_slice()), ( "global.select_transcript", @@ -2429,6 +2472,14 @@ impl RuntimeKeymap { validate_unique( "activity", context_bindings(KeymapContext::List).chain([ + ( + "global.focus_main_composer", + self.app.focus_main_composer.as_slice(), + ), + ( + "global.focus_sidebar_content", + self.app.focus_sidebar_content.as_slice(), + ), ("global.hide_pane", self.app.hide_pane.as_slice()), ("global.focus_activity", self.app.focus_activity.as_slice()), ( @@ -3891,21 +3942,26 @@ mod tests { #[test] fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() { - for (section, action) in [("pager", "page_up"), ("list", "move_down")] { + for (pane, section, action) in [ + ("hide_pane", "pager", "page_up"), + ("hide_pane", "list", "move_down"), + ("focus_main_composer", "pager", "page_up"), + ("focus_sidebar_content", "list", "move_down"), + ] { let config: TuiKeymap = toml::from_str(&format!( - "[global]\nhide_pane = 'f8'\n[{section}]\n{action} = 'f8'" + "[global]\n{pane} = 'f8'\n[{section}]\n{action} = 'f8'" )) .unwrap(); let error = RuntimeKeymap::for_session(&config, /*workspace*/ true).unwrap_err(); assert!( - error.contains("global.hide_pane") && error.contains(action), + error.contains(&format!("global.{pane}")) && error.contains(action), "{error}" ); } #[cfg(unix)] - { - let mut config = TuiKeymap::default(); - config.global.hide_pane = Some(one("ctrl-z")); + for action in ["hide_pane", "focus_main_composer", "focus_sidebar_content"] { + let config: TuiKeymap = + toml::from_str(&format!("[global]\n{action} = 'ctrl-z'")).unwrap(); assert!( RuntimeKeymap::for_session(&config, /*workspace*/ true) .unwrap_err() @@ -4105,12 +4161,15 @@ mod tests { let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert!(runtime.composer.toggle_shortcuts.is_empty()); keymap.global.hide_pane = Some(KeybindingsSpec::Many(vec![])); + keymap.global.focus_sidebar_content = Some(KeybindingsSpec::Many(vec![])); let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap(); - assert!( - runtime - .primary_hint(KeymapContext::Global, "hide_pane") - .is_none() - ); + for action in ["hide_pane", "focus_sidebar_content"] { + assert!( + runtime + .primary_hint(KeymapContext::Global, action) + .is_none() + ); + } } #[test] diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index e139873068..3334e60b68 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -104,7 +104,11 @@ pub(crate) struct KeymapActionId { impl KeymapActionId { pub(crate) fn is_side_pane(self) -> bool { - self.context == KeymapContext::Global && self.action == "hide_pane" + self.context == KeymapContext::Global + && matches!( + self.action, + "focus_main_composer" | "focus_sidebar_content" | "hide_pane" + ) } /// Whether two actions can be active together, including list-specific exceptions. @@ -299,6 +303,8 @@ define_runtime_action_bindings! { find_transcript, select_transcript, focus_activity, + focus_main_composer, + focus_sidebar_content, hide_pane, open_warnings => runtime_only, open_external_editor, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 9648435a9e..dbf5960137 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -55,6 +55,7 @@ const VOICE_TOGGLE_BLOCKED: u32 = 1 << 19; 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 TRANSCRIPT_BROWSING: u32 = 1 << 14; @@ -157,6 +158,10 @@ impl KeymapContextSet { } } + pub(crate) const fn with_main_composer_focus(self) -> Self { + Self(self.0 | MAIN_COMPOSER_FOCUS) + } + pub(crate) const fn with_side_pane(self) -> Self { Self(self.0 | SIDE_PANE) } @@ -167,7 +172,12 @@ impl KeymapContextSet { return self.0 & BACKEND_CYCLING != 0; } if action.is_side_pane() { - return self.0 & SIDE_PANE != 0; + let required = if action.action == "focus_main_composer" { + MAIN_COMPOSER_FOCUS + } else { + SIDE_PANE + }; + return self.0 & required != 0; } if action.context == KeymapContext::Chat && action.action == "open_presets" { return self.contains(KeymapContext::Chat) diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index 12f80edc15..c9c828944d 100644 --- a/codex-rs/tui/src/keymap/chords/leader_defaults.rs +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -32,6 +32,8 @@ pub(in crate::keymap) fn register( (Global, "change_host", 'b'), (Global, "select_transcript", '['), (Chat, "open_presets", 'p'), + (Global, "focus_main_composer", 'c'), + (Global, "focus_sidebar_content", 's'), (Global, "hide_pane", '-'), ] { if remaining == 0 { diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index b5a8840c85..8fea3a6d17 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -118,6 +118,8 @@ macro_rules! define_keymap_actions { define_keymap_actions! { global "Global" { change_host [MultiBackend] => "Change where a new conversation runs.", + 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.", open_agents => "Open the agent command center.", open_transcript => "Open the transcript overlay.", 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 fc9830f3b1..d53eac5ebe 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 @@ -2,6 +2,8 @@ source: tui/src/keymap_setup.rs 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 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 @@ -13,5 +15,3 @@ 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