From eb15d3a0f57a0d7b8c5267885bf2c41ce7ca75c3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 3 Nov 2026 11:30:50 +0000 Subject: [PATCH] Add a shortcut to hide the focused side conversation pane (#59130) ## What changed Add `tui.keymap.global.hide_pane`, defaulting to `leader -` in workspace mode. Hiding a focused side conversation returns to its parent while retaining the conversation, drafts, and view state for reopening. Expose the action in the workspace keymap picker, validate binding conflicts, and suppress held shortcut keys after switching focus. ## Testing Add coverage for hiding and reopening at split-pane and narrow widths, preserving conversation state, shortcut availability, key capture, and repeat/release handling. GitOrigin-RevId: 6897c34b208041e6f90ae0e18c781eebd62566bd --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 ++ codex-rs/tui/src/app.rs | 21 +++- .../tui/src/app/agents_overview_actions.rs | 2 +- codex-rs/tui/src/app/event_dispatch.rs | 10 +- codex-rs/tui/src/app/input.rs | 77 +++++++----- codex-rs/tui/src/app/input/select_mode.rs | 2 +- codex-rs/tui/src/app/session_lifecycle.rs | 5 + codex-rs/tui/src/app/side.rs | 42 ++++++- codex-rs/tui/src/app/side_pane.rs | 16 ++- codex-rs/tui/src/app/startup.rs | 15 ++- codex-rs/tui/src/app/test_support.rs | 5 + codex-rs/tui/src/app/tests.rs | 119 ++++++++++++++++-- codex-rs/tui/src/app/tests/key_chords.rs | 88 +++++++++++-- .../tests/user_verification_routes_tests.rs | 23 +++- codex-rs/tui/src/app/working_directory.rs | 5 +- codex-rs/tui/src/chatwidget/config_panel.rs | 3 +- codex-rs/tui/src/chatwidget/constructor.rs | 6 +- codex-rs/tui/src/chatwidget/interaction.rs | 9 ++ codex-rs/tui/src/chatwidget/keymap_picker.rs | 6 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 6 +- codex-rs/tui/src/keymap.rs | 56 ++++++++- codex-rs/tui/src/keymap/bindings.rs | 10 ++ codex-rs/tui/src/keymap/chords.rs | 8 ++ .../tui/src/keymap/chords/leader_defaults.rs | 51 ++++---- codex-rs/tui/src/keymap_setup.rs | 22 +++- codex-rs/tui/src/keymap_setup/actions.rs | 4 + codex-rs/tui/src/local_settings.rs | 7 ++ ...ts__hide_and_reopen_side_conversation.snap | 63 ++++++++++ ...__tests__keymap_picker_all_tab_search.snap | 1 + 30 files changed, 573 insertions(+), 121 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 0246916357..796f74c617 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -137,6 +137,8 @@ pub struct TuiGlobalKeymap { pub leader: Option, /// Change where a new conversation runs. pub change_host: Option, + /// Hide the focused sidebar pane. + pub hide_pane: 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 ff14ac2bbf..33058b9e73 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5279,6 +5279,7 @@ "copy": null, "find_transcript": null, "focus_activity": null, + "hide_pane": null, "leader": null, "open_agents": null, "open_external_editor": null, @@ -6108,6 +6109,14 @@ ], "description": "Focus activity groups in the owned transcript to inspect their details." }, + "hide_pane": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Hide the focused sidebar pane." + }, "leader": { "allOf": [ { @@ -6321,6 +6330,7 @@ "copy": null, "find_transcript": null, "focus_activity": null, + "hide_pane": null, "leader": null, "open_agents": null, "open_external_editor": null, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7a981aa784..7d385390e7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -966,7 +966,7 @@ impl App { &event, TuiEvent::Key(_) | TuiEvent::Mouse(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost ) { - self.expire_pending_key_chord(); + self.expire_pending_key_chord(tui); self.handle_draw_pre_render(tui, screen_size)?; } @@ -992,7 +992,7 @@ impl App { let mut event = if let TuiEvent::Key(mut key_event) = event { let escape = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); - if self.should_recover_vim_insert_escape(key_event) + if self.should_recover_vim_insert_escape(tui, key_event) && !(tui.is_owned_screen() && crate::transcript_view::JumpTarget::from_key(key_event).is_some()) { @@ -1012,6 +1012,23 @@ impl App { }; 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) + { + 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); + } let voice_toggle = |app: &Self, key: KeyEvent| { key.kind == KeyEventKind::Press && app diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index 38492f6026..8ad25524ce 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -84,7 +84,7 @@ async fn run_lifecycle_modal( } tui::TuiEvent::Key(_) | tui::TuiEvent::Paste(_) | tui::TuiEvent::FocusLost | tui::TuiEvent::Mouse(_) => {} tui::TuiEvent::Draw | tui::TuiEvent::Resize(_) | tui::TuiEvent::Resume | tui::TuiEvent::FocusGained => { - app.expire_pending_key_chord(); + app.expire_pending_key_chord(tui); app.render_chat_widget_frame(tui, screen_size)?; } } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0d05f9a774..a3ea592f4b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -3806,7 +3806,10 @@ impl App { } }; - let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) { + let runtime_keymap = match RuntimeKeymap::for_session( + &keymap_config, + self.local_settings.workspace_enabled(&self.config), + ) { Ok(runtime_keymap) => runtime_keymap, Err(err) => { let params = crate::keymap_setup::build_keymap_conflict_params( @@ -3899,7 +3902,10 @@ impl App { } }; - let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) { + let runtime_keymap = match RuntimeKeymap::for_session( + &keymap_config, + self.local_settings.workspace_enabled(&self.config), + ) { Ok(runtime_keymap) => runtime_keymap, Err(err) => { self.app_event_tx.send(AppEvent::FollowTranscript); diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 6a08b748c9..67507bf891 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -12,8 +12,12 @@ use crate::keymap::keymap_action_ids; mod select_mode; impl App { - pub(super) fn should_recover_vim_insert_escape(&self, key_event: KeyEvent) -> bool { - let active_contexts = self.active_keymap_contexts(); + pub(super) fn should_recover_vim_insert_escape( + &self, + tui: &tui::Tui, + key_event: KeyEvent, + ) -> bool { + let active_contexts = self.key_chord_contexts(tui); let rename_active = self .chat_widget .selected_index_for_active_view(AGENTS_OVERVIEW_VIEW_ID) @@ -92,6 +96,7 @@ impl App { if self.overlay.is_none() && self.chat_widget.handle_quick_dial_menu(key_event) { return None; } + let contexts = self.key_chord_contexts(tui); let select_mode_chord = self.select_mode_owns_chord(tui, key_event); if (matches!(&self.overlay, Some(Overlay::Transcript(overlay)) if overlay.owns_interaction_key(key_event)) || (tui.is_owned_screen() @@ -103,36 +108,43 @@ impl App { || crate::transcript_view::JumpTarget::from_key(key_event).is_none()))) && !select_mode_chord { - let close_chord = tui.is_owned_screen() + let close = tui.is_owned_screen() && self.overlay.is_none() && self.transcript_view.is_detailed() && !self.transcript_view.has_active_interaction() - && !self.backtrack.overlay_preview_active - && match self.key_chord_matcher.clone().advance( - key_event, - &self.keymap.chords, - self.active_keymap_contexts(), - ) { - crate::keymap::KeyChordMatch::Completed(event) => { - self.keymap.pager.close_transcript.is_pressed(event) - } - crate::keymap::KeyChordMatch::Pending(prefix) => { - self.keymap.chords.bindings.iter().any(|binding| { - binding.action.context == crate::keymap::KeymapContext::Pager - && binding.action.action == "close_transcript" - && binding.chord.prefix == prefix - }) - } - crate::keymap::KeyChordMatch::PassThrough - | crate::keymap::KeyChordMatch::Cancelled - | crate::keymap::KeyChordMatch::Ignored => false, - }; - if !close_chord { + && !self.backtrack.overlay_preview_active; + let allowed = |action: crate::keymap::KeymapActionId| { + (close + && action.context == crate::keymap::KeymapContext::Pager + && action.action == "close_transcript") + || (action.is_side_pane() && contexts.contains_action(action)) + }; + let chord = match self.key_chord_matcher.clone().advance( + key_event, + &self.keymap.chords, + contexts, + ) { + crate::keymap::KeyChordMatch::Completed(event) => { + self.keymap.chords.bindings.iter().any(|binding| { + allowed(binding.action) + && crate::keymap::dispatch_event(binding.action) == Some(event) + }) + } + crate::keymap::KeyChordMatch::Pending(prefix) => self + .keymap + .chords + .bindings + .iter() + .any(|binding| binding.chord.prefix == prefix && allowed(binding.action)), + crate::keymap::KeyChordMatch::PassThrough + | crate::keymap::KeyChordMatch::Cancelled + | crate::keymap::KeyChordMatch::Ignored => false, + }; + if !chord { self.cancel_pending_key_chord(); return Some(key_event); } } - let contexts = self.active_keymap_contexts(); let was_pending = self.key_chord_matcher.is_pending(); if !was_pending && contexts.is_warnings() @@ -184,7 +196,7 @@ impl App { } crate::keymap::KeyChordMatch::Completed(dispatch_event) => { self.set_key_chord_hint_override(/*items*/ None); - // Keep the real key so the newly opened config editor ignores it until release. + // A view transition needs the physical completion key to suppress held input. self.routed_key_activation = Some(KeyBinding::from_event(key_event)); Some(dispatch_event) } @@ -196,11 +208,11 @@ impl App { } } - pub(super) fn expire_pending_key_chord(&mut self) { + pub(super) fn expire_pending_key_chord(&mut self, tui: &tui::Tui) { if self.chat_widget.take_key_chord_reset() { self.cancel_pending_key_chord(); } - let contexts = self.active_keymap_contexts(); + let contexts = self.key_chord_contexts(tui); if self.key_chord_matcher.expire(contexts) { self.set_key_chord_hint_override(/*items*/ None); } @@ -225,6 +237,15 @@ impl App { self.chat_widget.set_footer_hint_override(items); } + fn key_chord_contexts(&self, tui: &tui::Tui) -> crate::keymap::KeymapContextSet { + let contexts = self.active_keymap_contexts(); + if self.side_pane_shortcuts_available(tui) { + contexts.with_side_pane() + } else { + contexts + } + } + pub(super) fn active_keymap_contexts(&self) -> crate::keymap::KeymapContextSet { use crate::keymap::KeymapContext; use crate::keymap::KeymapContextSet; diff --git a/codex-rs/tui/src/app/input/select_mode.rs b/codex-rs/tui/src/app/input/select_mode.rs index 1664dd2b3b..513201a724 100644 --- a/codex-rs/tui/src/app/input/select_mode.rs +++ b/codex-rs/tui/src/app/input/select_mode.rs @@ -24,7 +24,7 @@ impl App { if !select_mode { return false; } - let contexts = self.active_keymap_contexts(); + let contexts = self.key_chord_contexts(tui); let search_editing = matches!( &self.overlay, Some(Overlay::Transcript(overlay)) if overlay.is_search_editing() diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 01cf76b484..abc35c7875 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -742,6 +742,11 @@ impl App { }); } self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?; + if self.side_retention_enabled(tui) + && let Some(side) = self.side_threads.get_mut(&thread_id) + { + side.pane_open = true; + } if is_replay_only && self .thread_event_channels diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 683ecb377c..f64a88e16a 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -222,6 +222,8 @@ pub(super) struct SideThreadState { pub(super) parent_thread_id: ThreadId, /// Parent-thread condition that changed while this side thread is visible. pub(super) parent_status: Option, + /// A hidden pane keeps its child and parked display for the next focus switch. + pub(super) pane_open: bool, } impl SideThreadState { @@ -229,6 +231,7 @@ impl SideThreadState { Self { parent_thread_id, parent_status: None, + pane_open: true, } } } @@ -316,9 +319,18 @@ impl App { } pub(super) fn side_retention_enabled(&self, tui: &tui::Tui) -> bool { - self.config.features.enabled(Feature::TuiWorkspace) - && self.local_settings.transcript_mode.is_owned() - && tui.is_owned_screen() + self.local_settings.workspace_enabled(&self.config) && tui.is_owned_screen() + } + + pub(super) fn side_pane_shortcuts_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 + .current_displayed_thread_id() + .and_then(|id| self.side_pair_key(id)) + .is_some() } pub(super) fn side_pair_key(&self, thread_id: ThreadId) -> Option { @@ -453,6 +465,30 @@ impl App { Ok(()) } + pub(super) async fn hide_focused_side_pane( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + ) -> Result<()> { + let Some(child) = self.current_displayed_thread_id() else { + return Ok(()); + }; + let Some(side) = self.side_threads.get(&child) else { + return Ok(()); + }; + let parent = side.parent_thread_id; + self.select_agent_thread(tui, app_server, parent).await?; + if self.current_displayed_thread_id() == Some(parent) + && let Some(side) = self.side_threads.get_mut(&child) + { + side.pane_open = false; + tui.frame_requester().schedule_frame(); + self.surface_pending_inactive_thread_interactive_requests() + .await?; + } + Ok(()) + } + pub(super) async fn discard_side_thread( &mut self, app_server: &mut AppServerSession, diff --git a/codex-rs/tui/src/app/side_pane.rs b/codex-rs/tui/src/app/side_pane.rs index 43529a9353..efdd71305c 100644 --- a/codex-rs/tui/src/app/side_pane.rs +++ b/codex-rs/tui/src/app/side_pane.rs @@ -33,10 +33,16 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option child.parent_thread_id, - None => app.side_thread_for_parent(active)?, + let child = app.side_pair_key(active)?; + let side = app.side_threads.get(&child)?; + if !side.pane_open { + return None; + } + let focused_child = active == child; + let passive_thread = if focused_child { + side.parent_thread_id + } else { + child }; if !app.side_companions.contains_key(&passive_thread) { return None; @@ -46,7 +52,7 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option Result<()> { app.config.features.enable(Feature::TuiWorkspace)?; app.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Owned; + app.keymap = crate::keymap::RuntimeKeymap::for_session( + &app.local_settings.tui.keymap, + app.local_settings.workspace_enabled(&app.config), + ) + .map_err(color_eyre::eyre::Report::msg)?; Ok(()) } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2863a768d1..848bb809ce 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3363,16 +3363,14 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config( app.chat_widget.config_ref(), )); - app.select_permission_profile( - &mut app_server, - PermissionProfileSelection { - profile_id: ":read-only".into(), - approval_policy: Some(AskForApproval::OnRequest), - approvals_reviewer: Some(ApprovalsReviewer::User), - display_label: "Read Only".into(), - }, - ) - .await; + let read_only = PermissionProfileSelection { + profile_id: ":read-only".into(), + approval_policy: Some(AskForApproval::OnRequest), + approvals_reviewer: Some(ApprovalsReviewer::User), + display_label: "Read Only".into(), + }; + app.select_permission_profile(&mut app_server, read_only.clone()) + .await; let control = Box::pin(app.handle_start_side( &mut tui, @@ -3530,6 +3528,28 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() )) ); + macro_rules! keys { + ($($key:expr),+) => {{ + for key in [$($key),+] { + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key))).await?; + } + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + }}; + } + let leader = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + let [minus, repeat, release] = [ + KeyEventKind::Press, + KeyEventKind::Repeat, + KeyEventKind::Release, + ] + .map(|kind| KeyEvent::new_with_kind(KeyCode::Char('-'), KeyModifiers::NONE, kind)); + assert!(app.side_pane_shortcuts_available(&tui)); + // 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()); + assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id)); + assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft"); + for width in [100, 99, 100] { let frame = side_frame(&mut app, &mut tui, Size::new(width, /*height*/ 18))?; assert!( @@ -3580,8 +3600,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() let inactive = composer_gutter(&mut app, &mut tui)?; let toggle = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL); - boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(toggle))).await?; - drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + keys!(toggle); assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); let wide = side_frame(&mut app, &mut tui, size)?; assert!( @@ -3595,6 +3614,82 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() format!("Inactive\n{inactive}\n\nFocused\n{focused}") ); + // The pending permission change refuses a parent switch even though selection returns Ok. + app.pending_server_profiles + .insert(side_thread_id, read_only); + 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!"); + assert!(!app.chat_widget.is_in_paste_burst()); + let refused = side_frame(&mut app, &mut tui, size)?; + assert!( + refused.contains(parent_row) && refused.contains(child_row) && refused.contains('│'), + "{refused}" + ); + app.pending_server_profiles.remove(&side_thread_id); + + let channel = Arc::clone(&app.thread_event_channels[&side_thread_id].store); + let mut frames = Vec::new(); + for width in [100, 99] { + let size = Size::new(width, /*height*/ 18); + side_frame(&mut app, &mut tui, size)?; + let prefix = if width == 100 { + leader + } else { + app.local_settings.tui.keymap = toml::from_str("[global]\nleader = 'f12'")?; + enable_side_retention(&mut app)?; + KeyCode::F(12).into() + }; + assert!(app.transcript_view.begin_select_mode(&app.transcript_cells)); + keys!(prefix, minus); + assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id)); + keys!(repeat, release); + assert!(!app.chat_widget.is_in_paste_burst()); + let hidden = side_frame(&mut app, &mut tui, size)?; + assert!( + hidden.contains(parent_row) && !hidden.contains(child_row) && !hidden.contains('│'), + "{hidden}" + ); + if width == 100 { + frames.push(format!("{width} columns, hidden\n{hidden}")); + assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft"); + // A new physical press after release belongs to the parent, including its final raw key. + keys!(minus, toggle); + } else { + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "parent draft-" + ); + boxed_future!(app.handle_start_side( + &mut tui, + &mut app_server, + parent_thread_id, + /*user_message*/ None + )) + .await?; + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + } + assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); + assert!(Arc::ptr_eq( + &channel, + &app.thread_event_channels[&side_thread_id].store + )); + assert!(app.transcript_view.is_select_mode()); + app.transcript_view.end_select_mode(); + let reopened = side_frame(&mut app, &mut tui, size)?; + assert!( + reopened.contains(child_row) && reopened.contains("child draft!"), + "{reopened}" + ); + assert_eq!( + reopened.contains("parent draft-"), + width == 100, + "{reopened}" + ); + frames.push(format!("{width} columns, reopened\n{reopened}")); + } + assert_app_snapshot!("hide_and_reopen_side_conversation", frames.join("\n\n")); + 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 89624bf43e..b49f16a8f9 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -609,12 +609,12 @@ async fn legacy_terminal_preserves_image_paste_without_reading_clipboard() -> Re KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), KeyEvent::new(KeyCode::Char('V'), KeyModifiers::ALT | KeyModifiers::SHIFT), ] { - assert!(!app.should_recover_vim_insert_escape(key)); + assert!(!app.should_recover_vim_insert_escape(&tui, key)); } - assert!(app.should_recover_vim_insert_escape(KeyEvent::new( - KeyCode::Char('x'), - KeyModifiers::ALT, - ))); + assert!(app.should_recover_vim_insert_escape( + &tui, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT,) + )); } Ok(()) } @@ -642,12 +642,12 @@ async fn legacy_terminal_preserves_agent_shortcuts_without_editor_word_bindings( for key in ['b', 'f'] { let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT); - assert!(!app.should_recover_vim_insert_escape(event)); + assert!(!app.should_recover_vim_insert_escape(&tui, event)); } app.chat_widget.insert_str("abc"); for key in ['b', 'f'] { let event = KeyEvent::new(KeyCode::Char(key), KeyModifiers::ALT); - assert!(app.should_recover_vim_insert_escape(event)); + assert!(app.should_recover_vim_insert_escape(&tui, event)); } } Ok(()) @@ -1044,6 +1044,36 @@ async fn transcript_fixed_keys_take_precedence_over_pager_chord_prefixes() -> Re before ); assert!(!app.key_chord_matcher.is_pending()); + super::enable_side_retention(&mut app)?; + let parent = ThreadId::new(); + app.active_thread_id = Some(parent); + app.side_threads + .insert(ThreadId::new(), super::SideThreadState::new(parent)); + app.keymap = RuntimeKeymap::for_session( + &toml::from_str("[pager]\nscroll_up = 'f6 u'\nscroll_down = 'f6 d'")?, + /*workspace*/ true, + ) + .unwrap(); + for (stroke, expected) in [ + ('u', before), + ( + 'd', + app.transcript_view.selected_text(&app.transcript_cells), + ), + ] { + press(&mut app, &mut tui, &mut app_server, KeyCode::F(6).into()).await?; + press( + &mut app, + &mut tui, + &mut app_server, + KeyCode::Char(stroke).into(), + ) + .await?; + assert_eq!( + app.transcript_view.selected_text(&app.transcript_cells), + expected + ); + } tui.set_owned_screen(/*owned*/ false)?; app_server.shutdown().await?; Ok(()) @@ -1139,8 +1169,19 @@ 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; let mut app_server = start_config_write_test_app_server(&app).await?; let mut tui = crate::tui::test_support::make_test_tui()?; - for config in ["", "[chat]\ntoggle_voice = 'f8 v'"] { - app.keymap = RuntimeKeymap::from_config(&toml::from_str(config)?).unwrap(); + super::enable_side_retention(&mut app)?; + tui.set_owned_screen(true)?; + let parent = ThreadId::new(); + app.active_thread_id = Some(parent); + app.side_threads + .insert(ThreadId::new(), super::SideThreadState::new(parent)); + for (config, key) in [ + ("", 8), + ("[chat]\ntoggle_voice = 'f8 v'", 8), + ("[global]\nhide_pane = 'f12'", 12), + ] { + app.keymap = + RuntimeKeymap::for_session(&toml::from_str(config)?, /*workspace*/ true).unwrap(); app.chat_widget.open_keymap_capture( "composer".into(), "submit".into(), @@ -1148,10 +1189,35 @@ async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> { crate::app_event::KeymapCaptureMode::SingleKey, &app.keymap, ); - press(&mut app, &mut tui, &mut app_server, KeyCode::F(8).into()).await?; + press(&mut app, &mut tui, &mut app_server, KeyCode::F(key).into()).await?; assert!( - matches!(rx.try_recv()?, crate::app_event::AppEvent::KeymapCaptured { key, .. } if key == "f8") + matches!(rx.try_recv()?, crate::app_event::AppEvent::KeymapCaptured { key: captured, .. } if captured == format!("f{key}")) ); } + // A printable remap belongs to the composer unless this session and thread own a pane. + app.keymap = RuntimeKeymap::for_session( + &toml::from_str("[global]\nhide_pane = '%'")?, + /*workspace*/ true, + ) + .unwrap(); + for (thread, enabled, owned, offline, draft) in [ + (parent, true, true, false, ""), + (ThreadId::new(), true, true, false, "%"), + (parent, false, true, false, "%"), + (parent, true, false, false, "%"), + (parent, true, true, true, "%"), + ] { + app.active_thread_id = Some(thread); + app.config + .features + .set_enabled(codex_features::Feature::TuiWorkspace, enabled)?; + tui.set_owned_screen(owned)?; + app.reconnect.offline = offline; + app.chat_widget.apply_external_edit(String::new()); + for key in [KeyCode::Char('%'), KeyCode::Right] { + press(&mut app, &mut tui, &mut app_server, key.into()).await?; + } + assert_eq!(app.chat_widget.composer_text_with_pending(), draft); + } Ok(()) } diff --git a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs index ca06b5196d..bb98947a1d 100644 --- a/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs +++ b/codex-rs/tui/src/app/tests/user_verification_routes_tests.rs @@ -95,7 +95,7 @@ async fn inactive_thread_user_verification_replays_queued_request_once() -> Resu #[tokio::test] async fn side_toggle_surfaces_pending_verification_from_an_inactive_thread() -> Result<()> { - Box::pin(async { + let check_return = async |hide: bool| -> Result<()> { let (mut app, _events, _ops) = make_test_app_with_channels().await; let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; @@ -116,6 +116,8 @@ async fn side_toggle_surfaces_pending_verification_from_an_inactive_thread() -> ), ); let mut tui = crate::tui::test_support::make_test_tui()?; + enable_side_retention(&mut app)?; + tui.set_owned_screen(/*owned*/ true)?; app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) .await?; assert_eq!(app.active_thread_id, Some(side_thread_id)); @@ -134,17 +136,26 @@ async fn side_toggle_surfaces_pending_verification_from_an_inactive_thread() -> .await?; assert!(!app.chat_widget.has_active_view()); - app.toggle_side_conversation(&mut tui, &mut app_server) - .await?; + if hide { + app.hide_focused_side_pane(&mut tui, &mut app_server) + .await?; + } else { + app.toggle_side_conversation(&mut tui, &mut app_server) + .await?; + } assert_eq!(app.active_thread_id, Some(primary_thread_id)); - assert!(app.chat_widget.has_active_view()); + assert!( + app.chat_widget.has_active_view(), + "missing verification prompt after return (hide={hide})" + ); assert!( render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Approve deployment?") ); app_server.shutdown().await?; Ok(()) - }) - .await + }; + Box::pin(check_return(/*hide*/ false)).await?; + Box::pin(check_return(/*hide*/ true)).await } #[tokio::test] diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index df6aa9e4bd..083245aa79 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -391,7 +391,10 @@ impl App { return; } let local_settings = self.local_settings.reloaded(&config); - let keymap = match RuntimeKeymap::from_config(&local_settings.tui.keymap) { + let keymap = match RuntimeKeymap::for_session( + &local_settings.tui.keymap, + local_settings.workspace_enabled(&config), + ) { Ok(keymap) => keymap, Err(error) => return self.chat_widget.add_error_message(error), }; diff --git a/codex-rs/tui/src/chatwidget/config_panel.rs b/codex-rs/tui/src/chatwidget/config_panel.rs index 444b2a7106..c204965d1e 100644 --- a/codex-rs/tui/src/chatwidget/config_panel.rs +++ b/codex-rs/tui/src/chatwidget/config_panel.rs @@ -69,8 +69,7 @@ impl ChatWidget { } } if let Some(activated_at) = handoff.active_at() { - self.bottom_pane - .suppress_current_view_key_until_release(handoff.activation_key, activated_at); + self.suppress_current_view_key_until_release(handoff.activation_key, activated_at); } self.bottom_pane.config_editor_opened(); } diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 25cdb4e95e..e5e74417cc 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -76,7 +76,11 @@ impl ChatWidget { &header_model, &model_catalog.try_list_models().unwrap_or_default(), ); - let runtime_keymap = RuntimeKeymap::from_config(&local_settings.tui.keymap).ok(); + let runtime_keymap = RuntimeKeymap::for_session( + &local_settings.tui.keymap, + local_settings.workspace_enabled(&config), + ) + .ok(); let default_keymap = RuntimeKeymap::defaults(); let copy_last_response_binding = runtime_keymap .as_ref() diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index ec3be5170a..6fa70a9cfb 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -87,6 +87,15 @@ impl ChatWidget { std::mem::take(&mut self.bottom_pane.key_chord_reset_requested) } + pub(crate) fn suppress_current_view_key_until_release( + &mut self, + key: KeyBinding, + activated_at: Instant, + ) { + self.bottom_pane + .suppress_current_view_key_until_release(key, activated_at); + } + pub(crate) fn consume_view_transition_key_event(&mut self, key_event: KeyEvent) -> bool { self.bottom_pane .consume_view_transition_key_event(key_event) diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 0732608461..56d32c034c 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -28,7 +28,10 @@ impl ChatWidget { /// overrides. If the config is invalid, the user sees the parse error instead of a partial /// picker that could commit edits against stale runtime state. pub(crate) fn open_keymap_picker(&mut self) { - match RuntimeKeymap::from_config(&self.local_settings.tui.keymap) { + match RuntimeKeymap::for_session( + &self.local_settings.tui.keymap, + self.local_settings.workspace_enabled(self.config_ref()), + ) { Ok(runtime_keymap) => { let params = keymap_setup::build_keymap_picker_params_with_filter( &runtime_keymap, @@ -156,6 +159,7 @@ impl ChatWidget { keymap_setup::KeymapActionFilter { fast_mode_enabled: self.fast_mode_enabled(), multi_backend_enabled: self.config.features.enabled(Feature::TuiMultiBackend), + workspace_enabled: self.local_settings.workspace_enabled(self.config_ref()), } } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index cd8131f782..2774779fa6 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -889,8 +889,10 @@ impl ChatWidget { SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() { "" => self.open_keymap_picker(), "debug" => { - match crate::keymap::RuntimeKeymap::from_config(&self.local_settings.tui.keymap) - { + match crate::keymap::RuntimeKeymap::for_session( + &self.local_settings.tui.keymap, + self.local_settings.workspace_enabled(self.config_ref()), + ) { Ok(runtime_keymap) => self.open_keymap_debug(&runtime_keymap), Err(err) => { self.add_error_message(format!( diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index b438dd6513..844670a50c 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -104,6 +104,7 @@ pub(crate) struct RuntimeKeymap { pub(crate) struct AppKeymap { /// Change where a new conversation runs. pub(crate) change_host: Vec, + pub(crate) hide_pane: Vec, /// Open the agent command center. pub(crate) open_agents: Vec, /// Open transcript overlay. @@ -654,7 +655,7 @@ impl RuntimeKeymap { DEFAULTS .get_or_init(|| { - Self::resolve(&TuiKeymap::default()).unwrap_or_else(|error| { + Self::resolve(&TuiKeymap::default(), /*workspace*/ false).unwrap_or_else(|error| { panic!("built-in keymap defaults must be valid: {error}") }) }) @@ -675,10 +676,18 @@ impl RuntimeKeymap { if keymap == &TuiKeymap::default() { return Ok(Self::defaults()); } - Self::resolve(keymap) + Self::resolve(keymap, /*workspace*/ false) } - fn resolve(keymap: &TuiKeymap) -> Result { + pub(crate) fn for_session(keymap: &TuiKeymap, workspace: bool) -> Result { + if workspace { + Self::resolve(keymap, workspace) + } else { + Self::from_config(keymap) + } + } + + fn resolve(keymap: &TuiKeymap, workspace: bool) -> Result { 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() @@ -730,6 +739,11 @@ impl RuntimeKeymap { &defaults.app.change_host, "tui.keymap.global.change_host", )?, + hide_pane: resolve_bindings( + keymap.global.hide_pane.as_ref(), + &[], + "tui.keymap.global.hide_pane", + )?, open_agents: resolve_bindings( keymap.global.open_agents.as_ref(), &defaults.app.open_agents, @@ -1678,7 +1692,7 @@ impl RuntimeKeymap { } } resolved.configure_search(keymap)?; - chords::leader_defaults::register(keymap, &mut resolved); + chords::leader_defaults::register(keymap, &mut resolved, workspace); resolved.validate_conflicts()?; chords::validate_chord_conflicts(&resolved)?; chords::install_dispatch_bindings(&mut resolved)?; @@ -1705,6 +1719,7 @@ impl RuntimeKeymap { Self { app: AppKeymap { change_host: Vec::new(), + hide_pane: Vec::new(), open_agents: default_bindings![], open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], find_transcript: default_bindings![plain(KeyCode::F(3))], @@ -2056,6 +2071,7 @@ impl RuntimeKeymap { for (action, bindings) in [ ("global.change_host", &self.app.change_host), ("global.open_agents", &self.app.open_agents), + ("global.hide_pane", &self.app.hide_pane), ("chat.open_presets", &self.chat.open_presets), ("chat.toggle_voice", &self.chat.toggle_voice), ] { @@ -2085,6 +2101,7 @@ impl RuntimeKeymap { let main_bindings = [ ("change_host", self.app.change_host.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()), ("find_transcript", self.app.find_transcript.as_slice()), @@ -2253,6 +2270,7 @@ impl RuntimeKeymap { validate_no_shadow_with_allowed_overlaps( "main", [ + ("hide_pane", self.app.hide_pane.as_slice()), ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), @@ -2378,6 +2396,7 @@ impl RuntimeKeymap { validate_unique( "transcript", [ + ("global.hide_pane", self.app.hide_pane.as_slice()), ( "global.select_transcript", self.app.select_transcript.as_slice(), @@ -2410,6 +2429,7 @@ impl RuntimeKeymap { validate_unique( "activity", context_bindings(KeymapContext::List).chain([ + ("global.hide_pane", self.app.hide_pane.as_slice()), ("global.focus_activity", self.app.focus_activity.as_slice()), ( "global.find_transcript", @@ -3871,6 +3891,27 @@ mod tests { #[test] fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() { + for (section, action) in [("pager", "page_up"), ("list", "move_down")] { + let config: TuiKeymap = toml::from_str(&format!( + "[global]\nhide_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}" + ); + } + #[cfg(unix)] + { + let mut config = TuiKeymap::default(); + config.global.hide_pane = Some(one("ctrl-z")); + assert!( + RuntimeKeymap::for_session(&config, /*workspace*/ true) + .unwrap_err() + .contains("suspend") + ); + } let mut keymap = TuiKeymap::default(); keymap.global.toggle_side_conversation = Some(one("f12")); @@ -4063,6 +4104,13 @@ mod tests { keymap.composer.toggle_shortcuts = Some(KeybindingsSpec::Many(vec![])); 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![])); + let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap(); + assert!( + runtime + .primary_hint(KeymapContext::Global, "hide_pane") + .is_none() + ); } #[test] diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index f662edf156..e139873068 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -103,12 +103,21 @@ pub(crate) struct KeymapActionId { } impl KeymapActionId { + pub(crate) fn is_side_pane(self) -> bool { + self.context == KeymapContext::Global && self.action == "hide_pane" + } + /// Whether two actions can be active together, including list-specific exceptions. pub(super) fn overlaps(self, other: Self) -> bool { use KeymapContext::Chat; use KeymapContext::Global; use KeymapContext::List; + if self.is_side_pane() && matches!(other.context, List | KeymapContext::Pager) + || other.is_side_pane() && matches!(self.context, List | KeymapContext::Pager) + { + return true; + } match (self.context, other.context) { // Presets cannot open while a list owns input. (Chat, List) => self.action != "open_presets", @@ -290,6 +299,7 @@ define_runtime_action_bindings! { find_transcript, select_transcript, focus_activity, + hide_pane, 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 2b07a95ecd..9648435a9e 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -54,6 +54,7 @@ const RAW_KEY_CAPTURE: u32 = 1 << 18; 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 TRANSCRIPT_BROWSING: u32 = 1 << 14; @@ -156,11 +157,18 @@ impl KeymapContextSet { } } + pub(crate) const fn with_side_pane(self) -> Self { + Self(self.0 | SIDE_PANE) + } + /// Whether this input path can dispatch the action, including focus-specific exclusions. pub(crate) fn contains_action(self, action: KeymapActionId) -> bool { if action.context == KeymapContext::Global && action.action == "change_host" { return self.0 & BACKEND_CYCLING != 0; } + if action.is_side_pane() { + return self.0 & SIDE_PANE != 0; + } if action.context == KeymapContext::Chat && action.action == "open_presets" { return self.contains(KeymapContext::Chat) && self.contains(KeymapContext::Global) diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index f559eebef5..12f80edc15 100644 --- a/codex-rs/tui/src/keymap/chords/leader_defaults.rs +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -1,4 +1,4 @@ -//! Install implicit preset and selection shortcuts only for free leader chords. +//! Install implicit backend, preset, selection, and side-pane shortcuts for free leader chords. //! Explicit bindings, including an explicit unbind, always take priority. //! Implicit chords fit within the shared leader expansion limit. @@ -13,7 +13,11 @@ use super::runtime_action_bindings; use codex_config::types::TuiKeymap; use crossterm::event::KeyCode; -pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) { +pub(in crate::keymap) fn register( + keymap: &TuiKeymap, + resolved: &mut RuntimeKeymap, + workspace: bool, +) { let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub( resolved .chords @@ -22,36 +26,29 @@ pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeym .filter(|binding| binding.is_leader()) .count(), ); - for (configured, context, action, completion, spec) in [ - ( - keymap.global.change_host.as_ref(), - KeymapContext::Global, - "change_host", - 'b', - "leader b", - ), - ( - keymap.global.select_transcript.as_ref(), - KeymapContext::Global, - "select_transcript", - '[', - "leader [", - ), - ( - keymap.chat.open_presets.as_ref(), - KeymapContext::Chat, - "open_presets", - 'p', - "leader p", - ), + use KeymapContext::Chat; + use KeymapContext::Global; + for (context, name, completion) in [ + (Global, "change_host", 'b'), + (Global, "select_transcript", '['), + (Chat, "open_presets", 'p'), + (Global, "hide_pane", '-'), ] { if remaining == 0 { break; } - if configured.is_some() { + let action = KeymapActionId { + context, + action: name, + }; + if (!workspace && action.is_side_pane()) + || !matches!( + super::configured_binding_for_action(keymap, action), + Some(None) + ) + { continue; } - let action = KeymapActionId { context, action }; for prefix in resolved.chords.leader.clone() { let binding = RuntimeChordBinding { action, @@ -59,7 +56,7 @@ pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeym prefix, completion: crate::key_hint::plain(KeyCode::Char(completion)), }, - spec: spec.into(), + spec: format!("leader {completion}"), }; if !resolved.chords.bindings.iter().any(|existing| { binding.overlaps(existing.action) && existing.chord == binding.chord diff --git a/codex-rs/tui/src/keymap_setup.rs b/codex-rs/tui/src/keymap_setup.rs index a6741befc9..d6c4d39f53 100644 --- a/codex-rs/tui/src/keymap_setup.rs +++ b/codex-rs/tui/src/keymap_setup.rs @@ -896,13 +896,15 @@ mod tests { #[test] fn picker_covers_every_replaceable_action() { - let runtime = RuntimeKeymap::defaults(); + let keymap = TuiKeymap::default(); + let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap(); let params = build_keymap_picker_params_with_filter( &runtime, - &TuiKeymap::default(), + &keymap, KeymapActionFilter { - multi_backend_enabled: true, fast_mode_enabled: true, + multi_backend_enabled: true, + workspace_enabled: true, }, ); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); @@ -1276,13 +1278,21 @@ mod tests { #[test] fn picker_all_tab_items_remain_searchable() { - let runtime = RuntimeKeymap::defaults(); - let params = build_keymap_picker_params(&runtime, &TuiKeymap::default()); + let keymap = TuiKeymap::default(); + let runtime = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap(); + let params = build_keymap_picker_params_with_filter( + &runtime, + &keymap, + KeymapActionFilter { + workspace_enabled: true, + ..Default::default() + }, + ); let all_tab = selection_tab(¶ms, KEYMAP_ALL_TAB_ID); let snapshot = all_tab .items .iter() - .take(12) + .take(13) .map(|item| { format!( "{} | {} | {}", diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 2d02d40ce0..b5a8840c85 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -36,12 +36,14 @@ pub(super) struct KeymapActionDescriptor { enum KeymapActionFeature { FastMode, MultiBackend, + Workspace, } #[derive(Clone, Copy, Debug, Default)] pub(crate) struct KeymapActionFilter { pub(crate) fast_mode_enabled: bool, pub(crate) multi_backend_enabled: bool, + pub(crate) workspace_enabled: bool, } impl KeymapActionDescriptor { @@ -50,6 +52,7 @@ impl KeymapActionDescriptor { None => true, Some(KeymapActionFeature::FastMode) => filter.fast_mode_enabled, Some(KeymapActionFeature::MultiBackend) => filter.multi_backend_enabled, + Some(KeymapActionFeature::Workspace) => filter.workspace_enabled, } } } @@ -115,6 +118,7 @@ macro_rules! define_keymap_actions { define_keymap_actions! { global "Global" { change_host [MultiBackend] => "Change where a new conversation runs.", + hide_pane [Workspace] => "Hide the focused sidebar pane.", 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/local_settings.rs b/codex-rs/tui/src/local_settings.rs index d7b9e738ae..a01cb9bbe7 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -44,6 +44,13 @@ impl From<&Config> for LocalSettings { } impl LocalSettings { + pub(crate) fn workspace_enabled(&self, config: &Config) -> bool { + self.transcript_mode.is_owned() + && config + .features + .enabled(codex_features::Feature::TuiWorkspace) + } + fn with_accessibility_preferences( config: &Config, system_motion: crate::motion::MotionMode, 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 new file mode 100644 index 0000000000..8978c14e5e --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap @@ -0,0 +1,63 @@ +--- +source: tui/src/app/tests.rs +expression: "frames.join(\"\\n\\n\")" +--- +100 columns, hidden +parent-24 + +parent-25 + +parent-26 + +parent-27 + +parent-28 + +parent-29 + + New activity · ↓ Back to bottom · esc + +› parent draft + + GPT-6.1-Sol default · /tmp/project · Main [default] + New activity · esc latest + +100 columns, reopened +parent-24 │ child-24 + │ +parent-25 │ child-25 + │ +parent-26 │ child-26 + │ +parent-27 │ child-27 + │ +parent-28 │ child-28 + │ +parent-29 │ child-29 + │ + │ + │ +› parent draft- │ › child draft! + │ + GPT-6.1-Sol default · /tmp/project │ GPT-6.1-Sol default · /tmp/projec… + │ Side from main thread · ⌃/ to swit + +99 columns, reopened +child-24 + +child-25 + +child-26 + +child-27 + +child-28 + +child-29 + + + +› child draft! + + GPT-6.1-Sol default · /tmp/project · Agent + Side from main thread · ⌃/ to switch · ⌃c to close 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 3b2b2eab4b..fc9830f3b1 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,7 @@ source: tui/src/keymap_setup.rs expression: snapshot --- +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 Find Transcript | f3 | Global find_transcript Find Transcript Find text in the full transcript. f3 Default