From 9df200a0680f5653ffb69725ecac6a201e2790fb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 5 Nov 2026 23:02:02 +0000 Subject: [PATCH] Add sidebar tabs and nonblocking side-chat creation (#59767) ## Why Starting a side chat waits for fork and setup requests. Keep the workspace responsive during those requests and let users return to an existing side chat through the sidebar chooser. ## What changed - Add a clickable `Side chat` tab and `+` chooser, plus the configurable `focus_sidebar_tabs` action bound to `leader t` by default. - Create workspace side chats in the background, showing loading and failure states with `Enter` to retry and `Esc` to go back. Preserve opening prompts and avoid taking focus when the user has moved away. - Retain inherited permission profiles and defer startup notifications until the side chat is registered on its owning backend. - Quiet the parent composer while the chooser has focus and prevent completion popups or repeated activation keys from consuming chooser input. ## Testing Extend tests and snapshots for loading and failure states, returning to an existing chat, draft preservation, inherited permissions, focus transitions, and deferred MCP startup notifications. GitOrigin-RevId: 244bcd3f170819e2260f21dc1041cf8899fe048c --- codex-rs/config/src/tui_keymap.rs | 2 + codex-rs/core/config.schema.json | 10 + codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/app/backend_sessions.rs | 33 ++- codex-rs/tui/src/app/event_dispatch.rs | 17 +- codex-rs/tui/src/app/owned_transcript.rs | 76 +++++- codex-rs/tui/src/app/session_lifecycle.rs | 9 + codex-rs/tui/src/app/side.rs | 225 +++++++++++++++--- codex-rs/tui/src/app/side_companion.rs | 21 ++ codex-rs/tui/src/app/side_focus.rs | 167 ++++++++++--- codex-rs/tui/src/app/side_pane.rs | 31 +-- codex-rs/tui/src/app/sidebar.rs | 81 ++++++- ...i__app__tests__workspace_leader_hints.snap | 8 +- codex-rs/tui/src/app/startup.rs | 14 ++ codex-rs/tui/src/app/tests.rs | 127 +++++++++- .../src/app/tests/backend_sessions_tests.rs | 53 +++++ codex-rs/tui/src/app/tests/key_chords.rs | 45 +++- ...__key_chords__sidebar_failed_creation.snap | 8 + ...__tests__key_chords__sidebar_new_chat.snap | 20 +- codex-rs/tui/src/app/workspace_hud.rs | 1 + codex-rs/tui/src/app_event.rs | 7 + codex-rs/tui/src/app_server_session.rs | 51 ++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 43 +--- .../bottom_pane/chat_composer/footer_state.rs | 1 + .../chat_composer/status_surface.rs | 34 +++ codex-rs/tui/src/bottom_pane/footer.rs | 9 + codex-rs/tui/src/bottom_pane/mod.rs | 8 +- codex-rs/tui/src/chatwidget/interaction.rs | 4 + codex-rs/tui/src/keymap.rs | 41 +++- codex-rs/tui/src/keymap/bindings.rs | 2 + codex-rs/tui/src/keymap/chords.rs | 2 +- .../tui/src/keymap/chords/leader_defaults.rs | 1 + codex-rs/tui/src/keymap_setup/actions.rs | 1 + codex-rs/tui/src/sidebar_chooser.rs | 68 +++++- ...ts__hide_and_reopen_side_conversation.snap | 26 +- ...tui__app__tests__keyboard_pane_widths.snap | 10 +- ...ui__app__tests__sidebar_existing_chat.snap | 23 ++ ...dex_tui__app__tests__single_side_chat.snap | 24 +- ...__tests__keymap_picker_all_tab_search.snap | 2 +- 39 files changed, 1085 insertions(+), 222 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_failed_creation.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__sidebar_existing_chat.snap diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 156d106be4..95404963ba 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -141,6 +141,8 @@ pub struct TuiGlobalKeymap { pub focus_main_composer: Option, /// Focus the side conversation, or hide it when already focused. pub focus_sidebar_content: Option, + /// Open the sidebar tab chooser. + pub focus_sidebar_tabs: Option, /// Hide the focused sidebar pane. pub hide_pane: Option, /// Focus the parent conversation. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 321a7801d5..da77de3dd6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5327,6 +5327,7 @@ "focus_pane_left": null, "focus_pane_right": null, "focus_sidebar_content": null, + "focus_sidebar_tabs": null, "hide_pane": null, "leader": null, "maximize_pane_width": null, @@ -6211,6 +6212,14 @@ ], "description": "Focus the side conversation, or hide it when already focused." }, + "focus_sidebar_tabs": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the sidebar tab chooser." + }, "hide_pane": { "allOf": [ { @@ -6485,6 +6494,7 @@ "focus_pane_left": null, "focus_pane_right": null, "focus_sidebar_content": null, + "focus_sidebar_tabs": null, "hide_pane": null, "leader": null, "maximize_pane_width": null, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7210924ae0..d30d6c9739 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1087,7 +1087,7 @@ impl App { { return Ok(AppRunControl::Continue); } - if self.handle_chooser_input(tui, &event) { + if Box::pin(self.handle_sidebar_input(tui, app_server, &event)).await { return Ok(AppRunControl::Continue); } if let TuiEvent::Mouse(press) = &event diff --git a/codex-rs/tui/src/app/backend_sessions.rs b/codex-rs/tui/src/app/backend_sessions.rs index 1a9f090c5b..fa378dacef 100644 --- a/codex-rs/tui/src/app/backend_sessions.rs +++ b/codex-rs/tui/src/app/backend_sessions.rs @@ -49,7 +49,8 @@ struct SessionEntry { preference_key: Option, session: AppServerSession, listening: bool, - pending_requests: VecDeque, + pending_events: VecDeque, + side_startup: Vec, } /// A session's stable position in an append-only backend collection. @@ -89,7 +90,8 @@ impl BackendSessions { preference_key: None, session, listening: true, - pending_requests: VecDeque::new(), + pending_events: VecDeque::new(), + side_startup: Vec::new(), }); } @@ -192,6 +194,22 @@ impl BackendSessions { self.listening_slot(owner).is_some() } + pub(super) fn replay_ready_side_startup(&mut self, app: &super::App) { + let owner = self.displayed_owner(); + let entry = &mut self.entries[self.displayed.0]; + entry + .pending_events + .extend(entry.side_startup.extract_if(.., |event| { + !app.defer_side_startup_notification(event, owner) + })); + } + + pub(super) fn defer_side_startup(&mut self, event: AppServerSessionEvent) { + if let Some(slot) = self.slot(event.backend_id) { + self.entries[slot.0].side_startup.push(event); + } + } + pub(super) fn resume_events(&mut self, owner: BackendId) { if let Some(entry) = self .entries @@ -199,7 +217,8 @@ impl BackendSessions { .find(|entry| entry.session.backend_id() == owner) { entry.listening = true; - entry.pending_requests.clear(); + entry.pending_events.clear(); + entry.side_startup.clear(); } } @@ -217,7 +236,7 @@ impl BackendSessions { continue; } let event = if owner == request_owner - && let Some(event) = entry.pending_requests.pop_front() + && let Some(event) = entry.pending_events.pop_front() { Some(event) } else { @@ -233,7 +252,7 @@ impl BackendSessions { if owner != request_owner && matches!(&event.event, AppServerEvent::ServerRequest(_)) => { - entry.pending_requests.push_back(event); + entry.pending_events.push_back(event); cx.waker().wake_by_ref(); continue; } @@ -242,7 +261,7 @@ impl BackendSessions { if let Some(AppServerSessionEvent { event: AppServerEvent::ServerNotification(notification), .. }) = &event && let ServerNotification::ServerRequestResolved(resolved) = notification.as_ref() { - entry.pending_requests.retain(|pending| !matches!( + entry.pending_events.retain(|pending| !matches!( &pending.event, AppServerEvent::ServerRequest(request) if request.id() == &resolved.request_id )); } @@ -250,7 +269,7 @@ impl BackendSessions { .as_ref() .is_none_or(|event| matches!(event.event, AppServerEvent::Disconnected { .. })) { - entry.pending_requests.clear(); + entry.pending_events.clear(); } entry.listening = event.is_some(); self.next_source = (index + 1) % self.entries.len(); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ad3e8f3ba1..b2d1c01406 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -90,6 +90,7 @@ impl App { | AppEvent::InsertHistoryCell(_) | AppEvent::CommitRealtimeTranscriptHistory | AppEvent::ParkSideCompanion { .. } + | AppEvent::SideCreated { .. } | AppEvent::SideReplayFinished(_) | AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ResetTranscriptForThreadSwitchPreservingScreen @@ -1051,7 +1052,9 @@ impl App { AppEvent::ResetTranscriptForThreadSwitchPreservingScreen => { self.reset_transcript_state_after_clear(); tui.clear_pending_history_lines(); - tui.defer_thread_switch_clear(); + if self.conversation_frame.chooser_area().is_none() && self.chooser_focus().is_none() { + tui.defer_thread_switch_clear(); + } self.pending_thread_switch_resets -= 1; } AppEvent::CommitRealtimeTranscriptHistory => { @@ -3362,9 +3365,15 @@ impl App { parent_thread_id, user_message, } => { - return self - .handle_start_side(tui, app_server, parent_thread_id, user_message) - .await; + return Box::pin(self.handle_start_side(tui, app_server, parent_thread_id, user_message)).await; + } + AppEvent::SideCreated { + backend_id: _, + parent, + request, + result, + } => { + Box::pin(self.finish_side_creation(tui, app_server, parent, request, result)).await; } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index cc2fb7b69f..b42a5d14e2 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -21,6 +21,7 @@ use crate::keymap::configured_binding_for_action; use crate::keymap::keymap_action_ids; use crate::motion::MotionMode; use crate::pager_overlay::TranscriptHistoryState; +use crate::sidebar_chooser::TabState; use crate::terminal_hyperlinks::HyperlinkLine; use crate::transcript_image::ImagePlacement; use crate::transcript_view::TranscriptView; @@ -34,6 +35,7 @@ use ratatui::widgets::Widget; pub(super) struct ConversationFrame { inset: Option, sidebar: Option, + pub(super) chat_strip: Option, pub(super) leader_hud: Option, /// A dialog can obscure neighboring content in the terminal's completed buffer. behind_dialog: Option, @@ -47,7 +49,7 @@ impl ConversationFrame { } pub(super) fn has_caller_inset(&self) -> bool { - self.inset.is_some() && self.sidebar.is_none() + self.inset.is_some() && self.sidebar.is_none() && self.chat_strip.is_none() } pub(super) fn side_pane(&self) -> Option<&side_pane::SidePaneLayout> { @@ -229,20 +231,39 @@ impl App { ) -> Result { self.transcript_view.image_protocol = self.prepare_frame_images(); let frames = tui.frame_requester(); - if self.conversation_frame.sidebar.is_some() { + if self.conversation_frame.sidebar.is_some() || self.conversation_frame.chat_strip.is_some() + { // Recompute our own inset on every frame, including the first narrow frame. self.conversation_frame = ConversationFrame::default(); } let caller_inset = self.conversation_frame.inset; let layout = sidebar::paint_layout(self, tui, screen_size.into()); - let live = match &layout { - Some(sidebar::SidebarLayout::Chat(panes)) => Some(panes.focused), - Some(sidebar::SidebarLayout::Chooser { main, .. }) if !main.is_empty() => Some(*main), - _ => None, + let displayed = self.current_displayed_thread_id(); + let chat = match displayed.and_then(|id| self.side_pair_key(id)) { + Some(child) if Some(child) == displayed => TabState::Active, + Some(_) => TabState::Inactive, + None => TabState::Absent, + }; + let (live, strip) = match &layout { + Some(sidebar::SidebarLayout::Chat(panes)) => { + (Some(panes.focused), Some(panes.tab_strip)) + } + Some(sidebar::SidebarLayout::Chooser { main, .. }) => { + ((!main.is_empty()).then_some(*main), None) + } + None if chat == TabState::Active + && caller_inset.is_none() + && self.side_retention_enabled(tui) => + { + let [strip, body] = side_pane::tab_and_body(screen_size.into()); + (Some(body), Some(strip)) + } + None => (None, None), }; let mut prepared = self.prepare_conversation(screen_size.into(), caller_inset.or(live), &frames); self.conversation_frame.sidebar = layout; + self.conversation_frame.chat_strip = strip; if self.divider_drag.is_some_and(|drag| { !drag.matches(screen_size.into(), self.conversation_frame.side_pane()) || !self.conversation_focus_available(tui) @@ -294,7 +315,34 @@ impl App { focus, }) => { let focused = *focus == PaneFocus::Sidebar; - crate::sidebar_chooser::paint(focused, *sidebar, frame.buffer, &self.keymap); + if let Some(creation) = self.sidebar().and_then(|pane| pane.creation.as_ref()) { + use sidebar::CreationState; + ratatui::widgets::Clear.render(*sidebar, frame.buffer); + let body = sidebar.inner(ratatui::layout::Margin::new(1, 1)); + let text = match &creation.state { + CreationState::Waiting { started, .. } => { + let mode = MotionMode::from_animations_enabled( + self.local_settings.tui.animations, + ); + let glyph = crate::motion::loading_glyph(*started, mode, &frames); + format!("{glyph} Starting side chat…") + } + CreationState::Failed { message, .. } => { + format!("{message}\nEnter retry · Esc back") + } + }; + ratatui::widgets::Paragraph::new(text) + .wrap(ratatui::widgets::Wrap { trim: false }) + .render(body, frame.buffer); + } else { + crate::sidebar_chooser::paint( + focused, + *sidebar, + frame.buffer, + &self.keymap, + chat, + ); + } frame .image_placements .retain(|image| !sidebar.intersects(image.area)); @@ -305,6 +353,9 @@ impl App { } None => None, }; + if let Some(strip) = strip { + crate::sidebar_chooser::tab_strip(strip, TabState::Absent, chat, frame.buffer); + } if let Some(x) = divider { side_pane::paint_divider(frame, x, self.divider_drag, highlight_divider); } @@ -331,6 +382,7 @@ impl App { ) -> PreparedConversation { self.conversation_frame.inset = conversation_inset; self.conversation_frame.sidebar = None; + self.conversation_frame.chat_strip = None; let area = self.conversation_area(viewport); if let Some((previous, reflowed)) = self.chat_widget.resize_owned_conversation(area.width) { self.transcript_view @@ -430,11 +482,19 @@ impl App { let max_composer_height = ((u32::from(area.height) * 2 / 3) as u16) .max(8) .min(area.height); + let unfocused = matches!( + self.conversation_frame.sidebar, + Some(sidebar::SidebarLayout::Chooser { + focus: PaneFocus::Sidebar, + .. + }) + ); let options = |interacting| ComposerRenderOptions { + unfocused, max_height: Some(max_composer_height), overlay_top: area.top(), floating_shortcuts: prepared.floating_shortcuts, - command_popup_placement: if interacting { + command_popup_placement: if interacting || unfocused { CommandPopupPlacement::Hidden } else { CommandPopupPlacement::Overlay diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 108ea97414..afe21bce74 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -747,6 +747,15 @@ impl App { }); } self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?; + if !is_replay_only + && let Some(message) = self + .side_threads + .get_mut(&thread_id) + .and_then(|side| side.opening_message.take()) + { + self.chat_widget + .submit_user_message_as_plain_user_turn(message); + } if self.side_retention_enabled(tui) { if self.side_threads.contains_key(&thread_id) { self.sidebar_mut().show( diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 960de36f11..7759e06000 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -215,6 +215,7 @@ 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, + pub(super) opening_message: Option, } impl SideThreadState { @@ -222,6 +223,7 @@ impl SideThreadState { Self { parent_thread_id, parent_status: None, + opening_message: None, } } } @@ -318,7 +320,12 @@ impl App { pub(super) fn conversation_focus_available(&self, tui: &tui::Tui) -> bool { self.side_retention_enabled(tui) && self.overlay.is_none() - && self.chat_widget.no_modal_or_popup_active() + && (self.chat_widget.no_modal_or_popup_active() + // Replaying the parent may reopen a completion for its parked draft. + // The focused chooser hides it and owns the keystrokes. + || (self.chooser_focus() == Some(sidebar::PaneFocus::Sidebar) + && self.sidebar_chooser_available() + && self.chat_widget.completion_can_yield_focus())) } pub(super) fn main_composer_shortcut_available(&self) -> bool { @@ -790,10 +797,17 @@ impl App { parent_thread_id: ThreadId, mut user_message: Option, ) -> Result { - // The fork and selection finish in this event. On success the next frame - // paints the chat at the chooser's width; on failure it slides closed. - if self.chooser_focus().is_some() { - self.sidebar_mut().hide(); + if let Some(pane) = self.sidebars.get_mut(&Some(parent_thread_id)) + && pane.creation.is_some() + { + // Retry and Back consume the saved attempt. A new /side must not overwrite its prompt. + pane.show( + sidebar::SidebarContent::Chooser, + sidebar::PaneFocus::Sidebar, + ); + tui.frame_requester().schedule_frame(); + self.restore_side_user_message(user_message); + return Ok(AppRunControl::Continue); } if let Some(message) = self.side_start_block_message() { self.restore_side_user_message(user_message.take()); @@ -829,29 +843,175 @@ impl App { /*inc*/ 1, &[("source", "slash_command")], ); + let workspace = self.side_retention_enabled(tui); + let request = uuid::Uuid::new_v4(); + if workspace { + let pane = self.sidebars.entry(Some(parent_thread_id)).or_default(); + pane.creation = Some(sidebar::SideCreation { + owner: app_server.backend_id(), + state: sidebar::CreationState::Waiting { + request, + started: Instant::now(), + }, + message: user_message.take(), + }); + pane.show( + sidebar::SidebarContent::Chooser, + sidebar::PaneFocus::Sidebar, + ); + let size = tui.prepare_draw_size()?; + self.render_chat_widget_frame(tui, size)?; + } self.refresh_in_memory_config_from_disk_best_effort("starting a side conversation") .await; - let fork_config = self.side_fork_config(); - let selected_profile = self.selected_server_profile(parent_thread_id); - match app_server - .fork_side_thread( - &self.local_settings, - fork_config, + let fork = app_server.sidebar_fork( + self.local_settings.clone(), + self.side_fork_config(), + parent_thread_id, + self.selected_server_profile(parent_thread_id), + Self::side_boundary_prompt_item(), + ); + let create = async move { + fork.await + .map(Box::new) + .map_err(|err| Self::side_start_error_message(&err)) + }; + if workspace { + let tx = self.app_event_tx.clone(); + let backend_id = app_server.backend_id(); + tokio::spawn(async move { + tx.send(AppEvent::SideCreated { + backend_id, + parent: parent_thread_id, + request, + result: create.await, + }); + }); + } else { + self.apply_side_creation( + tui, + app_server, parent_thread_id, - selected_profile.as_ref(), + user_message, + /*enter*/ true, + create.await, ) + .await; + } + Ok(AppRunControl::Continue) + } + + pub(super) async fn finish_side_creation( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + parent: ThreadId, + request: uuid::Uuid, + result: Result, String>, + ) { + let enter = !self.reconnect.offline + && self.current_displayed_thread_id() == Some(parent) + && self.chooser_focus() == Some(sidebar::PaneFocus::Sidebar) + && self.conversation_focus_available(tui); + let pending = self + .sidebars + .get_mut(&Some(parent)) + .and_then(|pane| pane.creation.as_mut()) + .filter(|creation| creation.request() == Some(request)); + let Some(pending) = pending else { + if let Ok(fork) = result + && let Err(error) = app_server + .thread_unsubscribe(fork.thread.session.thread_id) + .await + { + tracing::warn!(%error, "failed to release unclaimed side chat"); + } + return; + }; + tui.frame_requester().schedule_frame(); + if let Err(message) = result { + pending.state = sidebar::CreationState::Failed { + message, + cleanup: None, + }; + self.sync_side_thread_ui(); + return; + } + let message = pending.message.take(); + self.apply_side_creation(tui, app_server, parent, message, enter, result) .await - { - Ok(forked) => { + } + + async fn apply_side_creation( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + parent_thread_id: ThreadId, + mut user_message: Option, + enter: bool, + result: Result, String>, + ) { + match result { + Ok(fork) => { + app_server.observe_sidebar_fork(&fork); + let forked = fork.thread; let child_thread_id = forked.session.thread_id; + if let Err(err) = fork.boundary { + let message = + format!("Failed to prepare side conversation {child_thread_id}: {err}"); + let discarded = self.discard_side_thread(app_server, child_thread_id).await; + if let Some(pending) = self + .sidebars + .get_mut(&Some(parent_thread_id)) + .and_then(|pane| pane.creation.as_mut()) + { + pending.state = sidebar::CreationState::Failed { + message, + cleanup: (!discarded).then_some(child_thread_id), + }; + pending.message = user_message; + } else { + if !discarded { + self.side_threads + .insert(child_thread_id, SideThreadState::new(parent_thread_id)); + self.keep_side_thread_visible_after_cleanup_failure( + tui, + app_server, + child_thread_id, + ) + .await; + } + self.restore_side_user_message(user_message); + self.chat_widget.add_error_message(message); + } + return; + } + let already_selected = self.active_thread_id == Some(child_thread_id); + let enter = enter || already_selected; + if fork.inherited_profile + && let Some(profile) = &forked.session.active_permission_profile + { + self.agents_overview + .selected_permission_profiles + .insert(child_thread_id, profile.id.clone()); + } + if !enter { + self.side_companions + .entry(child_thread_id) + .or_insert_with(|| SideCompanion::empty(forked.session.cwd.clone())); + } let channel = self.ensure_thread_channel(child_thread_id); + channel.backend_id = Some(app_server.backend_id()); { let mut store = channel.store.lock().await; Self::install_side_thread_snapshot(&mut store, forked.session, forked.turns); } - self.side_threads - .insert(child_thread_id, SideThreadState::new(parent_thread_id)); + let mut child = SideThreadState::new(parent_thread_id); + if !enter { + child.opening_message = user_message.take(); + } + self.side_threads.insert(child_thread_id, child); // `thread/started` is delivered after the fork response; seed navigation before // the first selection without blocking on another app-server read. self.upsert_agent_picker_thread( @@ -861,17 +1021,18 @@ impl App { /*is_closed*/ false, ); self.sync_agent_picker_thread(child_thread_id); - if let Err(err) = app_server - .thread_inject_items(child_thread_id, vec![Self::side_boundary_prompt_item()]) - .await - { - self.discard_side_thread_or_keep_visible(tui, app_server, child_thread_id) - .await; - self.restore_side_user_message(user_message.take()); - self.chat_widget.add_error_message(format!( - "Failed to prepare side conversation {child_thread_id}: {err}" - )); - return Ok(AppRunControl::Continue); + if let Some(pane) = self.sidebars.get_mut(&Some(parent_thread_id)) { + pane.chat_ready(child_thread_id); + } + if !enter { + self.sync_side_thread_ui(); + return; + } + if already_selected { + // Selection normally skips the live thread. Replay the initialized snapshot + // even if the picker exposed this child before the boundary RPC completed. + self.store_active_thread_receiver().await; + self.active_thread_id = None; } if let Err(err) = self .select_agent_thread(tui, app_server, child_thread_id) @@ -894,12 +1055,9 @@ impl App { self.chat_widget.add_error_message(format!( "Failed to switch into side conversation {child_thread_id}: {err}" )); - return Ok(AppRunControl::Continue); + return; } if self.active_thread_id == Some(child_thread_id) { - if selected_profile.is_some() { - self.adopt_inherited_server_selection(); - } if let Some(user_message) = user_message.take() { let _ = self .chat_widget @@ -918,11 +1076,8 @@ impl App { self.restore_side_user_message(user_message.take()); self.chat_widget .set_side_conversation_context_label(/*label*/ None); - self.chat_widget - .add_error_message(Self::side_start_error_message(&err)); + self.chat_widget.add_error_message(err); } } - - Ok(AppRunControl::Continue) } } diff --git a/codex-rs/tui/src/app/side_companion.rs b/codex-rs/tui/src/app/side_companion.rs index d8e492d4d4..a1b739cc1a 100644 --- a/codex-rs/tui/src/app/side_companion.rs +++ b/codex-rs/tui/src/app/side_companion.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::TurnStatus; use codex_protocol::items::AsyncUserInputQuestion; +use codex_utils_absolute_path::AbsolutePathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -49,6 +50,26 @@ impl SideAnswer { } impl SideCompanion { + pub(super) fn empty(cwd: AbsolutePathBuf) -> Self { + Self { + display: CapturedSideDisplay { + rendered_completion_turn_ids: Default::default(), + active: None, + auxiliary: Vec::new(), + draft: Box::default(), + status: None, + work: None, + last_completed_agent_message: None, + cwd, + visualizations: None, + }, + cells: Vec::new(), + view: TranscriptView::default(), + answer: None, + parked_tail: 0, + } + } + pub(super) fn apply_notification( &mut self, notification: &ServerNotification, diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs index b5bfa7020e..9de6920b24 100644 --- a/codex-rs/tui/src/app/side_focus.rs +++ b/codex-rs/tui/src/app/side_focus.rs @@ -15,15 +15,61 @@ impl App { && self.chat_widget.sidebar_chooser_available() } - pub(super) fn handle_chooser_input(&mut self, tui: &tui::Tui, event: &TuiEvent) -> bool { - let focus = self.chooser_focus(); - if focus.is_none() - || !self.conversation_focus_available(tui) - || !self.sidebar_chooser_available() - { + async fn open_sidebar_tabs( + &mut self, + tui: &mut tui::Tui, + server: &mut AppServerSession, + ) -> Result<()> { + if let Some(parent) = self.active_side_parent_thread_id() { + self.select_agent_thread(tui, server, parent).await?; + if self.current_displayed_thread_id() != Some(parent) { + return Ok(()); + } + self.focus_returned_parent_composer(parent).await?; + } + self.zoomed_thread = None; + self.sidebar_mut() + .show(SidebarContent::Chooser, PaneFocus::Sidebar); + tui.frame_requester().schedule_frame(); + Ok(()) + } + + pub(super) async fn handle_sidebar_input( + &mut self, + tui: &mut tui::Tui, + server: &mut AppServerSession, + event: &TuiEvent, + ) -> bool { + if !self.conversation_focus_available(tui) || !self.sidebar_chooser_available() { return false; } - let open = match event { + if let TuiEvent::Mouse(mouse) = event + && mouse.kind == MouseEventKind::Down(MouseButton::Left) + && let Some(strip) = self.conversation_frame.chat_strip + && strip.contains((mouse.column, mouse.row).into()) + { + let point = (mouse.column, mouse.row).into(); + let (tab, add) = crate::sidebar_chooser::strip_targets(strip); + let result = if add.contains(point) { + self.open_sidebar_tabs(tui, server).await + } else if tab.contains(point) + && let Some(id) = self.switchable_side_child() + && self.current_displayed_thread_id() != Some(id) + { + self.select_agent_thread(tui, server, id).await + } else { + return true; + }; + if let Err(err) = result { + self.chat_widget.add_error_message(err.to_string()); + } + return true; + } + if self.chooser_focus().is_none() { + return false; + } + let child = self.switchable_side_child(); + let (open, activation) = match event { TuiEvent::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { tui.frame_requester().schedule_frame(); let Some(area) = self @@ -35,44 +81,91 @@ impl App { .show(SidebarContent::Chooser, PaneFocus::Main); return false; }; - self.sidebar_mut() - .show(SidebarContent::Chooser, PaneFocus::Sidebar); - if crate::sidebar_chooser::choice_contains(area, mouse.row) { + let pane = self.sidebar_mut(); + pane.show(SidebarContent::Chooser, PaneFocus::Sidebar); + if pane.creation.is_some() { + return true; + } + let open = if crate::sidebar_chooser::choice_contains(area, mouse.row) + || (child.is_some() + && crate::sidebar_chooser::strip_targets(area) + .0 + .contains((mouse.column, mouse.row).into())) + { true } else if mouse.row == area.bottom() - 1 && mouse.column > area.x { let column = mouse.column - area.x - 1; let hints = crate::sidebar_chooser::footer(&self.keymap, area); match hints.items.iter().position(|range| range.contains(&column)) { - Some(0) => true, // Open. - Some(1 | 3) => false, // Back or hide. + Some(0) => true, // Open. + Some(1) => child.is_some(), // Back. + Some(3) => false, // Hide. _ => return true, } } else { return true; - } + }; + (open, None) } - _ if focus != Some(PaneFocus::Sidebar) => return false, - TuiEvent::Key(key) - if key.kind == KeyEventKind::Press - && key.modifiers.is_empty() - && matches!(key.code, KeyCode::Esc | KeyCode::Enter) => - { - self.chat_widget.suppress_current_view_key_until_release( - KeyBinding::from_event(*key), - Instant::now(), - ActivationKeySuppression::ViewTransition, - ); + _ if self.chooser_focus() != Some(PaneFocus::Sidebar) => return false, + TuiEvent::Key(key) if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => { + let open = match key.code { + KeyCode::Enter => true, + KeyCode::Esc => child.is_some(), + KeyCode::Left | KeyCode::Right if child.is_some() => true, + _ => return true, + }; tui.frame_requester().schedule_frame(); - key.code == KeyCode::Enter + (open, Some(KeyBinding::from_event(*key))) } _ => return matches!(event, TuiEvent::Key(_) | TuiEvent::Paste(_)), }; - if open { - self.chat_widget - .dispatch_command(crate::slash_command::SlashCommand::Side); + let pane = self.sidebar_mut(); + if let Some(creation) = &pane.creation { + match (&creation.state, open) { + (sidebar::CreationState::Waiting { .. }, true) => {} + (sidebar::CreationState::Waiting { .. }, false) => pane.hide(), + (sidebar::CreationState::Failed { cleanup, .. }, _) => { + if let Some(id) = *cleanup + && !self.discard_side_thread(server, id).await + { + return true; + } + let message = self + .sidebar_mut() + .creation + .take() + .and_then(|creation| creation.message); + if open && let Some(parent_thread_id) = self.current_displayed_thread_id() { + self.app_event_tx.send(AppEvent::StartSide { + parent_thread_id, + user_message: message, + }); + } else { + self.restore_side_user_message(message); + } + } + } + } else { + if open && let Some(id) = child { + if let Err(err) = self.select_agent_thread(tui, server, id).await { + self.chat_widget.add_error_message(err.to_string()); + } + } else if open { + self.chat_widget + .dispatch_command(crate::slash_command::SlashCommand::Side); + } + if !open || self.chat_widget.thread_id().is_none() { + self.sidebar_mut().hide(); + } } - if !open || self.chat_widget.thread_id().is_none() { - self.sidebar_mut().hide(); + // Selecting a chat replaces its widget. Guard the composer that will receive repeats. + if let Some(activation) = activation { + self.chat_widget.suppress_current_view_key_until_release( + activation, + Instant::now(), + ActivationKeySuppression::ViewTransition, + ); } true } @@ -89,6 +182,7 @@ impl App { let main = bindings.focus_main_composer.is_pressed(key) && self.main_composer_shortcut_available(); let sidebar = bindings.focus_sidebar_content.is_pressed(key); + let tabs = bindings.focus_sidebar_tabs.is_pressed(key) && self.sidebar_chooser_available(); let hide = bindings.hide_pane.is_pressed(key); let width_change = if bindings.move_divider_left.is_pressed(key) { 2 @@ -115,7 +209,7 @@ impl App { } let resize = bindings.resize_panes.is_pressed(key) && self.open_pane_focus().is_some(); let resizing = resize || width_change != 0 || focused_percent.is_some(); - if !(left || right || main || sidebar || hide || zoom_target.is_some() || resizing) + if !(tabs || left || right || main || sidebar || hide || zoom_target.is_some() || resizing) || !self.conversation_focus_available(tui) { return false; @@ -142,7 +236,9 @@ impl App { && !on_child && (self.transcript_view.has_active_interaction() || self.backtrack.overlay_preview_active); - let result = if resize { + let result = if tabs { + self.open_sidebar_tabs(tui, app_server).await + } else if resize { self.begin_pane_resize(tui) } else if let Some(percent) = focused_percent { self.set_focused_pane_width(tui, percent).await @@ -152,7 +248,7 @@ impl App { self.zoomed_thread = (self.zoomed_thread != Some(target)).then_some(target); tui.frame_requester().schedule_frame(); Ok(()) - } else if child.is_none() && chooser.is_some() { + } else if chooser.is_some() { if hide || (sidebar && chooser == Some(PaneFocus::Sidebar)) { self.sidebar_mut().hide(); } else { @@ -200,8 +296,9 @@ impl App { if let Err(err) = result { self.chat_widget.add_error_message(err.to_string()); } - let view_changed = - leaves_transcript || self.current_displayed_thread_id() != previous_thread; + let view_changed = leaves_transcript + || self.current_displayed_thread_id() != previous_thread + || (main && chooser == Some(PaneFocus::Sidebar)); let suppression = if view_changed { ActivationKeySuppression::ViewTransition } else { diff --git a/codex-rs/tui/src/app/side_pane.rs b/codex-rs/tui/src/app/side_pane.rs index aea4ba9cb8..ab5fa5246b 100644 --- a/codex-rs/tui/src/app/side_pane.rs +++ b/codex-rs/tui/src/app/side_pane.rs @@ -4,11 +4,10 @@ use super::App; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use super::side_companion::SideCompanion; use super::sidebar::SidebarContent; -use crate::bottom_pane::inset_footer_hint_area; +use crate::bottom_pane::render_unfocused_status; use crate::custom_terminal::Frame; use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; use crate::tui::Tui; @@ -25,9 +24,7 @@ use crossterm::event::MouseEventKind::Up; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; -use ratatui::style::Stylize; use ratatui::widgets::StatefulWidgetRef; -use ratatui::widgets::Widget; use std::time::Duration; const GAP: u16 = 1; @@ -81,6 +78,7 @@ pub(super) struct SidePaneLayout { pub(super) passive_thread: ThreadId, pub(super) passive: Rect, pub(super) divider: u16, + pub(super) tab_strip: Rect, } /// A wide terminal can show a pair once the outgoing conversation has been captured. @@ -119,6 +117,7 @@ pub(super) fn pair_layout( } let active = app.current_displayed_thread_id()?; let side = app.side_threads.get(&child)?; + let [tab_strip, sidebar] = tab_and_body(sidebar); let (focused, passive, passive_thread) = if active == child { (sidebar, parent, side.parent_thread_id) } else { @@ -131,10 +130,15 @@ pub(super) fn pair_layout( focused, passive, passive_thread, + tab_strip, divider: parent.right(), }) } +pub(super) fn tab_and_body(area: Rect) -> [Rect; 2] { + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(area) +} + pub(super) fn sidebar_areas(app: &App, tui: &Tui, area: Rect, fraction: f64) -> Option<[Rect; 2]> { let active = app.current_displayed_thread_id(); let passive = app @@ -462,16 +466,15 @@ impl SidePaneLayout { ); } if let Some(status) = &companion.display.status { - let footer = inset_footer_hint_area(Rect { - y: composer.bottom(), - height: status_height, - ..area - }); - truncate_line_with_ellipsis_if_overflow( - status.clone().dim().into(), - usize::from(footer.width), - ) - .render(footer, frame.buffer); + render_unfocused_status( + status.clone(), + Rect { + y: composer.bottom(), + height: status_height, + ..area + }, + frame.buffer, + ); } } } diff --git a/codex-rs/tui/src/app/sidebar.rs b/codex-rs/tui/src/app/sidebar.rs index 7f26feabbe..220417b7d0 100644 --- a/codex-rs/tui/src/app/sidebar.rs +++ b/codex-rs/tui/src/app/sidebar.rs @@ -22,8 +22,35 @@ pub(super) enum PaneFocus { Sidebar, } +pub(super) enum CreationState { + Waiting { + request: uuid::Uuid, + started: std::time::Instant, + }, + Failed { + message: String, + cleanup: Option, + }, +} + +pub(super) struct SideCreation { + pub(super) owner: crate::backend_id::BackendId, + pub(super) state: CreationState, + pub(super) message: Option, +} + +impl SideCreation { + pub(super) fn request(&self) -> Option { + match self.state { + CreationState::Waiting { request, .. } => Some(request), + CreationState::Failed { .. } => None, + } + } +} + #[derive(Default)] pub(super) struct SidebarPane { + pub(super) creation: Option, content: SidebarContent, focus: Option, motion: PaneMotion, @@ -35,6 +62,11 @@ impl SidebarPane { self.focus = Some(focus); } + pub(super) fn chat_ready(&mut self, child: ThreadId) { + self.creation = None; + self.content = SidebarContent::Chat(child); + } + pub(super) fn hide(&mut self) { self.focus = None; } @@ -49,6 +81,40 @@ impl SidebarPane { } impl App { + fn side_creation_pending(&self, owner: crate::backend_id::BackendId) -> bool { + self.sidebars.values().any(|pane| { + pane.creation + .as_ref() + .is_some_and(|creation| creation.owner == owner && creation.request().is_some()) + }) + } + + /// Before the fork returns its ID, ownership routing would drop the child's MCP statuses. + /// Keep these events with their backend, then route them normally after registration. + pub(super) fn defer_side_startup_notification( + &self, + event: &crate::app_server_session::AppServerSessionEvent, + displayed: crate::backend_id::BackendId, + ) -> bool { + use codex_app_server_client::AppServerEvent; + use codex_app_server_protocol::ServerNotification; + let AppServerEvent::ServerNotification(notification) = &event.event else { + return false; + }; + let id = match notification.as_ref() { + ServerNotification::ThreadStarted(started) if started.thread.ephemeral => { + Some(started.thread.id.as_str()) + } + ServerNotification::McpServerStatusUpdated(status) => status.thread_id.as_deref(), + _ => None, + }; + id.and_then(|id| ThreadId::from_string(id).ok()) + .is_some_and(|id| { + (event.backend_id != displayed || !self.owns_thread_for_routing(id)) + && self.side_creation_pending(event.backend_id) + }) + } + fn sidebar_parent(&self) -> Option { self.active_side_parent_thread_id() .or_else(|| self.current_displayed_thread_id()) @@ -109,14 +175,13 @@ pub(super) fn paint_layout(app: &mut App, tui: &Tui, area: Rect) -> Option { - Some(SidebarLayout::Chooser { - main, - sidebar, - focus, - }) - } - SidebarContent::Chooser => None, + SidebarContent::Chooser => (app.sidebar_chooser_available() + && (focus == PaneFocus::Main || app.chat_widget.completion_can_yield_focus())) + .then_some(SidebarLayout::Chooser { + main, + sidebar, + focus, + }), SidebarContent::Chat(child) => { side_pane::pair_layout(app, child, [main, sidebar]).map(SidebarLayout::Chat) } diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_leader_hints.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_leader_hints.snap index 8f0585d5af..8b2e724d1c 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_leader_hints.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_leader_hints.snap @@ -7,10 +7,10 @@ expression: "panel_text(&app, &tui)" Panes This pane c Main composer [ Select transcript s Sidebar content p Presets › - z Zoom pane - Move = Equal widths - → / l Pane right r Resize panes - , Minimum width + t Open in sidebar z Zoom pane + = Equal widths + Move r Resize panes + → / l Pane right , Minimum width . Maximum width Esc cancel diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 084508de52..37ee228f9f 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -1311,12 +1311,19 @@ See the Codex keymap documentation for supported actions and examples." backends.set_preference_key(backends.displayed_owner(), "local"); } + let mut parked_side_completions = HashMap::<_, Vec>::new(); let exit_reason_result = if let Some(exit_reason) = pre_loop_exit_reason { Ok(exit_reason) } else { loop { let displayed_session = backends.displayed; let displayed_owner = backends.displayed_owner(); + if let Some(events) = parked_side_completions.remove(&displayed_owner) { + for event in events { + app.app_event_tx.send(event); + } + } + backends.replay_ready_side_startup(&app); let multi_backend = app.config.features.enabled(Feature::TuiMultiBackend); let next_backend = if multi_backend && app.can_cycle_conversation_backend() { backends.next_listening_backend(displayed_owner) @@ -1507,6 +1514,10 @@ See the Codex keymap documentation for supported actions and examples." if cell.as_any().is::() ); let event_session = match &event { + AppEvent::SideCreated { backend_id, .. } if *backend_id != displayed_owner => { + parked_side_completions.entry(*backend_id).or_default().push(event); + continue; + } AppEvent::StartupThreadStarted { backend_id, .. } => { backends.slot(*backend_id).unwrap_or(displayed_session) } @@ -1588,6 +1599,9 @@ See the Codex keymap documentation for supported actions and examples." } (owner, app_server_event) = backends.next_event(paused_backend) => { match app_server_event { + Some(event) if app.defer_side_startup_notification(&event, displayed_owner) => { + backends.defer_side_startup(event); + } Some(event) if owner != displayed_owner || !app.reconnect.offline => { if let Some(session) = backends.session(owner) { app.handle_polled_backend_event(session, displayed_owner, event).await; diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2a9857e198..c8432c45ea 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3336,7 +3336,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() config.codex_home.join("config.toml"), // Each thread reloads this footer. Omit the platform-specific working directory. format!( - "openai_base_url = '{}/v1'\n[tui]\nstatus_line = ['model-with-reasoning']", + "openai_base_url = '{}/v1'\ndefault_permissions = ':workspace'\n[permissions.side-reader]\nextends = ':read-only'\n[tui]\nstatus_line = ['model-with-reasoning']", model.uri() ), )?; @@ -3380,7 +3380,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() app.chat_widget.config_ref(), )); let read_only = PermissionProfileSelection { - profile_id: ":read-only".into(), + profile_id: "side-reader".into(), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::User), display_label: "Read Only".into(), @@ -3388,6 +3388,9 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() app.select_permission_profile(&mut app_server, read_only.clone()) .await; + enable_side_retention(&mut app)?; + app.local_settings.tui.animations = false; + tui.set_owned_screen(/*owned*/ true)?; let control = Box::pin(app.handle_start_side( &mut tui, &mut app_server, @@ -3396,9 +3399,63 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() )) .await?; - let side_thread_id = app - .active_thread_id - .expect("side conversation should become active"); + // The config refresh reloads workspace preferences from the fixture home. + enable_side_retention(&mut app)?; + app.local_settings.tui.animations = false; + // Hold the completion in the event queue while painting the pending sidebar. + let pending = side_frame(&mut app, &mut tui, Size::new(120, 12))?; + let loading = pending + .lines() + .find(|row| row.contains("Starting side chat")) + .unwrap(); + insta::assert_snapshot!(loading.split_once('│').unwrap().1.trim(), @"◌ Starting side chat…"); + // Finish in the background, then change the parent's permissions before the first child turn. + app.sidebar_mut() + .show(sidebar::SidebarContent::Chooser, sidebar::PaneFocus::Main); + while app.side_thread_for_parent(parent_thread_id).is_none() { + let event = time::timeout(Duration::from_secs(5), app_event_rx.recv()) + .await? + .expect("event stream"); + boxed_future!(app.handle_event(&mut tui, &mut app_server, event)).await?; + } + let side_thread_id = app.side_thread_for_parent(parent_thread_id).unwrap(); + assert_eq!(app.active_thread_id, Some(parent_thread_id)); + assert_eq!( + app.agents_overview + .selected_permission_profiles + .get(&side_thread_id), + Some(&read_only.profile_id) + ); + app.select_permission_profile( + &mut app_server, + PermissionProfileSelection { + profile_id: ":danger-full-access".into(), + display_label: "Full access".into(), + ..read_only.clone() + }, + ) + .await; + let mut saw_thread_started = false; + loop { + let event = app_server.next_event().await.expect("app-server event"); + let mut parent_changed = false; + if let codex_app_server_client::AppServerEvent::ServerNotification(n) = &event.event { + saw_thread_started |= matches!(n.as_ref(), ServerNotification::ThreadStarted(started) if started.thread.id == side_thread_id.to_string()); + parent_changed = matches!(n.as_ref(), ServerNotification::ThreadSettingsUpdated(settings) + if settings.thread_id == parent_thread_id.to_string() + && settings.thread_settings.active_permission_profile.as_ref().is_some_and(|p| p.id == ":danger-full-access")); + } + app.handle_backend_event(&app_server, event).await; + if parent_changed { + break; + } + } + app.select_agent_thread(&mut tui, &mut app_server, side_thread_id) + .await?; + assert_eq!( + app.config.permissions.permission_profile(), + &PermissionProfile::read_only() + ); assert!(matches!(control, AppRunControl::Continue)); assert_ne!(side_thread_id, parent_thread_id); assert!(app.side_threads.contains_key(&side_thread_id)); @@ -3429,7 +3486,6 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert_eq!(app.active_thread_id, Some(expected)); } - let mut saw_thread_started = false; loop { let event = time::timeout( std::time::Duration::from_secs(/*secs*/ 2), @@ -3649,6 +3705,28 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() format!("Inactive\n{inactive}\n\nFocused\n{focused}") ); + keys!(leader, KeyCode::Char('t').into()); + assert!(!tui.has_deferred_thread_switch_clear()); + let chooser = side_frame(&mut app, &mut tui, size)?; + assert_app_snapshot!( + "sidebar_existing_chat", + chooser + .lines() + .filter_map(|line| line.split_once('│').map(|(_, sidebar)| sidebar.trim_end())) + .collect::>() + .join("\n") + ); + let sidebar_x = app.conversation_frame.chooser_area().unwrap().x; + app.local_settings.tui.animations = true; + keys!(KeyCode::Enter.into()); + assert!(!tui.has_deferred_thread_switch_clear()); + assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); + // A press-only terminal reports held Enter as another press after the replay. + keys!(KeyCode::Enter.into()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "child draft!"); + assert!(side_frame(&mut app, &mut tui, size)?.contains(child_row)); + assert_eq!(app.conversation_frame.chat_strip.unwrap().x, sidebar_x); + app.local_settings.tui.animations = false; // The pending permission change refuses a parent switch even though selection returns Ok. app.pending_server_profiles .insert(side_thread_id, read_only.clone()); @@ -4341,8 +4419,30 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() .contains("Feedback recorded (no logs).") })); + // A completion restored with the parent draft must not receive the chooser's Enter. + let child_draft = app.chat_widget.composer_text_with_pending(); + keys!(prefix, main); + app.chat_widget.set_composer_cursor(/*cursor*/ 0); + let kill_line = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL); + for _ in 0..app.chat_widget.composer_text_with_pending().lines().count() { + keys!(kill_line, kill_line); + } + app.chat_widget.insert_str("/qui"); + keys!( + KeyCode::Esc.into(), + prefix, + side, + prefix, + KeyCode::Char('t').into() + ); + assert!(!app.chat_widget.no_modal_or_popup_active()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "/qui"); + keys!(KeyCode::Enter.into()); + assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); + assert_eq!(app.chat_widget.composer_text_with_pending(), child_draft); + // Copy a retained range with the child composer still focused. - app.transcript_view.jump_to_latest(); + side_frame(&mut app, &mut tui, even)?; let parent = app.side_companions.get_mut(&parent_thread_id).unwrap(); parent.view.jump_to_latest(); assert!(parent.view.begin_select_mode(&parent.cells)); @@ -5332,6 +5432,19 @@ async fn side_defers_parent_approval_overlay_until_parent_replay() -> Result<()> assert_eq!(app.chat_widget.has_active_view(), true); + enable_side_retention(&mut app)?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + app.sidebar_mut().show( + super::sidebar::SidebarContent::Chooser, + super::sidebar::PaneFocus::Sidebar, + ); + let frame = side_frame(&mut app, &mut tui, Size::new(/*width*/ 80, /*height*/ 18))?; + assert!( + frame.contains("Yes, proceed") && !frame.contains("Open in sidebar"), + "{frame}" + ); + Ok(()) } diff --git a/codex-rs/tui/src/app/tests/backend_sessions_tests.rs b/codex-rs/tui/src/app/tests/backend_sessions_tests.rs index 0529552e5e..a6f3162ded 100644 --- a/codex-rs/tui/src/app/tests/backend_sessions_tests.rs +++ b/codex-rs/tui/src/app/tests/backend_sessions_tests.rs @@ -51,6 +51,59 @@ async fn backend_sessions_parked_request_does_not_hide_disconnect() -> Result<() let initial = backends.displayed; let thread_id = ThreadId::new(); + // A startup failure that arrives before a fork is registered stays on its owning backend. + let mut app = make_test_app().await; + app.sidebar_mut().creation = Some(sidebar::SideCreation { + owner: background_id, + state: sidebar::CreationState::Waiting { + request: uuid::Uuid::new_v4(), + started: Instant::now(), + }, + message: None, + }); + let failure = ServerNotification::McpServerStatusUpdated( + codex_app_server_protocol::McpServerStatusUpdatedNotification { + thread_id: Some(thread_id.to_string()), + name: "tools".into(), + status: McpServerStartupState::Failed, + error: Some("startup failed".into()), + failure_reason: None, + }, + ); + let event = crate::app_server_session::AppServerSessionEvent { + backend_id: background_id, + event: AppServerEvent::ServerNotification(Box::new(failure.clone())), + }; + assert!(app.defer_side_startup_notification(&event, displayed_id)); + backends.defer_side_startup(event); + backends.replay_ready_side_startup(&app); + assert!( + backends + .next_event(/*paused_backend*/ None) + .now_or_never() + .is_none() + ); + backends.displayed = backends.slot(background_id).unwrap(); + backends.replay_ready_side_startup(&app); + assert!( + backends + .next_event(/*paused_backend*/ None) + .now_or_never() + .is_none() + ); + app.ensure_thread_channel(thread_id); + backends.replay_ready_side_startup(&app); + let (owner, event) = backends.next_event(/*paused_backend*/ None).await; + assert_eq!(owner, background_id); + let AppServerEvent::ServerNotification(notification) = event.unwrap().event else { + panic!("expected the parked MCP startup failure"); + }; + assert_eq!( + serde_json::to_value(notification)?, + serde_json::to_value(failure)? + ); + backends.displayed = initial; + background_events .send(AppServerEvent::ServerRequest(Box::new(approval_request( thread_id, /*request_id*/ 1, diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index a5e27ece45..9ae8ffd9c2 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -1205,6 +1205,7 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> let parent = ThreadId::new(); app.chat_widget .handle_thread_session(test_thread_session(parent, app.config.cwd.to_path_buf())); + app.chat_widget.local_settings.tui.status_line = Some(vec!["model-with-reasoning".into()]); app.chat_widget.insert_str("parent draft"); let size = ratatui::layout::Size::new(120, 12); @@ -1223,15 +1224,16 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> } if key == KeyCode::Esc { let frame = super::side_frame(&mut app, &mut tui, size)?; - let panel: Vec<_> = frame - .lines() - .map(|line| line.chars().skip(80).collect::()) - .filter(|line| !line.trim().is_empty()) - .collect(); - insta::assert_snapshot!("sidebar_new_chat", panel.join("\n")); + insta::assert_snapshot!("sidebar_new_chat", frame); for stroke in [ ctrl('x'), KeyCode::Char('c').into(), + KeyEvent::new_with_kind( + KeyCode::Char('c'), + KeyModifiers::NONE, + KeyEventKind::Repeat, + ), + KeyCode::Char('c').into(), KeyCode::Char('!').into(), KeyCode::Left.into(), // Flush buffered typing before the focus change. ] { @@ -1257,6 +1259,37 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> assert_eq!(app.conversation_frame.chooser_area().unwrap().width, 20); } } + let request = uuid::Uuid::new_v4(); + app.sidebar_mut().creation = Some(crate::app::sidebar::SideCreation { + owner: server.backend_id(), + state: crate::app::sidebar::CreationState::Waiting { + request, + started: std::time::Instant::now(), + }, + message: Some("first prompt".into()), + }); + app.finish_side_creation( + &mut tui, + &mut server, + parent, + request, + Err("The side conversation could not be prepared.".into()), + ) + .await; + app.handle_start_side(&mut tui, &mut server, parent, Some("second prompt".into())) + .await?; + let narrow = super::side_frame(&mut app, &mut tui, ratatui::layout::Size::new(30, 8))?; + insta::assert_snapshot!("sidebar_failed_creation", narrow); + Box::pin(press(&mut app, &mut tui, &mut server, KeyCode::Esc.into())).await?; + assert!( + super::side_frame(&mut app, &mut tui, ratatui::layout::Size::new(30, 8))? + .contains("Open in sidebar") + ); + let draft = app.chat_widget.composer_text_with_pending(); + assert!( + draft.contains("first prompt") && draft.contains("second prompt"), + "{draft}" + ); tokio::time::resume(); server.shutdown().await?; Ok(()) diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_failed_creation.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_failed_creation.snap new file mode 100644 index 0000000000..973aa4f3e8 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_failed_creation.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/tests/key_chords.rs +expression: narrow +--- + + The side conversation could + not be prepared. + Enter retry · Esc back diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap index 100c1c7b47..24e01b5f15 100644 --- a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap @@ -1,10 +1,16 @@ --- source: tui/src/app/tests/key_chords.rs -assertion_line: 1131 -expression: "panel.join(\"\\n\")" +expression: frame --- - + - Open in sidebar - › + New side chat - Start from this conversation - Enter open · Esc back · ↑/↓ move … + │ + + │ + │ Open in sidebar + │ + │ › + New side chat + │ Start from this conversation + │ + │ +› parent draft │ + │ + gpt-test default │ + │ Enter open · Esc back · ↑/↓ move … diff --git a/codex-rs/tui/src/app/workspace_hud.rs b/codex-rs/tui/src/app/workspace_hud.rs index dc7fb0cc77..56da8b6684 100644 --- a/codex-rs/tui/src/app/workspace_hud.rs +++ b/codex-rs/tui/src/app/workspace_hud.rs @@ -48,6 +48,7 @@ impl App { let (group, label) = match action { "focus_command_center" => (0, "Command Center"), "focus_main_composer" => (0, "Main composer"), + "focus_sidebar_tabs" => (0, "Open in sidebar"), "focus_sidebar_content" if side => (0, "Hide sidebar"), "focus_sidebar_content" => (0, "Sidebar content"), "resize_panes" if can_size => (2, "Resize panes"), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 885ff973e8..3b96f223c3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -472,6 +472,13 @@ pub(crate) enum AppEvent { user_message: Option, }, + SideCreated { + backend_id: crate::backend_id::BackendId, + parent: ThreadId, + request: uuid::Uuid, + result: Result, String>, + }, + /// Submit an op to the specified thread, regardless of current focus. SubmitThreadOp { thread_id: ThreadId, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index f6b219055b..21f5e3f5a6 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -446,6 +446,14 @@ impl ThreadParamsMode { } } +#[derive(Debug)] +pub(crate) struct SidebarFork { + pub(crate) thread: AppServerStartedThread, + pub(crate) boundary: Result<(), String>, + pub(crate) inherited_profile: bool, + legacy: bool, +} + #[derive(Debug, Clone)] pub(crate) struct AppServerStartedThread { pub(crate) session: ThreadSessionState, @@ -1192,6 +1200,49 @@ impl AppServerSession { .await } + /// Fork and inject the side boundary on a request-only view. The live session + /// keeps receiving notifications and drawing frames while these RPCs run. + pub(crate) fn sidebar_fork( + &self, + settings: LocalSettings, + config: Config, + parent: ThreadId, + profile: Option, + boundary: ResponseItem, + ) -> impl Future> + Send + use<> { + let mut view = self + .request_view() + .with_thread_tool_transport(self.thread_tool_transport()); + if self.task_tools_available(parent) { + view.remember_task_tool_thread(parent); + } + async move { + let thread = view + .fork_side_thread(&settings, config, parent, profile.as_ref()) + .await?; + let boundary = view + .thread_inject_items(thread.session.thread_id, vec![boundary]) + .await + .map(|_| ()) + .map_err(|err| err.to_string()); + Ok(SidebarFork { + thread, + boundary, + inherited_profile: profile.is_some(), + legacy: view.history_support == ThreadHistorySupport::LegacyOnly, + }) + } + } + + pub(crate) fn observe_sidebar_fork(&mut self, fork: &SidebarFork) { + if fork.legacy { + self.history_support = ThreadHistorySupport::LegacyOnly; + } + if fork.thread.task_tools_available { + self.remember_task_tool_thread(fork.thread.session.thread_id); + } + } + pub(crate) async fn fork_side_thread( &mut self, local_settings: &LocalSettings, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 681dcef543..47476b29e9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2080,9 +2080,11 @@ impl ChatComposer { /// Whether a popup or query owns input. pub(crate) fn popup_active(&self) -> bool { - self.history_search.is_some() - || self.draft.textarea.vim_query().is_some() - || self.popups.active() + self.query_active() || self.popups.active() + } + + pub(crate) fn query_active(&self) -> bool { + self.history_search.is_some() || self.draft.textarea.vim_query().is_some() } #[inline] @@ -4433,30 +4435,6 @@ impl ChatComposer { } } - pub(crate) fn set_status_line(&mut self, status_line: Option) -> bool { - if self.footer.status_line_value == status_line { - return false; - } - self.footer.status_line_value = status_line; - true - } - - pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { - if self.footer.status_line_enabled == enabled { - return false; - } - self.footer.status_line_enabled = enabled; - true - } - - pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) -> bool { - if self.footer.side_conversation_context_label == label { - return false; - } - self.footer.side_conversation_context_label = label; - true - } - /// Replaces the contextual footer label for the currently viewed agent. /// /// Returning `false` means the value was unchanged, so callers can skip redraw work. This @@ -4686,9 +4664,12 @@ impl ChatComposer { } } let transcript_hint_area = inset_footer_hint_area(footer_rect); - let warning_notice = self.warning_notice_layout(transcript_hint_area, options); + let warning_notice = self + .warning_notice_layout(transcript_hint_area, options) + .filter(|_| !options.unfocused); let warning_area = warning_notice.as_ref().map(|(area, _)| *area); match options.footer { + _ if options.unfocused => {} Some(footer) => { self.render_transcript_footer(transcript_hint_area, buf, footer, warning_area); } @@ -5026,7 +5007,7 @@ impl ChatComposer { } } } - if !self.draft.input_enabled || textarea_is_empty { + if !options.unfocused && (!self.draft.input_enabled || textarea_is_empty) { let text = if self.draft.input_enabled { self.placeholder_text.as_str().to_string() } else { @@ -5090,7 +5071,9 @@ impl ChatComposer { self.cursor_pos_with_options(area, options), buf, ); - if options.footer.is_some_and(|footer| footer.is_interactive) { + if options.unfocused { + buf.set_style(composer_rect, crate::style::quiet_workspace_style()); + } else if options.footer.is_some_and(|footer| footer.is_interactive) { buf.set_style(composer_rect, Style::default().dim()); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index ec84b6def4..432926f882 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -86,6 +86,7 @@ pub(crate) struct ComposerRenderOptions<'a> { pub(crate) overlay_top: u16, /// The workspace paints the open Presets menu over the completed frame. pub(crate) floating_shortcuts: bool, + pub(crate) unfocused: bool, pub(crate) command_popup_placement: CommandPopupPlacement, pub(crate) footer: Option<&'a TranscriptFooter>, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs index d05f8ad2e0..bc87760233 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/status_surface.rs @@ -5,6 +5,30 @@ use super::*; impl ChatComposer { + pub(crate) fn set_status_line(&mut self, status_line: Option) -> bool { + if self.footer.status_line_value == status_line { + return false; + } + self.footer.status_line_value = status_line; + true + } + + pub(crate) fn set_status_line_enabled(&mut self, enabled: bool) -> bool { + if self.footer.status_line_enabled == enabled { + return false; + } + self.footer.status_line_enabled = enabled; + true + } + + pub(crate) fn set_side_conversation_context_label(&mut self, label: Option) -> bool { + if self.footer.side_conversation_context_label == label { + return false; + } + self.footer.side_conversation_context_label = label; + true + } + pub(super) fn status_surface_height(&self, options: ComposerRenderOptions<'_>) -> u16 { u16::from(options.separate_status_line && self.footer.status_line_enabled) } @@ -28,6 +52,16 @@ impl ChatComposer { if area.is_empty() { return; } + if options.unfocused { + if let Some(content) = self.status_line_content() { + crate::bottom_pane::footer::render_unfocused_status( + content.clone().into_hyperlink_line().line.to_string(), + area, + buf, + ); + } + return; + } let mut props = self.footer_props(); let show_cycle_hint = !props.is_task_running && !self.blocks_direct_input diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 9d6a9830b2..a191d45e2f 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -723,6 +723,15 @@ pub(crate) fn render_context_right(area: Rect, buf: &mut Buffer, line: &Line<'st } } +pub(crate) fn render_unfocused_status(text: String, area: Rect, buf: &mut Buffer) { + let area = inset_footer_hint_area(area); + crate::line_truncation::truncate_line_with_ellipsis_if_overflow( + text.dim().into(), + usize::from(area.width), + ) + .render(area, buf); +} + pub(crate) fn inset_footer_hint_area(mut area: Rect) -> Rect { if area.width > 2 { area.x += 2; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a8a0a7b686..b38f8d13ad 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -26,6 +26,7 @@ pub(crate) use chat_composer::TranscriptFooter; pub(crate) use composer_gap::ComposerGap; pub(crate) use footer::footer_hint_items_line; pub(crate) use footer::inset_footer_hint_area; +pub(crate) use footer::render_unfocused_status; use std::collections::VecDeque; use std::path::PathBuf; @@ -1930,8 +1931,13 @@ impl BottomPane { /// Return true when no popups or modal views are active, regardless of task state. pub(crate) fn can_launch_external_editor(&self) -> bool { + self.completion_can_yield_focus() && !self.composer.popup_active() + } + + /// A hidden completion popup can yield to another pane. Prompts and queries keep input. + pub(crate) fn completion_can_yield_focus(&self) -> bool { !self.has_active_view() - && !self.composer.popup_active() + && !self.composer.query_active() && !self.questions.as_ref().is_some_and(|q| q.expanded) } diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index e203af6542..930fd80700 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -475,6 +475,10 @@ impl ChatWidget { self.bottom_pane.no_modal_or_popup_active() } + pub(crate) fn completion_can_yield_focus(&self) -> bool { + self.bottom_pane.completion_can_yield_focus() + } + pub(crate) fn can_launch_external_editor(&self) -> bool { !self.external_writer_view && self.bottom_pane.can_launch_external_editor() } diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 77a87b9e8b..289d48cffb 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -106,6 +106,7 @@ pub(crate) struct AppKeymap { pub(crate) change_host: Vec, pub(crate) focus_main_composer: Vec, pub(crate) focus_sidebar_content: Vec, + pub(crate) focus_sidebar_tabs: Vec, pub(crate) hide_pane: Vec, pub(crate) focus_pane_left: Vec, pub(crate) focus_pane_right: Vec, @@ -707,6 +708,7 @@ impl RuntimeKeymap { classic_keymap = keymap.clone(); classic_keymap.global.focus_main_composer = None; classic_keymap.global.focus_sidebar_content = None; + classic_keymap.global.focus_sidebar_tabs = None; classic_keymap.global.hide_pane = None; classic_keymap.global.focus_pane_left = None; classic_keymap.global.focus_pane_right = None; @@ -773,6 +775,11 @@ impl RuntimeKeymap { &[], "tui.keymap.global.focus_sidebar_content", )?, + focus_sidebar_tabs: resolve_bindings( + keymap.global.focus_sidebar_tabs.as_ref(), + &[], + "tui.keymap.global.focus_sidebar_tabs", + )?, hide_pane: resolve_bindings( keymap.global.hide_pane.as_ref(), &[], @@ -1800,6 +1807,7 @@ impl RuntimeKeymap { change_host: Vec::new(), focus_main_composer: Vec::new(), focus_sidebar_content: Vec::new(), + focus_sidebar_tabs: Vec::new(), hide_pane: Vec::new(), focus_pane_left: Vec::new(), focus_pane_right: Vec::new(), @@ -2170,6 +2178,7 @@ impl RuntimeKeymap { "global.focus_sidebar_content", &self.app.focus_sidebar_content, ), + ("global.focus_sidebar_tabs", &self.app.focus_sidebar_tabs), ("global.focus_pane_left", &self.app.focus_pane_left), ("global.focus_pane_right", &self.app.focus_pane_right), ("global.minimize_pane_width", minimize), @@ -2219,6 +2228,7 @@ impl RuntimeKeymap { "focus_sidebar_content", self.app.focus_sidebar_content.as_slice(), ), + ("focus_sidebar_tabs", self.app.focus_sidebar_tabs.as_slice()), ("focus_pane_left", self.app.focus_pane_left.as_slice()), ("focus_pane_right", self.app.focus_pane_right.as_slice()), ("minimize_pane_width", minimize), @@ -2405,6 +2415,7 @@ impl RuntimeKeymap { "focus_sidebar_content", self.app.focus_sidebar_content.as_slice(), ), + ("focus_sidebar_tabs", self.app.focus_sidebar_tabs.as_slice()), ("focus_pane_left", self.app.focus_pane_left.as_slice()), ("focus_pane_right", self.app.focus_pane_right.as_slice()), ("minimize_pane_width", minimize), @@ -2548,6 +2559,10 @@ impl RuntimeKeymap { "global.focus_sidebar_content", self.app.focus_sidebar_content.as_slice(), ), + ( + "global.focus_sidebar_tabs", + self.app.focus_sidebar_tabs.as_slice(), + ), ( "global.focus_pane_left", self.app.focus_pane_left.as_slice(), @@ -2607,6 +2622,10 @@ impl RuntimeKeymap { "global.focus_sidebar_content", self.app.focus_sidebar_content.as_slice(), ), + ( + "global.focus_sidebar_tabs", + self.app.focus_sidebar_tabs.as_slice(), + ), ( "global.focus_pane_left", self.app.focus_pane_left.as_slice(), @@ -4183,18 +4202,16 @@ mod tests { #[test] fn pane_binding_only_shadows_side_toggle_in_workspace_sessions() { - let keymap = toml::from_str("[global]\nfocus_pane_right = 'ctrl-/'").unwrap(); - let classic = RuntimeKeymap::for_session(&keymap, /*workspace*/ false).unwrap(); - assert_eq!( - classic.app.toggle_side_conversation, - RuntimeKeymap::defaults().app.toggle_side_conversation - ); - let workspace = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap(); - assert!(workspace.app.toggle_side_conversation.is_empty()); - assert_eq!( - workspace.app.focus_pane_right, - vec![key_hint::ctrl(KeyCode::Char('/'))] - ); + for action in ["focus_pane_right", "focus_sidebar_tabs"] { + let keymap = toml::from_str(&format!("[global]\n{action} = 'ctrl-/'")).unwrap(); + let classic = RuntimeKeymap::for_session(&keymap, /*workspace*/ false).unwrap(); + assert_eq!( + classic.app.toggle_side_conversation, + RuntimeKeymap::defaults().app.toggle_side_conversation + ); + let workspace = RuntimeKeymap::for_session(&keymap, /*workspace*/ true).unwrap(); + assert!(workspace.app.toggle_side_conversation.is_empty()); + } } #[test] diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 30dfd90954..062a306b28 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -109,6 +109,7 @@ impl KeymapActionId { self.action, "focus_main_composer" | "focus_sidebar_content" + | "focus_sidebar_tabs" | "hide_pane" | "focus_pane_left" | "focus_pane_right" @@ -316,6 +317,7 @@ define_runtime_action_bindings! { focus_activity, focus_main_composer, focus_sidebar_content, + focus_sidebar_tabs, hide_pane, focus_pane_left, focus_pane_right, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index 82449698f7..05e54d7fd7 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -205,7 +205,7 @@ impl KeymapContextSet { | "maximize_pane_width" | "resize_panes" => OPEN_PANE, "focus_main_composer" => MAIN_COMPOSER_FOCUS, - "focus_sidebar_content" => SIDE_PANE | SIDEBAR_CHOOSER, + "focus_sidebar_content" | "focus_sidebar_tabs" => SIDE_PANE | SIDEBAR_CHOOSER, _ => SIDE_PANE, }; return self.0 & required != 0; diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index 3b4155aab3..ec13899715 100644 --- a/codex-rs/tui/src/keymap/chords/leader_defaults.rs +++ b/codex-rs/tui/src/keymap/chords/leader_defaults.rs @@ -47,6 +47,7 @@ pub(in crate::keymap) fn register( (Global, "resize_panes", Char('r'), "r"), (Global, "minimize_pane_width", Char(','), ","), (Global, "maximize_pane_width", Char('.'), "."), + (Global, "focus_sidebar_tabs", Char('t'), "t"), ] { if remaining == 0 { break; diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index d08443a251..3dc4732624 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -119,6 +119,7 @@ define_keymap_actions! { global "Global" { change_host [MultiBackend] => "Change where a new conversation runs.", focus_main_composer [Workspace] => "Focus the main conversation composer.", + focus_sidebar_tabs [Workspace] => "Open the sidebar tab chooser.", focus_sidebar_content [Workspace] => "Focus the side conversation, or hide it when already focused.", hide_pane [Workspace] => "Hide the focused sidebar pane.", focus_pane_left [Workspace] => "Focus the parent conversation.", diff --git a/codex-rs/tui/src/sidebar_chooser.rs b/codex-rs/tui/src/sidebar_chooser.rs index 1ed3c35e89..0a736c2f99 100644 --- a/codex-rs/tui/src/sidebar_chooser.rs +++ b/codex-rs/tui/src/sidebar_chooser.rs @@ -1,4 +1,4 @@ -//! The empty sidebar belongs to its parent conversation and can remain open without taking input. +//! The sidebar chooser belongs to its parent conversation and can remain open without taking input. use crate::footer_hint; use crate::keymap::KeymapContext; @@ -28,6 +28,46 @@ pub(crate) fn footer(keymap: &RuntimeKeymap, area: Rect) -> footer_hint::HintLin footer_hint::HintLine::new(hints.into_iter().chain(hide), body_row(area, area.y).width) } +const CHAT_TAB: &str = " Side chat "; + +pub(crate) fn strip_targets(area: Rect) -> (Rect, Rect) { + let width = area.width.saturating_sub(1); + let plus = width.min(3); + let tabs = width - plus; + let height = area.height.min(1); + let tab_width = tabs.min(CHAT_TAB.len() as u16); + ( + Rect::new(area.x, area.y, tab_width, height), + Rect::new(area.x + tabs, area.y, plus, height), + ) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum TabState { + Absent, + Active, + Inactive, +} + +pub(crate) fn tab_strip(area: Rect, chooser: TabState, chat: TabState, buf: &mut Buffer) { + let (tab, add) = strip_targets(area); + let quiet = crate::style::secondary_text_style(); + let tab_style = |state| match state { + TabState::Active => crate::style::selection_style().not_underlined(), + TabState::Inactive => crate::bottom_pane::active_tab_style(), + TabState::Absent => quiet, + }; + if chat != TabState::Absent { + let style = if chooser == TabState::Absent { + tab_style(chat) + } else { + quiet + }; + Line::from(CHAT_TAB).style(style).render(tab, buf); + } + Line::from(" + ").style(tab_style(chooser)).render(add, buf); +} + pub(crate) fn choice_contains(area: Rect, y: u16) -> bool { let rows = if body_row(area, area.y).width < WIDE_WIDTH { 4..6 @@ -37,7 +77,13 @@ pub(crate) fn choice_contains(area: Rect, y: u16) -> bool { y < area.bottom().saturating_sub(1) && rows.contains(&y.saturating_sub(area.y)) } -pub(crate) fn paint(focused: bool, area: Rect, buf: &mut Buffer, keymap: &RuntimeKeymap) { +pub(crate) fn paint( + focused: bool, + area: Rect, + buf: &mut Buffer, + keymap: &RuntimeKeymap, + chat: TabState, +) { Clear.render(area, buf); if area.is_empty() { return; @@ -47,15 +93,12 @@ pub(crate) fn paint(focused: bool, area: Rect, buf: &mut Buffer, keymap: &Runtim } else { crate::style::quiet_workspace_style() }; - let strip_width = area.width.saturating_sub(1); - let add_width = strip_width.min(3); - let add = Rect::new(area.x + strip_width - add_width, area.y, add_width, 1); - let tab_style = if focused { - style.not_underlined() + let chooser = if focused { + TabState::Active } else { - crate::bottom_pane::active_tab_style() + TabState::Inactive }; - Line::from(" + ").style(tab_style).render(add, buf); + tab_strip(area, chooser, chat, buf); let row = |y| body_row(area, y); let footer_y = area.bottom() - 1; let mut paint = |offset, line: Line<'_>| { @@ -65,8 +108,11 @@ pub(crate) fn paint(focused: bool, area: Rect, buf: &mut Buffer, keymap: &Runtim } }; paint(2, Line::from("Open in sidebar").bold()); - let detail = "Start from this conversation"; - let title = "› + New side chat"; + let (title, detail) = if chat == TabState::Absent { + ("› + New side chat", "Start from this conversation") + } else { + ("› Side chat", "Return to this conversation") + }; let width = row(area.y).width; if width >= WIDE_WIDTH { let pad = usize::from(width - 1) - detail.len(); diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap index 8b8e00d9a2..a312e3a22b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__hide_and_reopen_side_conversation.snap @@ -23,18 +23,18 @@ parent-29 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-24 │ Side chat + + │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! @@ -43,6 +43,7 @@ parent-29 │child-29 │ Side from main thread · ⌃/ to swit 99 columns, reopened + Side chat + child-24 child-25 @@ -56,7 +57,6 @@ child-28 child-29 - › child draft! GPT-6.1-Sol default · Agent diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__keyboard_pane_widths.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__keyboard_pane_widths.snap index 69c71c73fb..2bb87d5669 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__keyboard_pane_widths.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__keyboard_pane_widths.snap @@ -3,6 +3,7 @@ source: tui/src/app/tests.rs expression: "format!(\"Zoomed\\n{zoomed}\\nRestored\\n{restored}\\nEqualized\\n{equalized}\")" --- Zoomed + Side chat + • unfinished answer @@ -16,14 +17,13 @@ Zoomed - › xchild draft! GPT-6.1-Sol default · Agent tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close Restored -parent-24 │• unfinished answer - │ +parent-24 │ Side chat + + │• unfinished answer parent-25 │ │ parent-26 │ @@ -41,8 +41,8 @@ parent-29 GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent │ tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close Equalized -parent-24 │• unfinished answer - │ +parent-24 │ Side chat + + │• unfinished answer parent-25 │ │ parent-26 │ diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__sidebar_existing_chat.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__sidebar_existing_chat.snap new file mode 100644 index 0000000000..608629622b --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__sidebar_existing_chat.snap @@ -0,0 +1,23 @@ +--- +source: tui/src/app/tests.rs +assertion_line: 3584 +expression: "chooser.lines().filter_map(|line|\nline.split_once('│').map(|(_, sidebar)|\nsidebar.trim_end())).collect::>().join(\"\\n\")" +--- + Side chat + + + Open in sidebar + + › Side chat + Return to this conversation + + + + + + + + + + + + Enter open · Esc back … diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap index dfd4e239e9..017ac79b7c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__single_side_chat.snap @@ -2,18 +2,18 @@ source: tui/src/app/tests.rs expression: panel --- -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-24 │ Side chat + + │child-24 +parent-25 │ + │child-25 +parent-26 │ + │child-26 +parent-27 │ + │child-27 +parent-28 │ + │child-28 +parent-29 │ + │child-29 New activity · ↓ Back to bottom · esc │ │ › parent draft │› child draft! 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 76f477a071..3c7737db97 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 @@ -3,6 +3,7 @@ source: tui/src/keymap_setup.rs expression: snapshot --- Focus Main Composer | leader c (ctrl-x c) | Global focus_main_composer Focus Main Composer Focus the main conversation composer. leader c (ctrl-x c) Default +Focus Sidebar Tabs | leader t (ctrl-x t) | Global focus_sidebar_tabs Focus Sidebar Tabs Open the sidebar tab chooser. leader t (ctrl-x t) Default Focus Sidebar Content | leader s (ctrl-x s) | Global focus_sidebar_content Focus Sidebar Content Focus the side conversation, or hide it when already focused. leader s (ctrl-x s) Default Hide Pane | leader - (ctrl-x -) | Global hide_pane Hide Pane Hide the focused sidebar pane. leader - (ctrl-x -) Default Focus Pane Left | leader left (ctrl-x left), leader h (ctrl-x h) | Global focus_pane_left Focus Pane Left Focus the parent conversation. leader left (ctrl-x left), leader h (ctrl-x h) Default @@ -14,4 +15,3 @@ Resize Panes | leader r (ctrl-x r) | Global resize_panes Resize Panes Enter keyb Move Divider Left | leader shift-h (ctrl-x shift-h) | Global move_divider_left Move Divider Left Move the divider left when the side pane has focus. leader shift-h (ctrl-x shift-h) Default Move Divider Right | leader shift-l (ctrl-x shift-l) | Global move_divider_right Move Divider Right Move the divider right when the side pane has focus. leader shift-l (ctrl-x shift-l) Default Toggle Pane Zoom | leader z (ctrl-x z) | Global toggle_pane_zoom Toggle Pane Zoom Zoom or restore the focused pane. leader z (ctrl-x z) Default -Open Agents | unbound | Global open_agents Open Agents Open the agent command center. unbound Default