diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 93392f1f7e..afc599e09e 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -111,6 +111,8 @@ pub struct TuiGlobalKeymap { pub toggle_fast_mode: Option, /// Toggle raw scrollback mode for copy-friendly transcript selection. pub toggle_raw_output: Option, + /// Switch between a side conversation and its parent without closing either. + pub toggle_side_conversation: Option, } /// Chat context keybindings. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 8ffc5be4de..a499adc0a9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3240,6 +3240,7 @@ "toggle_fast_mode": null, "toggle_raw_output": null, "toggle_shortcuts": null, + "toggle_side_conversation": null, "toggle_vim_mode": null }, "list": { @@ -3818,6 +3819,14 @@ ], "description": "Toggle the composer shortcut overlay." }, + "toggle_side_conversation": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Switch between a side conversation and its parent without closing either." + }, "toggle_vim_mode": { "allOf": [ { @@ -3919,6 +3928,7 @@ "toggle_fast_mode": null, "toggle_raw_output": null, "toggle_shortcuts": null, + "toggle_side_conversation": null, "toggle_vim_mode": null } }, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 7b3e8c5adf..b8b1bce2b4 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2398,6 +2398,7 @@ impl App { self.keymap = runtime_keymap.clone(); self.chat_widget .apply_keymap_update(keymap_config, &runtime_keymap); + self.sync_side_thread_ui(); self.chat_widget .return_to_keymap_picker(&context, &action, &runtime_keymap); self.chat_widget.add_info_message(message, /*hint*/ None); @@ -2448,6 +2449,7 @@ impl App { self.keymap = runtime_keymap.clone(); self.chat_widget .apply_keymap_update(keymap_config, &runtime_keymap); + self.sync_side_thread_ui(); self.chat_widget .return_to_keymap_picker(&context, &action, &runtime_keymap); self.chat_widget.add_info_message( diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index bbfb52f46e..dcd88dfb82 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -142,6 +142,19 @@ impl App { let app_keymap_shortcuts_available = self.app_keymap_shortcuts_available(); + let side_toggle_bindings = &self.keymap.app.toggle_side_conversation; + if app_keymap_shortcuts_available + && (side_toggle_bindings.is_pressed(key_event) + || side_toggle_bindings.contains(&crate::key_hint::ctrl(KeyCode::Char('/'))) + && crate::key_hint::ctrl(KeyCode::Char('7')).is_press(key_event)) + { + if let Err(err) = self.toggle_side_conversation(tui, app_server).await { + self.chat_widget + .add_error_message(format!("Failed to switch side conversation: {err}")); + } + return; + } + if app_keymap_shortcuts_available && self.keymap.app.toggle_vim_mode.is_pressed(key_event) { self.chat_widget.toggle_vim_mode_and_notify(); return; diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 50cf8a9213..3e85ed65ef 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -20,7 +20,7 @@ const SIDE_NO_STARTED_CONVERSATION_MESSAGE: &str = concat!( "Send a message first, then try /side again." ); const SIDE_ALREADY_OPEN_MESSAGE: &str = - "A side conversation is already open. Press Ctrl+C to return before starting another."; + "A side conversation is already open. Press ctrl + c to return before starting another."; const SIDE_BOUNDARY_PROMPT: &str = r#"Side conversation boundary. Everything before this boundary is inherited history from the parent thread. It is reference context only. It is not your current task. @@ -236,6 +236,19 @@ impl App { .map(|state| (state.parent_thread_id, state.parent_status)) else { clear_side_ui(&mut self.chat_widget); + if self + .side_threads + .values() + .any(|state| state.parent_thread_id == active_thread_id) + && let Some(binding) = + crate::keymap::primary_binding(&self.keymap.app.toggle_side_conversation) + { + self.chat_widget + .set_side_conversation_context_label(Some(format!( + "{} for side", + binding.display_label() + ))); + } return; }; @@ -256,7 +269,12 @@ impl App { if let Some(parent_status) = parent_status { label_parts.push(parent_status.label(parent_is_main).to_string()); } - label_parts.push("Ctrl+C to return".to_string()); + if let Some(binding) = + crate::keymap::primary_binding(&self.keymap.app.toggle_side_conversation) + { + label_parts.push(format!("{} to switch", binding.display_label())); + } + label_parts.push("ctrl + c to close".to_string()); self.chat_widget .set_side_conversation_context_label(Some(format!("Side {}", label_parts.join(" · ")))); } @@ -353,12 +371,37 @@ impl App { &self, target_thread_id: ThreadId, ) -> Option { - let side_thread_id = self.current_displayed_thread_id()?; - if target_thread_id == side_thread_id || !self.side_threads.contains_key(&side_thread_id) { + let active_thread_id = self.current_displayed_thread_id()?; + let (&side_thread_id, state) = self.side_threads.iter().next()?; + if target_thread_id == side_thread_id || target_thread_id == active_thread_id { return None; } - Some(side_thread_id) + (active_thread_id == side_thread_id || active_thread_id == state.parent_thread_id) + .then_some(side_thread_id) + } + + pub(super) async fn toggle_side_conversation( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + ) -> Result<()> { + let Some(active_thread_id) = self.current_displayed_thread_id() else { + return Ok(()); + }; + let Some((&side_thread_id, state)) = self.side_threads.iter().next() else { + return Ok(()); + }; + let target_thread_id = if active_thread_id == side_thread_id { + state.parent_thread_id + } else if active_thread_id == state.parent_thread_id { + side_thread_id + } else { + return Ok(()); + }; + + self.select_agent_thread(tui, app_server, target_thread_id) + .await } pub(super) async fn discard_side_thread( @@ -484,7 +527,7 @@ impl App { pub(super) fn side_start_block_message(&self) -> Option<&'static str> { if self.primary_thread_id.is_none() { Some(SIDE_MAIN_THREAD_UNAVAILABLE_MESSAGE) - } else if !self.side_threads.is_empty() { + } else if self.active_side_parent_thread_id().is_some() { Some(SIDE_ALREADY_OPEN_MESSAGE) } else { None @@ -530,7 +573,6 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result<()> { - let active_thread_id_before_switch = self.active_thread_id; let side_thread_to_discard = self.side_thread_to_discard_after_switch(thread_id); self.select_agent_thread(tui, app_server, thread_id).await?; if self.active_thread_id == Some(thread_id) @@ -539,7 +581,7 @@ impl App { if self.discard_side_thread(app_server, side_thread_id).await { self.surface_pending_inactive_thread_interactive_requests() .await?; - } else if active_thread_id_before_switch == Some(side_thread_id) { + } else { self.keep_side_thread_visible_after_cleanup_failure( tui, app_server, @@ -565,6 +607,15 @@ impl App { return Ok(AppRunControl::Continue); } + if let Some((&side_thread_id, state)) = self.side_threads.iter().next() + && (parent_thread_id != state.parent_thread_id + || !self.discard_side_thread(app_server, side_thread_id).await) + { + self.restore_side_user_message(user_message.take()); + self.sync_side_thread_ui(); + return Ok(AppRunControl::Continue); + } + self.session_telemetry.counter( "codex.thread.side", /*inc*/ 1, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index f9cb758eaa..e27e82522d 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3788,7 +3788,7 @@ async fn side_fork_config_inherits_parent_thread_runtime_settings() { } #[tokio::test] -async fn side_start_block_message_tracks_open_side_conversation() { +async fn side_start_block_message_allows_replacing_open_side_conversation() { let mut app = make_test_app().await; assert_eq!( app.side_start_block_message(), @@ -3803,10 +3803,14 @@ async fn side_start_block_message_tracks_open_side_conversation() { app.side_threads .insert(side_thread_id, SideThreadState::new(parent_thread_id)); + app.active_thread_id = Some(parent_thread_id); + assert_eq!(app.side_start_block_message(), None); + + app.active_thread_id = Some(side_thread_id); assert_eq!( app.side_start_block_message(), Some( - "A side conversation is already open. Press Ctrl+C to return before starting another." + "A side conversation is already open. Press ctrl + c to return before starting another." ) ); @@ -4269,6 +4273,16 @@ async fn side_discard_selection_keeps_current_side_thread() { app.side_thread_to_discard_after_switch(parent_thread_id), Some(side_thread_id) ); + + app.active_thread_id = Some(parent_thread_id); + assert_eq!( + app.side_thread_to_discard_after_switch(ThreadId::new()), + Some(side_thread_id) + ); + assert_eq!( + app.side_thread_to_discard_after_switch(side_thread_id), + None + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index d060666a22..f6490dcef8 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -14,6 +14,10 @@ use codex_app_server_protocol::WarningNotification; impl App { pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { + let side_thread_ids: Vec = self.side_threads.keys().copied().collect(); + for side_thread_id in side_thread_ids { + self.discard_side_thread(app_server, side_thread_id).await; + } if let Some(thread_id) = self.chat_widget.thread_id() { if let Err(err) = app_server.thread_unsubscribe(thread_id).await { tracing::warn!("failed to unsubscribe thread {thread_id}: {err}"); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 189e2dfe6d..bf7c5a5260 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -593,7 +593,7 @@ pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> { if let Some(rest) = label.strip_prefix("Side ") { Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()]) } else { - Line::from(label.to_string()).magenta() + Line::from(vec![Span::from(label.to_string()).magenta()]) } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap index 186c35b597..6ab706777b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_preserves_status_line.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Check recently modified functions for compatibility " " " -" gpt-5.6-sol Side from main thread · Ctrl+C to return " +" gpt-5.6-sol Side from main thread · ctrl + / to switch · ctrl + c to close " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_hidden_side.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_hidden_side.snap new file mode 100644 index 0000000000..64931540dd --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_hidden_side.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/side.rs +expression: terminal.backend() +--- +" " +" " +"› Ask Codex to do anything " +" " +" gpt-5.6-sol ctrl + / for side " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap index 0427c3926c..84c11757e5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__side_context_label_shows_parent_status.snap @@ -6,4 +6,4 @@ expression: terminal.backend() " " "› Check recently modified functions for compatibility " " " -" gpt-5.6-sol def… Side from main thread · main needs input · Ctrl+C to return " +" Side from main thread · main needs input · ctrl + / to switch · ctrl + c to cl" diff --git a/codex-rs/tui/src/chatwidget/tests/side.rs b/codex-rs/tui/src/chatwidget/tests/side.rs index 39a0cb9d73..3b5863d2d6 100644 --- a/codex-rs/tui/src/chatwidget/tests/side.rs +++ b/codex-rs/tui/src/chatwidget/tests/side.rs @@ -389,7 +389,7 @@ async fn side_context_label_preserves_status_line_snapshot() { chat.refresh_status_line(); chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( - "Side from main thread · Ctrl+C to return".to_string(), + "Side from main thread · ctrl + / to switch · ctrl + c to close".to_string(), )); let width = 80; @@ -410,7 +410,8 @@ async fn side_context_label_shows_parent_status_snapshot() { chat.show_welcome_banner = false; chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( - "Side from main thread · main needs input · Ctrl+C to return".to_string(), + "Side from main thread · main needs input · ctrl + / to switch · ctrl + c to close" + .to_string(), )); let width = 80; @@ -421,3 +422,27 @@ async fn side_context_label_shows_parent_status_snapshot() { .expect("draw side conversation footer"); assert_chatwidget_snapshot!("side_context_label_shows_parent_status", terminal.backend()); } + +#[tokio::test] +async fn side_context_label_shows_hidden_side_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.config.tui_status_line = Some(vec!["model-name".to_string()]); + chat.refresh_status_line(); + chat.set_side_conversation_context_label(Some("ctrl + / for side".to_string())); + + let width = 80; + let height = chat.desired_height(width); + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw hidden side conversation footer"); + let hint_start = width - "ctrl + / for side".len() as u16 - 2; + assert_eq!( + terminal.backend().buffer()[(hint_start, height - 1)] + .style() + .fg, + Some(ratatui::style::Color::Magenta) + ); + assert_chatwidget_snapshot!("side_context_label_shows_hidden_side", terminal.backend()); +} diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index b4f00a4d2f..7c06550822 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -70,6 +70,8 @@ pub(crate) struct AppKeymap { pub(crate) toggle_fast_mode: Vec, /// Toggle raw scrollback mode for copy-friendly transcript selection. pub(crate) toggle_raw_output: Vec, + /// Switch between a side conversation and its parent without closing either. + pub(crate) toggle_side_conversation: Vec, } /// Chat-level keybindings evaluated at the app event layer. @@ -385,6 +387,12 @@ impl RuntimeKeymap { /// dispatch, or conflict guarantees from this resolver no longer hold. pub(crate) fn from_config(keymap: &TuiKeymap) -> Result { let defaults = Self::built_in_defaults(); + let side_toggle_default_is_shadowed = keymap.global.toggle_side_conversation.is_none() + && ["ctrl-/", "ctrl-7"].into_iter().any(|alias| { + configured_main_surface_alias_is_used(keymap, alias) + || configured_context_alias_is_used(&keymap.list, alias) + || configured_context_alias_is_used(&keymap.approval, alias) + }); let app = AppKeymap { open_transcript: resolve_bindings( @@ -422,6 +430,15 @@ impl RuntimeKeymap { &defaults.app.toggle_raw_output, "tui.keymap.global.toggle_raw_output", )?, + toggle_side_conversation: if side_toggle_default_is_shadowed { + Vec::new() + } else { + resolve_bindings( + keymap.global.toggle_side_conversation.as_ref(), + &defaults.app.toggle_side_conversation, + "tui.keymap.global.toggle_side_conversation", + )? + }, }; let mut chat = ChatKeymap { @@ -809,6 +826,10 @@ impl RuntimeKeymap { keymap.global.toggle_raw_output.as_ref(), app.toggle_raw_output.as_slice(), ), + ( + keymap.global.toggle_side_conversation.as_ref(), + app.toggle_side_conversation.as_slice(), + ), (keymap.list.move_up.as_ref(), list_move_up.as_slice()), (keymap.list.move_down.as_ref(), list_move_down.as_slice()), (keymap.list.accept.as_ref(), list_accept.as_slice()), @@ -916,6 +937,7 @@ impl RuntimeKeymap { toggle_vim_mode: default_bindings![], toggle_fast_mode: default_bindings![], toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))], + toggle_side_conversation: default_bindings![ctrl(KeyCode::Char('/'))], }, chat: ChatKeymap { interrupt_turn: default_bindings![plain(KeyCode::Esc)], @@ -1162,6 +1184,15 @@ impl RuntimeKeymap { /// 2. Contexts with hard-coded sequence behavior, such as edit-previous /// backtracking, intentionally stay outside this configurable keymap. fn validate_conflicts(&self) -> Result<(), String> { + let mut side_toggle_bindings = self.app.toggle_side_conversation.clone(); + let slash_binding = key_hint::ctrl(KeyCode::Char('/')); + let legacy_slash_binding = key_hint::ctrl(KeyCode::Char('7')); + if side_toggle_bindings.contains(&slash_binding) + && !side_toggle_bindings.contains(&legacy_slash_binding) + { + side_toggle_bindings.push(legacy_slash_binding); + } + validate_unique( "app", [ @@ -1175,6 +1206,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("toggle_side_conversation", side_toggle_bindings.as_slice()), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", @@ -1218,6 +1250,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("toggle_side_conversation", side_toggle_bindings.as_slice()), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", @@ -1267,6 +1300,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("toggle_side_conversation", side_toggle_bindings.as_slice()), ], [ ("list.move_up", self.list.move_up.as_slice()), @@ -1341,6 +1375,7 @@ impl RuntimeKeymap { ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), + ("toggle_side_conversation", side_toggle_bindings.as_slice()), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), @@ -2684,6 +2719,55 @@ mod tests { expect_conflict(&keymap, "clear_terminal", "toggle_fast_mode"); } + #[test] + fn toggle_side_conversation_can_be_remapped_and_rejects_conflicts() { + let mut keymap = TuiKeymap::default(); + keymap.global.toggle_side_conversation = Some(one("f12")); + + let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); + assert_eq!( + runtime.app.toggle_side_conversation, + vec![key_hint::plain(KeyCode::F(12))] + ); + + keymap.global.toggle_side_conversation = Some(one("ctrl-l")); + expect_conflict(&keymap, "clear_terminal", "toggle_side_conversation"); + } + + #[test] + fn toggle_side_conversation_default_yields_to_existing_configured_bindings() { + for binding in ["ctrl-/", "ctrl-7"] { + let mut keymap = TuiKeymap::default(); + keymap.global.open_transcript = Some(one(binding)); + + let runtime = + RuntimeKeymap::from_config(&keymap).expect("existing keymap remains valid"); + + assert_eq!( + runtime.app.open_transcript, + vec![parse_keybinding(binding).expect("configured binding")] + ); + assert!(runtime.app.toggle_side_conversation.is_empty()); + } + + let mut keymap = TuiKeymap::default(); + keymap.global.open_transcript = Some(one("ctrl-/")); + keymap.global.copy = Some(one("ctrl-7")); + + let runtime = + RuntimeKeymap::from_config(&keymap).expect("distinct legacy bindings remain valid"); + assert!(runtime.app.toggle_side_conversation.is_empty()); + } + + #[test] + fn explicit_side_toggle_rejects_legacy_ctrl_slash_alias_conflicts() { + let mut keymap = TuiKeymap::default(); + keymap.global.open_transcript = Some(one("ctrl-7")); + keymap.global.toggle_side_conversation = Some(one("ctrl-/")); + + expect_conflict(&keymap, "open_transcript", "toggle_side_conversation"); + } + #[test] fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() { let mut keymap = TuiKeymap::default(); diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index b355cda69f..ed924247be 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -93,6 +93,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[ action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."), gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode), action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."), + action("global", "Global", "toggle_side_conversation", "Switch between a side conversation and its parent."), action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."), action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."), action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."), @@ -236,6 +237,7 @@ pub(super) fn binding_slot<'a>( ("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode), ("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode), ("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output), + ("global", "toggle_side_conversation") => Some(&mut keymap.global.toggle_side_conversation), ("chat", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn), ("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort), ("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort), @@ -361,6 +363,7 @@ pub(super) fn bindings_for_action<'a>( ("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()), ("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()), ("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()), + ("global", "toggle_side_conversation") => Some(runtime_keymap.app.toggle_side_conversation.as_slice()), ("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()), ("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()), ("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()), 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 454f2adb6a..96b96403d7 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 @@ -8,9 +8,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default +Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default -Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap index 5e6c520f36..7332b06ead 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_custom.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 108 actions, 1 customized, 2 unbound. + 109 actions, 1 customized, 2 unbound. [All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -16,7 +16,7 @@ expression: "render_picker(params, 120)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Global Toggle Side Conversation ctrl-/ Chat Interrupt Turn esc - Chat Decrease Reasoning Effort alt-,, shift-down left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap index 3438158332..083b5812e2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_fast_mode_enabled.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 109 actions, 0 customized, 3 unbound. + 110 actions, 0 customized, 3 unbound. [All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug @@ -17,6 +17,6 @@ expression: "render_picker(params, 120)" Global - Toggle Vim Mode unbound Global - Toggle Fast Mode unbound Global Toggle Raw Output alt-r - Chat Interrupt Turn esc + Global Toggle Side Conversation ctrl-/ left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap index 7a1e796cd7..b38e163496 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_first_actions.snap @@ -2,11 +2,11 @@ source: tui/src/keymap_setup.rs expression: snapshot --- -tab: All (108 selectable) +tab: All (109 selectable) tab: Common (20 selectable) tab: Customized (0) (0 selectable) tab: Unbound (2) (2 selectable) -tab: App (10 selectable) +tab: App (11 selectable) tab: Composer (5 selectable) tab: Editor (17 selectable) tab: Vim (48 selectable) @@ -19,9 +19,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard. Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default +Toggle Side Conversation | ctrl-/ | Global toggle_side_conversation Toggle Side Conversation Switch between a side conversation and its parent. ctrl-/ Default Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default Decrease Reasoning Effort | alt-,, shift-down | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-,, shift-down Default Increase Reasoning Effort | alt-., shift-up | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-., shift-up Default Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default Submit | enter | Composer submit Submit Submit the current composer draft. enter Default -Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap index a94c326890..a389ef1ebe 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_narrow.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 78)" Keymap All configurable shortcuts. - 108 actions, 0 customized, 2 unbound. + 109 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -17,7 +17,7 @@ expression: "render_picker(params, 78)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Global Toggle Side Conversation ctrl-/ Chat Interrupt Turn esc - Chat Decrease Reasoning Effort alt-,, shift-down left/right group · enter edit shortcut · * custom · - unbound · esc close diff --git a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap index 5d0ae55ef1..4ccc5e4871 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__keymap_setup__tests__keymap_picker_wide.snap @@ -5,7 +5,7 @@ expression: "render_picker(params, 120)" Keymap All configurable shortcuts. - 108 actions, 0 customized, 2 unbound. + 109 actions, 0 customized, 2 unbound. [All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug @@ -16,7 +16,7 @@ expression: "render_picker(params, 120)" Global Clear Terminal ctrl-l Global - Toggle Vim Mode unbound Global Toggle Raw Output alt-r + Global Toggle Side Conversation ctrl-/ Chat Interrupt Turn esc - Chat Decrease Reasoning Effort alt-,, shift-down left/right group · enter edit shortcut · * custom · - unbound · esc close