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