diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 95404963ba..9f27bf75e9 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -145,6 +145,8 @@ pub struct TuiGlobalKeymap { pub focus_sidebar_tabs: Option, /// Hide the focused sidebar pane. pub hide_pane: Option, + /// Close the active sidebar tab. + pub close_sidebar_tab: Option, /// Focus the parent conversation. pub focus_pane_left: Option, /// Focus the open side conversation. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 4acc9e7938..3d1ad5e7e9 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5368,6 +5368,7 @@ "global": { "change_host": null, "clear_terminal": null, + "close_sidebar_tab": null, "copy": null, "equalize_pane_widths": null, "find_transcript": null, @@ -6197,6 +6198,14 @@ ], "description": "Clear the terminal UI." }, + "close_sidebar_tab": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Close the active sidebar tab." + }, "copy": { "allOf": [ { @@ -6535,6 +6544,7 @@ "default": { "change_host": null, "clear_terminal": null, + "close_sidebar_tab": null, "copy": null, "equalize_pane_widths": null, "find_transcript": null, diff --git a/codex-rs/rollout/src/lib.rs b/codex-rs/rollout/src/lib.rs index 1b33e051eb..9f640a526c 100644 --- a/codex-rs/rollout/src/lib.rs +++ b/codex-rs/rollout/src/lib.rs @@ -165,6 +165,7 @@ pub use session_index::append_thread_name; pub use session_index::find_thread_meta_by_name_str; pub use session_index::find_thread_meta_candidates_by_name_str; pub use session_index::find_thread_name_by_id; +pub use session_index::find_thread_name_entries_by_ids; pub use session_index::find_thread_names_by_ids; pub use session_index::remove_thread_name_entries; pub use state_db::StateDbHandle; diff --git a/codex-rs/rollout/src/session_index.rs b/codex-rs/rollout/src/session_index.rs index 46c852b978..e8b3fcea37 100644 --- a/codex-rs/rollout/src/session_index.rs +++ b/codex-rs/rollout/src/session_index.rs @@ -173,6 +173,18 @@ pub async fn find_thread_names_by_ids( codex_home: &Path, thread_ids: &HashSet, ) -> std::io::Result> { + Ok(find_thread_name_entries_by_ids(codex_home, thread_ids) + .await? + .into_iter() + .map(|(id, entry)| (id, entry.thread_name.trim().to_owned())) + .collect()) +} + +/// Returns each thread's latest indexed name and when it was written. +pub async fn find_thread_name_entries_by_ids( + codex_home: &Path, + thread_ids: &HashSet, +) -> std::io::Result> { if thread_ids.is_empty() { return Ok(HashMap::new()); } @@ -191,7 +203,7 @@ pub async fn find_thread_names_by_ids( let name = entry.thread_name.trim(); // The first nonempty name seen for an id is its latest usable name. if !name.is_empty() && remaining_ids.remove(&entry.id) { - names.insert(entry.id, name.to_string()); + names.insert(entry.id, entry); if remaining_ids.is_empty() { break; } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5efaf0eb99..84a42c3ef4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -281,6 +281,7 @@ mod side_companion; mod side_focus; mod side_pane; mod sidebar; +mod sidebar_close; mod startup; pub(crate) mod startup_bootstrap; mod startup_prompts; @@ -853,13 +854,23 @@ impl App { self.transcript_view .sync_patch_approval(self.chat_widget.current_patch_approval()); // A chooser action must never decide a modal that has not been painted yet. - if self.conversation_frame.chooser_area().is_some() + if (self.conversation_frame.chooser_area().is_some() + || self.sidebar().is_some_and(|pane| pane.close.is_some())) && self.chat_widget.has_active_modal() && matches!( &event, TuiEvent::Key(_) | TuiEvent::Mouse(_) | TuiEvent::Paste(_) ) { + if let TuiEvent::Key(key) = &event + && key.kind != KeyEventKind::Release + { + self.chat_widget.suppress_current_view_key_until_release( + KeyBinding::from_event(*key), + Instant::now(), + crate::activation_key_guard::ActivationKeySuppression::ViewTransition, + ); + } tui.frame_requester().schedule_frame(); return Ok(AppRunControl::Continue); } @@ -871,6 +882,9 @@ impl App { { tui.frame_requester().schedule_frame(); } + if Box::pin(sidebar_close::handle(self, tui, app_server, &event)).await { + return Ok(AppRunControl::Continue); + } if self.handle_pane_resize(tui, &event).await { return Ok(AppRunControl::Continue); } diff --git a/codex-rs/tui/src/app/agent_picker.rs b/codex-rs/tui/src/app/agent_picker.rs index a17663f660..1132d640f1 100644 --- a/codex-rs/tui/src/app/agent_picker.rs +++ b/codex-rs/tui/src/app/agent_picker.rs @@ -222,6 +222,18 @@ impl App { else { return; }; + if matches!(notification, ServerNotification::ThreadArchived(_)) + && (self.side_threads.contains_key(&thread_id) + || self + .sidebars + .values() + .any(|pane| pane.chat_tabs().choice_index(thread_id).is_some())) + { + self.app_event_tx.send(AppEvent::SideChatArchived { + backend_id: app_server.backend_id(), + target: thread_id, + }); + } let refresh_uncached_thread = matches!(visibility, AgentPickerThreadVisibility::Visible) && self.agent_navigation.get(&thread_id).is_none(); self.set_agent_picker_thread_visibility(thread_id, visibility); diff --git a/codex-rs/tui/src/app/agents_overview_actions.rs b/codex-rs/tui/src/app/agents_overview_actions.rs index 8ad25524ce..b9cae381d9 100644 --- a/codex-rs/tui/src/app/agents_overview_actions.rs +++ b/codex-rs/tui/src/app/agents_overview_actions.rs @@ -208,7 +208,7 @@ impl App { ), AgentsOverviewAction::Delete => ( format!("Permanently delete “{name}”?"), - "This stops any running work in this task and its child agents, then permanently deletes their history. This cannot be undone.", + "Tasks with saved side chats stay intact. Otherwise, deleting this task stops its running work and permanently deletes its history and its child agents’ history.", "Permanently delete task and child agents", ), }; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 2a9c394e60..3881aacea9 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -91,6 +91,9 @@ impl App { | AppEvent::CommitRealtimeTranscriptHistory | AppEvent::ParkSideCompanion { .. } | AppEvent::SideCreated { .. } + | AppEvent::SideChatRemovalFinished { .. } + | AppEvent::SideChatArchived { .. } + | AppEvent::SavedSideChatsLoaded { .. } | AppEvent::SideReplayFinished(_) | AppEvent::ResetTranscriptForThreadSwitch | AppEvent::ResetTranscriptForThreadSwitchPreservingScreen @@ -3388,6 +3391,55 @@ impl App { } => { Box::pin(self.finish_side_creation(tui, app_server, parent, request, result)).await; } + AppEvent::SideChatRemovalFinished { backend_id, target, request, action, result } => { + if backend_id == app_server.backend_id() + && let Some(side) = self.side_threads.get_mut(&target) + && side.close_request == Some(request) + { + side.close_request = None; + match (action, result) { + (sidebar_close::Action::Archive, Ok(())) => { + sidebar_close::forget_archived(self, tui, app_server, target).await?; + } + (sidebar_close::Action::CloseTab, Ok(())) => { + self.abandoned_side_threads.insert(target); + self.discard_thread_local_state(target).await; + } + (_, Err(message)) => { + if action == sidebar_close::Action::Archive { + // The server may have stopped the writer before the file move failed. + if let Some(channel) = self.thread_event_channels.get_mut(&target) { + channel.mark_replay_only(); + } + self.mark_agent_picker_thread_closed(target); + } + self.add_agents_overview_error(message); + } + } + tui.frame_requester().schedule_frame(); + } + } + AppEvent::SideChatArchived { backend_id, target } => { + if backend_id == app_server.backend_id() { + sidebar_close::forget_archived(self, tui, app_server, target).await?; + } + } + AppEvent::SavedSideChatsLoaded { backend_id, parent, request, result } => { + let visible = self.current_displayed_thread_id() == Some(parent); + if let Some(pane) = self.sidebars.get_mut(&Some(parent)) + && pane.saved_chats_request == Some(request) + { + pane.set_saved_chats_request(/*request*/ None); + if backend_id == app_server.backend_id() { + match result { + Ok(chats) => pane.update_saved_chats(chats), + Err(error) if visible => self.chat_widget.add_error_message(error), + Err(error) => tracing::warn!(%parent, %error), + } + } + tui.frame_requester().schedule_frame(); + } + } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); } @@ -4232,7 +4284,9 @@ impl App { let result = async { self.stop_voice_for_removed_thread(app_server, thread_id) .await?; - app_server.thread_delete(thread_id).await + app_server + .delete_thread_with_saved_side_chats(thread_id) + .await } .await; Ok(match result { diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index b68d910e56..58d4803a83 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -660,7 +660,9 @@ impl App { } if side_return_shortcut_matches(key_event) - && self.maybe_return_from_side(tui, app_server).await + && self + .maybe_return_from_side(tui, app_server, key_event) + .await { return; } diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 7bc13b0b00..7834b9c675 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -38,6 +38,7 @@ pub(super) struct ConversationFrame { pub(super) chat_strip: Option, pub(super) sidebar_input: Option<(usize, Option, usize)>, pub(super) leader_hud: Option, + pub(super) close_buttons: [Rect; 2], /// A dialog can obscure neighboring content in the terminal's completed buffer. behind_dialog: Option, caller_images: Vec, @@ -249,6 +250,12 @@ impl App { self.conversation_frame = ConversationFrame::default(); } let caller_inset = self.conversation_frame.inset; + let parent = self.sidebar_parent(); + if !self.conversation_focus_available(tui) + && let Some(pane) = self.sidebars.get_mut(&parent) + { + pane.close = None; + } let layout = sidebar::paint_layout(self, tui, screen_size.into()); let displayed = self.current_displayed_thread_id(); let chat = match displayed.and_then(|id| self.side_pair_key(id)) { @@ -365,6 +372,15 @@ impl App { ratatui::widgets::Paragraph::new(text) .wrap(ratatui::widgets::Wrap { trim: false }) .render(body, frame.buffer); + } else if self + .sidebar() + .is_some_and(|pane| pane.saved_chats_request.is_some()) + { + ratatui::widgets::Clear.render(*sidebar, frame.buffer); + Line::from("Loading side chats…").dim().render( + sidebar.inner(ratatui::layout::Margin::new(1, 1)), + frame.buffer, + ); } else if let Some(pane) = self.sidebar() { let footer = if let Some(hint) = resize_hint { let row = crate::sidebar_chooser::body_row(*sidebar, sidebar.y); @@ -421,6 +437,27 @@ impl App { if let Some(divider) = divider { side_pane::paint_divider(frame, divider.x, self.divider_drag, highlight_divider); } + self.conversation_frame.close_buttons = Default::default(); + if let Some(close) = self.sidebar().and_then(|pane| pane.close.as_ref()) { + // A resize can collapse an unfocused sidebar while its confirmation is open. + let viewport = frame.area(); + let column = self + .conversation_frame + .chooser_area() + .or(strip) + .unwrap_or(viewport); + let area = Rect { + x: column.x, + width: column.width, + ..viewport + }; + let buttons = close.paint(area, frame.buffer); + frame + .image_placements + .retain(|image| !area.intersects(image.area)); + frame.cursor_position = None; + self.conversation_frame.close_buttons = buttons; + } self.paint_centered_dialog(frame); self.conversation_frame.leader_hud = hud.map(|hud| { let area = hud.render_overlay(frame.area(), frame.buffer); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 7f71bd69d0..c1d5539f2e 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -10,6 +10,7 @@ use super::app_server_event_targets::server_notification_thread_target; use super::app_server_event_targets::server_request_thread_id; use super::*; use crate::app_server_session::SessionAccount; +use crate::app_server_session::SideChatStorage; use crate::app_server_session::is_server_shutting_down_error; use crate::app_server_session::source_agent_path; use crate::app_server_session::thread_blocks_direct_input; @@ -385,13 +386,26 @@ impl App { return Ok(true); } - let (session, turns, live_attached) = match app_server - .resume_thread( - &self.local_settings, - self.config.clone(), - thread_id, - crate::app_server_session::ResumeModelSettings::PreserveExistingThread, - ) + use crate::app_server_session::ResumeModelSettings; + let side = self.side_threads.get(&thread_id); + let mut config = self.config.clone(); + let resume_settings = if side.is_some_and(|side| side.storage == SideChatStorage::Attached) + && self + .agent_navigation + .get(&thread_id) + .is_some_and(|entry| entry.is_closed) + { + config.developer_instructions = Some( + codex_context_fragments::SideConversationPolicy::Attached + .instructions(config.developer_instructions.as_deref()), + ); + ResumeModelSettings::PreserveSideConversation + } else { + ResumeModelSettings::PreserveExistingThread + }; + let is_side = side.is_some(); + let (mut session, turns, live_attached) = match app_server + .resume_thread(&self.local_settings, config, thread_id, resume_settings) .await { Ok(started) => { @@ -466,6 +480,11 @@ impl App { (session, turns, false) } }; + if is_side { + session.forked_from_id = None; + session.fork_parent_title = None; + session.daybreak_enabled = false; + } // A reattach can discover output missed while the server was disconnected. let replay_may_have_output = if self.side_companions.contains_key(&thread_id) { match self.thread_event_channels.get(&thread_id) { @@ -570,6 +589,18 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result<()> { + if self + .side_threads + .get(&thread_id) + .is_some_and(|side| side.close_request.is_some()) + { + self.chat_widget.add_info_message( + "Side chat is still closing. Try again in a moment.".to_string(), + /*hint*/ None, + ); + return Ok(()); + } + let pending_profile_on_unavailable_thread = self.thread_unavailable(thread_id) && self.pending_server_profiles.contains_key(&thread_id); let cached_session = if self.thread_unavailable(thread_id) { diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index b6698e18fd..755d7366fa 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -1,6 +1,6 @@ -//! Transient side-conversation threads. +//! Side-conversation thread lifecycle. //! -//! A side conversation is an ephemeral fork used for a quick /side question while keeping the +//! A side conversation is a fork used for a quick /side question while keeping the //! primary thread focused. This module owns the app-level lifecycle for those forks: switching into //! them, returning to their parent, retaining them across normal navigation, and explicitly //! discarding them. The fork receives hidden developer instructions that make inherited history @@ -9,14 +9,10 @@ use super::*; use crate::app_event::SideStartMode; +use crate::app_server_session::SideChatStorage; use crate::chatwidget::InterruptedTurnNoticeMode; -use codex_app_server_protocol::ThreadUnsubscribeParams; -use codex_app_server_protocol::ThreadUnsubscribeResponse; -use codex_app_server_protocol::TurnInterruptParams; -use codex_app_server_protocol::TurnInterruptResponse; -use codex_context_fragments::SideConversationPolicy; -const SIDE_RENAME_BLOCK_MESSAGE: &str = "Side conversations are ephemeral and cannot be renamed."; +const SIDE_RENAME_BLOCK_MESSAGE: &str = "Side conversations cannot be renamed."; const SIDE_MAIN_THREAD_UNAVAILABLE_MESSAGE: &str = "'/side' is unavailable until the main thread is ready."; const SIDE_NO_STARTED_CONVERSATION_MESSAGE: &str = concat!( @@ -80,6 +76,7 @@ impl SideParentStatus { #[cfg(test)] mod tests { use super::*; + use codex_context_fragments::SideConversationPolicy; use codex_context_fragments::side_conversation_boundary; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -185,6 +182,8 @@ pub(super) struct SideThreadState { /// Parent-thread condition that changed while this side thread is visible. pub(super) parent_status: Option, pub(super) opening_message: Option, + pub(super) close_request: Option, + pub(super) storage: SideChatStorage, } impl SideThreadState { @@ -193,6 +192,8 @@ impl SideThreadState { parent_thread_id, parent_status: None, opening_message: None, + close_request: None, + storage: SideChatStorage::Temporary, } } } @@ -221,10 +222,10 @@ impl App { clear_side_ui(&mut self.chat_widget); return; }; - let Some((parent_thread_id, parent_status)) = self + let Some((parent_thread_id, parent_status, storage)) = self .side_threads .get(&active_thread_id) - .map(|state| (state.parent_thread_id, state.parent_status)) + .map(|state| (state.parent_thread_id, state.parent_status, state.storage)) else { clear_side_ui(&mut self.chat_widget); if self @@ -245,8 +246,12 @@ impl App { return; }; - self.chat_widget - .set_thread_rename_block_message(SIDE_RENAME_BLOCK_MESSAGE); + match storage { + SideChatStorage::Attached => self.chat_widget.clear_thread_rename_block(), + SideChatStorage::Temporary => self + .chat_widget + .set_thread_rename_block_message(SIDE_RENAME_BLOCK_MESSAGE), + } self.chat_widget .set_side_conversation_active(/*active*/ true); self.chat_widget @@ -268,8 +273,15 @@ impl App { ) { label_parts.push(format!("{} to switch", binding.display_label())); } + let action = if storage == SideChatStorage::Attached + && self.local_settings.workspace_enabled(&self.config) + { + "archive" + } else { + "close" + }; label_parts.push(format!( - "{} to close", + "{} to {action}", crate::key_hint::ctrl(KeyCode::Char('c')).display_label() )); self.chat_widget @@ -398,14 +410,35 @@ impl App { &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, + key: KeyEvent, ) -> bool { if self.overlay.is_none() && self.chat_widget.no_modal_or_popup_active() && self.chat_widget.composer_is_empty() - && let Some(parent_thread_id) = self.active_side_parent_thread_id() + && let Some(target) = self.current_displayed_thread_id() + && let Some(side) = self.side_threads.get(&target) { + if matches!(key.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c')) + && self.side_retention_enabled(tui) + && self.sidebar_chooser_available() + && !self.transcript_view.owns_interaction_key(key) + && side.storage == SideChatStorage::Attached + { + if let Err(err) = sidebar_close::request( + self, + tui, + app_server, + target, + sidebar_close::Action::Archive, + ) + .await + { + self.chat_widget.add_error_message(err.to_string()); + } + return true; + } if self - .select_agent_thread_and_discard_side(tui, app_server, parent_thread_id) + .select_agent_thread_and_discard_side(tui, app_server, side.parent_thread_id) .await .is_err() { @@ -504,57 +537,36 @@ impl App { self.abandoned_side_threads.insert(thread_id); self.pending_app_server_requests .cancel_thread_verification(&thread_id.to_string()); - let turn_id = self - .active_turn_id_for_thread(thread_id) - .await - .unwrap_or_default(); - let request_handle = app_server.request_handle(); - let interrupt_request_id = app_server.next_request_id(); - let retry_interrupt_request_id = app_server.next_request_id(); - let unsubscribe_request_id = app_server.next_request_id(); + let turn_id = self.active_turn_id_for_thread(thread_id).await; + let attachment = self + .thread_event_channels + .get(&thread_id) + .map(ThreadEventChannel::attachment); + let mut view = app_server.request_view(); + let saved_parent = self + .side_threads + .get(&thread_id) + .filter(|side| side.storage == SideChatStorage::Attached) + .map(|side| side.parent_thread_id); self.discard_thread_local_state(thread_id).await; - tokio::spawn(async move { - let interrupt_result = request_handle - .request_typed::(ClientRequest::TurnInterrupt { - request_id: interrupt_request_id, - params: TurnInterruptParams { - thread_id: thread_id.to_string(), - turn_id: turn_id.clone(), - }, - }) - .await; - let interrupt_result = if let Err(error) = &interrupt_result - && let Some(actual_turn_id) = active_turn_interrupt_race(error) + let cleanup = tokio::spawn(async move { + if let Err(error) = interrupt_for_close(&mut view, thread_id, turn_id, attachment).await { - request_handle - .request_typed::(ClientRequest::TurnInterrupt { - request_id: retry_interrupt_request_id, - params: TurnInterruptParams { - thread_id: thread_id.to_string(), - turn_id: actual_turn_id, - }, - }) - .await - } else { - interrupt_result - }; - if let Err(error) = interrupt_result { tracing::warn!(%error, "failed to interrupt side conversation"); } - if let Err(error) = request_handle - .request_typed::(ClientRequest::ThreadUnsubscribe { - request_id: unsubscribe_request_id, - params: ThreadUnsubscribeParams { - thread_id: thread_id.to_string(), - }, - }) - .await - { + if let Err(error) = view.thread_unsubscribe(thread_id).await { tracing::warn!(%error, "failed to unsubscribe side conversation"); } }); + if let Some(parent) = saved_parent { + self.sidebars + .entry(Some(parent)) + .or_default() + .closing_chats + .insert(thread_id, cleanup); + } } pub(super) async fn discard_closed_side_thread(&mut self, thread_id: ThreadId) { @@ -588,7 +600,7 @@ impl App { if let Some(side) = self.side_threads.remove(&thread_id) && let Some(pane) = self.sidebars.get_mut(&Some(side.parent_thread_id)) { - pane.remove_chat(thread_id); + pane.remove_chat(thread_id, side.storage); } self.side_companions.remove(&thread_id); self.agent_navigation.remove(thread_id); @@ -606,28 +618,16 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> std::result::Result<(), String> { - let interrupt_result = - if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { - app_server.turn_interrupt(thread_id, turn_id).await - } else { - app_server.startup_interrupt(thread_id).await - }; - // Replay-only sides may still be running after reconnect, so always interrupt. - // If the ephemeral thread is gone, let unsubscribe and local cleanup finish. - if let Err(TypedRequestError::Server { method, source }) = &interrupt_result - && method == "turn/interrupt" - && source.code == -32600 - && source.message == format!("thread not found: {thread_id}") - && self - .thread_event_channels - .get(&thread_id) - .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ReplayOnly) - { - return Ok(()); - } - interrupt_result.map_err(|err| { - format!("Failed to close side conversation {thread_id}; it is still open: {err}") - }) + let turn_id = self.active_turn_id_for_thread(thread_id).await; + let attachment = self + .thread_event_channels + .get(&thread_id) + .map(ThreadEventChannel::attachment); + interrupt_for_close(app_server, thread_id, turn_id, attachment) + .await + .map_err(|err| { + format!("Failed to close side conversation {thread_id}; it is still open: {err}") + }) } async fn keep_side_thread_visible_after_cleanup_failure( @@ -645,7 +645,7 @@ impl App { } } - async fn discard_side_thread_or_keep_visible( + pub(super) async fn discard_side_thread_or_keep_visible( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, @@ -668,12 +668,7 @@ impl App { } fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort(); fork_config.service_tier = self.chat_widget.configured_service_tier(); - fork_config.ephemeral = true; fork_config.daybreak_enabled = false; - fork_config.developer_instructions = Some( - SideConversationPolicy::Temporary - .instructions(fork_config.developer_instructions.as_deref()), - ); fork_config } @@ -824,6 +819,11 @@ impl App { self.side_fork_config(), parent_thread_id, self.selected_server_profile(parent_thread_id), + if workspace && app_server.can_list_saved_side_chats(parent_thread_id) { + SideChatStorage::Attached + } else { + SideChatStorage::Temporary + }, ); let create = async move { fork.await @@ -961,6 +961,7 @@ impl App { Self::install_side_thread_snapshot(&mut store, forked.session, forked.turns); } let mut child = SideThreadState::new(parent_thread_id); + child.storage = fork.storage; child.parent_status = self .side_thread_for_parent(parent_thread_id) .and_then(|id| self.side_threads.get(&id)) @@ -1039,3 +1040,33 @@ impl App { } } } + +/// A cached turn can finish or be replaced before the server handles an interrupt. +pub(super) async fn interrupt_for_close( + server: &mut AppServerSession, + thread: ThreadId, + turn: Option, + attachment: Option, +) -> std::result::Result<(), TypedRequestError> { + let mut result = match turn { + Some(turn) => server.turn_interrupt(thread, turn).await, + None => server.startup_interrupt(thread).await, + }; + if let Err(error) = &result + && let Some(current) = active_turn_interrupt_race(error) + { + result = server.turn_interrupt(thread, current).await; + } + match result { + Err(TypedRequestError::Server { method, source }) + if method == "turn/interrupt" + && source.code == -32600 + && (source.message == "no active turn to interrupt" + || (attachment == Some(ThreadEventAttachment::ReplayOnly) + && source.message == format!("thread not found: {thread}"))) => + { + Ok(()) + } + result => result, + } +} diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs index 76ed1a5988..1db002f759 100644 --- a/codex-rs/tui/src/app/side_focus.rs +++ b/codex-rs/tui/src/app/side_focus.rs @@ -3,9 +3,11 @@ use super::sidebar::PaneFocus; use super::sidebar::SidebarContent; +use super::sidebar_close::Action::CloseTab; use super::*; use crate::activation_key_guard::ActivationKeySuppression; use crate::app_event::SideStartMode; +use crate::app_server_session::SideChatStorage; use crate::bottom_pane::ScrollState; use crate::keymap::ListAction; use crate::sidebar_chooser; @@ -25,7 +27,7 @@ impl App { } let pane = self.sidebar()?; let tabs = pane.chat_tabs().strip(); - let TabTarget::Tab(id) = tabs.hit(strip, point)? else { + let (TabTarget::Tab(id), _) = tabs.hit(strip, point)? else { return None; }; if self.conversation_frame.chat_strip.is_some() { @@ -55,8 +57,35 @@ impl App { self.focus_returned_parent_composer(parent).await?; } self.zoomed_thread = None; - self.sidebar_mut() - .show(SidebarContent::Chooser, PaneFocus::Sidebar); + let parent = self.current_displayed_thread_id(); + let pane = self.sidebar_mut(); + pane.closing_chats.retain(|_, task| !task.is_finished()); + let was_open = pane.visible_content() == Some(SidebarContent::Chooser); + pane.show(SidebarContent::Chooser, PaneFocus::Sidebar); + if !was_open + && pane.creation.is_none() + && pane.saved_chats_request.is_none() + && let Some(parent) = parent + && server.can_list_saved_side_chats(parent) + { + let request = uuid::Uuid::new_v4(); + pane.set_saved_chats_request(Some(request)); + let codex_home = self.config.codex_home.clone(); + let mut view = server.request_view(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = view + .list_saved_side_chats(parent, codex_home.as_path()) + .await + .map_err(|err| format!("Failed to load saved side chats: {err}")); + tx.send(AppEvent::SavedSideChatsLoaded { + backend_id: view.backend_id(), + parent, + request, + result, + }); + }); + } tui.frame_requester().schedule_frame(); Ok(()) } @@ -71,49 +100,55 @@ impl App { return false; } if let TuiEvent::Mouse(mouse) = event - && mouse.kind == MouseEventKind::Down(MouseButton::Left) - && [ - self.conversation_frame.chat_strip, - self.conversation_frame.chooser_area(), - ] - .into_iter() - .flatten() - .any(|area| area.contains((mouse.column, mouse.row).into())) - && self.conversation_frame.sidebar_input - != self.sidebar().map(sidebar::SidebarPane::input_state) - { - // A tab may have closed or the chooser scrolled since this frame was painted. - tui.frame_requester().schedule_frame(); - return true; - } - if let TuiEvent::Mouse(mouse) = event - && mouse.kind == MouseEventKind::Down(MouseButton::Left) - && let Some(pane) = self.sidebar() - && (self.conversation_frame.chat_strip.is_some() || pane.creation.is_none()) - && let Some(strip) = self.conversation_frame.sidebar_tab_strip() - && strip.contains((mouse.column, mouse.row).into()) + && matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left | MouseButton::Middle) + ) { let point = (mouse.column, mouse.row).into(); - let target = pane.chat_tabs().strip().hit(strip, point); - let result = if matches!(target, Some(TabTarget::Add)) { - self.open_sidebar_tabs(tui, server).await - } else if let Some(TabTarget::Tab(id)) = target - && self.current_displayed_thread_id() != Some(id) + let frame = &self.conversation_frame; + let chooser = frame.chooser_area(); + let pane = self.sidebar(); + if (frame.chat_strip.is_some_and(|area| area.contains(point)) + || chooser.is_some_and(|area| area.contains(point))) + && frame.sidebar_input != pane.map(sidebar::SidebarPane::input_state) { - let zoomed = self.zoomed_thread.is_some() - && self.zoomed_thread == self.current_displayed_thread_id(); - let result = self.select_agent_thread(tui, server, id).await; - if zoomed && self.current_displayed_thread_id() == Some(id) { - self.zoomed_thread = Some(id); - } - result - } else { + // A tab may have closed or the chooser scrolled since this frame was painted. + tui.frame_requester().schedule_frame(); + return true; + } + if let Some(pane) = pane + && (frame.chat_strip.is_some() + || (pane.creation.is_none() && pane.saved_chats_request.is_none())) + && let Some(strip) = frame.sidebar_tab_strip() + && strip.contains(point) + { + let target = pane.chat_tabs().strip().hit(strip, point); + let middle = mouse.kind == MouseEventKind::Down(MouseButton::Middle); + let result = if let Some((TabTarget::Tab(id), tab)) = target + && (middle || crate::sidebar_chooser::close_cell(tab).contains(point)) + { + sidebar_close::request(self, tui, server, id, CloseTab).await + } else if !middle && matches!(target, Some((TabTarget::Add, _))) { + self.open_sidebar_tabs(tui, server).await + } else if let Some((TabTarget::Tab(id), _)) = target + && self.current_displayed_thread_id() != Some(id) + { + let zoomed = self.zoomed_thread.is_some() + && self.zoomed_thread == self.current_displayed_thread_id(); + let result = self.select_agent_thread(tui, server, id).await; + if zoomed && self.current_displayed_thread_id() == Some(id) { + self.zoomed_thread = Some(id); + } + result + } else { + return true; + }; + if let Err(err) = result { + self.chat_widget.add_error_message(err.to_string()); + } 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; @@ -132,7 +167,7 @@ impl App { }; let pane = self.sidebar_mut(); pane.show(SidebarContent::Chooser, PaneFocus::Sidebar); - if pane.creation.is_some() { + if pane.creation.is_some() || pane.saved_chats_request.is_some() { return true; } let choice = pane.choice; @@ -199,6 +234,9 @@ impl App { let open = matches!(action, ChooserAction::Open(_)); let pane = self.sidebar_mut(); + if open && pane.saved_chats_request.is_some() { + return true; + } if let Some(creation) = &pane.creation { match (&creation.state, open) { (sidebar::CreationState::Waiting { .. }, true) => {} @@ -232,9 +270,35 @@ impl App { ChooserAction::Hide => None, }; if let Some(id) = target { + let pane = self.sidebar_mut(); + if pane + .closing_chats + .get(&id) + .is_some_and(|task| !task.is_finished()) + { + self.chat_widget.add_info_message( + "Side chat is still closing. Try again in a moment.".to_string(), + /*hint*/ None, + ); + return true; + } + let saved = !pane.chat_tabs().ids.contains(&id); + if saved && let Some(parent) = self.current_displayed_thread_id() { + self.abandoned_side_threads.remove(&id); + self.side_threads.insert( + id, + side::SideThreadState { + storage: SideChatStorage::Attached, + ..side::SideThreadState::new(parent) + }, + ); + } if let Err(err) = self.select_agent_thread(tui, server, id).await { self.chat_widget.add_error_message(err.to_string()); } + if saved && self.current_displayed_thread_id() != Some(id) { + self.side_threads.remove(&id); + } } else if open && let Some(parent_thread_id) = self.chat_widget.thread_id() { self.app_event_tx.send(AppEvent::StartSide { parent_thread_id, @@ -267,6 +331,9 @@ impl App { key: KeyEvent, ) -> bool { let bindings = &self.keymap.app; + let close = self.current_displayed_thread_id().filter(|id| { + bindings.close_sidebar_tab.is_pressed(key) && self.side_threads.contains_key(id) + }); let left = bindings.focus_pane_left.is_pressed(key); let right = bindings.focus_pane_right.is_pressed(key); let main = @@ -300,7 +367,15 @@ impl App { } let resize = bindings.resize_panes.is_pressed(key) && can_resize; let resizing = resize || width_change != 0 || focused_percent.is_some(); - if !(tabs || left || right || main || sidebar || hide || zoom_target.is_some() || resizing) + if !(close.is_some() + || tabs + || left + || right + || main + || sidebar + || hide + || zoom_target.is_some() + || resizing) || !self.conversation_focus_available(tui) { return false; @@ -327,7 +402,9 @@ impl App { && !on_child && (self.transcript_view.has_active_interaction() || self.backtrack.overlay_preview_active); - let result = if tabs { + let result = if let Some(id) = close { + sidebar_close::request(self, tui, app_server, id, CloseTab).await + } else if tabs { self.open_sidebar_tabs(tui, app_server).await } else if resize { self.begin_pane_resize(tui) @@ -374,10 +451,7 @@ impl App { result } else if sidebar && child.is_none() { self.focus_current_parent_view(tui); - self.sidebar_mut() - .show(SidebarContent::Chooser, PaneFocus::Sidebar); - tui.frame_requester().schedule_frame(); - Ok(()) + self.open_sidebar_tabs(tui, app_server).await } else if hide || sidebar { self.hide_focused_side_pane(tui, app_server).await } else { diff --git a/codex-rs/tui/src/app/sidebar.rs b/codex-rs/tui/src/app/sidebar.rs index 1273290dab..c77e552329 100644 --- a/codex-rs/tui/src/app/sidebar.rs +++ b/codex-rs/tui/src/app/sidebar.rs @@ -5,10 +5,17 @@ use super::App; use super::pane_motion::PaneMotion; use super::side_pane; use super::side_pane::SidePaneLayout; +use crate::app_server_session::SideChat; +use crate::app_server_session::SideChatStorage; +use crate::app_server_session::side_chat_prompt_preview; use crate::sidebar_chooser::ChatTabs; use crate::tui::Tui; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadItemPayload; use codex_protocol::ThreadId; use ratatui::layout::Rect; +use std::collections::HashMap; +use tokio::task::JoinHandle; #[derive(Clone, Copy, Default, PartialEq, Eq)] pub(super) enum SidebarContent { @@ -52,8 +59,13 @@ impl SideCreation { #[derive(Default)] pub(super) struct SidebarPane { pub(super) creation: Option, + pub(super) close: Option, content: SidebarContent, tabs: Vec, + // The chooser keeps the same order when an attached chat becomes a live tab. + choices: Vec, + pub(super) saved_chats_request: Option, + pub(super) closing_chats: HashMap>, // Visit order is independent of the order in which tabs appear. visits: Vec, pub(super) choice: usize, @@ -66,18 +78,12 @@ impl SidebarPane { pub(super) fn show(&mut self, content: SidebarContent, focus: PaneFocus) { match content { SidebarContent::Chat(id) => { - if !self.tabs.contains(&id) { - self.tabs.push(id); - self.tabs_revision += 1; - } + self.add_tab(id); self.visits.retain(|visited| *visited != id); self.visits.push(id); } SidebarContent::Chooser if self.content != content || self.focus.is_none() => { - self.choice = self - .selected_chat() - .and_then(|id| self.chat_tabs().choice_index(id)) - .unwrap_or(0); + self.reset_choice(); } SidebarContent::Chooser => {} } @@ -88,10 +94,50 @@ impl SidebarPane { pub(super) fn chat_tabs(&self) -> ChatTabs<'_> { ChatTabs { ids: &self.tabs, + choices: &self.choices, selected: self.selected_chat(), } } + pub(super) fn set_saved_chats_request(&mut self, request: Option) { + self.saved_chats_request = request; + self.tabs_revision += 1; + } + + pub(super) fn update_saved_chats(&mut self, mut chats: Vec) { + for chat in &mut self.choices { + if !self.tabs.contains(&chat.id) { + continue; + } + if let Some(saved) = chats.iter_mut().find(|saved| saved.id == chat.id) { + std::mem::swap(saved, chat); + } else { + chats.push(std::mem::replace(chat, chat.id.into())); + } + } + let reordered = self + .choices + .iter() + .map(|chat| chat.id) + .ne(chats.iter().map(|chat| chat.id)); + self.choices = chats; + if reordered { + self.tabs_revision += 1; + self.reset_choice(); + } + } + + fn reset_choice(&mut self) { + self.choice = self + .selected_chat() + .and_then(|id| self.chat_tabs().choice_index(id)) + .unwrap_or(usize::from(!self.choices.is_empty())); + } + + pub(super) fn previous_chat(&self, target: ThreadId) -> Option { + self.visits.iter().rev().copied().find(|id| *id != target) + } + pub(super) fn selected_chat(&self) -> Option { self.visits.last().copied() } @@ -100,11 +146,20 @@ impl SidebarPane { (self.tabs_revision, self.selected_chat(), self.choice) } + fn add_tab(&mut self, id: ThreadId) { + if !self.tabs.contains(&id) { + if self.chat_tabs().choice_index(id).is_none() { + self.choices.push(id.into()); + } + self.tabs.push(id); + self.tabs_revision += 1; + } + } + pub(super) fn chat_ready(&mut self, child: ThreadId) { self.creation = None; if !self.tabs.contains(&child) { - self.tabs.push(child); - self.tabs_revision += 1; + self.add_tab(child); // A completed background fork must not replace the user's selection. self.visits.insert(0, child); } @@ -119,17 +174,17 @@ impl SidebarPane { self.focus = None; } - pub(super) fn remove_chat(&mut self, id: ThreadId) { + pub(super) fn remove_chat(&mut self, id: ThreadId, storage: SideChatStorage) { let Some(index) = self.tabs.iter().position(|tab| *tab == id) else { return; }; + self.close.take_if(|close| close.target == id); self.tabs.remove(index); self.tabs_revision += 1; self.visits.retain(|visited| *visited != id); - if index + 1 < self.choice { - self.choice -= 1; + if storage == SideChatStorage::Temporary { + self.remove_choice(id); } - self.choice = self.choice.min(self.tabs.len()); if self.content == SidebarContent::Chat(id) { self.content = match self.selected_chat() { Some(child) => SidebarContent::Chat(child), @@ -142,12 +197,55 @@ impl SidebarPane { } } + pub(super) fn remove_choice(&mut self, id: ThreadId) { + if let Some(index) = self.chat_tabs().choice_index(id) { + self.choices.remove(index - 1); + if index < self.choice { + self.choice -= 1; + } + self.choice = self.choice.min(self.choices.len()); + } + } + pub(super) fn visible_content(&self) -> Option { self.focus.map(|_| self.content) } } impl App { + pub(super) fn track_side_chat_title( + &mut self, + id: ThreadId, + notification: &ServerNotification, + ) { + let (name, prompt) = match notification { + ServerNotification::ThreadNameUpdated(update) => (Some(&update.thread_name), None), + ServerNotification::ItemCompleted(update) => match &update.item.payload { + ThreadItemPayload::UserMessage { content, .. } => (None, Some(content)), + _ => return, + }, + _ => return, + }; + let Some(chat) = self + .sidebars + .values_mut() + .find_map(|pane| pane.choices.iter_mut().find(|chat| chat.id == id)) + else { + return; + }; + + if let Some(name) = name { + chat.name.clone_from(name); + } else if chat.preview.is_empty() + && let Some(preview) = prompt.and_then(|content| side_chat_prompt_preview(content)) + { + chat.preview = preview.to_owned(); + } else { + return; + } + self.chat_widget.request_redraw(); + } + fn side_creation_pending(&self, owner: crate::backend_id::BackendId) -> bool { self.sidebars.values().any(|pane| { pane.creation diff --git a/codex-rs/tui/src/app/sidebar_close.rs b/codex-rs/tui/src/app/sidebar_close.rs new file mode 100644 index 0000000000..010606f17e --- /dev/null +++ b/codex-rs/tui/src/app/sidebar_close.rs @@ -0,0 +1,325 @@ +//! Closing a tab keeps saved chats in the picker. Archiving also removes the saved entry. +//! Confirmation belongs to the parent and does not select the target tab. + +use super::*; +use crate::app_server_session::SideChatStorage; +use crate::footer_hint; +use ratatui::buffer::Buffer; +use ratatui::layout::Margin; +use ratatui::widgets::Widget; + +pub(super) use crate::app_event::SideChatRemoval as Action; + +pub(super) struct Confirmation { + action: Action, + pub(super) target: ThreadId, + message: String, + confirm: bool, +} + +impl Confirmation { + pub(super) fn paint(&self, area: Rect, buf: &mut Buffer) -> [Rect; 2] { + ratatui::widgets::Clear.render(area, buf); + if area.width < 3 || area.height < 3 { + return Default::default(); + } + let body = area.inner(Margin::new(1, 0)); + let lines = textwrap::wrap(&self.message, usize::from(body.width)); + let offset = lines.len() + 2; + let accept = match self.action { + Action::CloseTab => " Close ", + Action::Archive => " Archive ", + }; + if body.width < 10 + accept.len() as u16 || offset >= usize::from(area.height) { + Line::from("Resize to review · Esc to cancel").render(body, buf); + return Default::default(); + } + for (line, row) in lines.into_iter().zip(body.rows().skip(1)) { + Line::from(line).render(row, buf); + } + let y = area.y + offset as u16; + let buttons = [ + Rect::new(body.x, y, 8, 1).intersection(area), + Rect::new(body.x.saturating_add(10), y, accept.len() as u16, 1).intersection(area), + ]; + for (index, (label, button)) in [" Cancel ", accept].into_iter().zip(buttons).enumerate() { + let style = if self.confirm == (index == 1) { + crate::style::selection_style() + } else { + crate::style::secondary_text_style() + }; + Line::from(label).style(style).render(button, buf); + } + if let Some(footer) = body.rows().nth(offset + 2) { + footer_hint::HintLine::new( + [ + footer_hint::shortcut("←/→", "choose"), + footer_hint::shortcut("Enter", "confirm"), + footer_hint::shortcut("Esc", "cancel"), + ], + footer.width, + ) + .line + .render(footer, buf); + } + buttons + } +} + +pub(super) async fn request( + app: &mut App, + tui: &mut tui::Tui, + server: &mut AppServerSession, + target: ThreadId, + action: Action, +) -> Result<()> { + let Some(side) = app.side_threads.get(&target) else { + return Ok(()); + }; + if side.close_request.is_some() { + return Ok(()); + } + let displayed = app.current_displayed_thread_id() == Some(target); + let mut losses = Vec::new(); + if app.active_turn_id_for_thread(target).await.is_some() + || (displayed && app.chat_widget.is_agent_turn_running()) + { + losses.push("stop the running turn"); + } + let has_unsent_input = side.opening_message.is_some() + || if displayed { + app.chat_widget.has_unsent_input() + } else if let Some(channel) = app.thread_event_channels.get(&target) { + channel + .store + .lock() + .await + .input_state + .as_ref() + .is_some_and(crate::chatwidget::ThreadInputState::has_unsent_input) + } else { + false + }; + if has_unsent_input { + losses.push("discard unsent input"); + } + if side.storage == SideChatStorage::Temporary { + losses.push("lose this temporary chat’s history"); + } + if action == Action::Archive { + losses.push("remove it from Side chats"); + } + if losses.is_empty() { + return execute(app, tui, server, target, action).await; + } + let Some(pane) = app.sidebar() else { + return Ok(()); + }; + let tabs = pane.chat_tabs(); + let Some(index) = tabs.choice_index(target) else { + return Ok(()); + }; + let title = crate::sidebar_chooser::side_chat_label(&tabs.choices[index - 1]) + .map(str::to_owned) + .unwrap_or_else(|| format!("Side chat {index}")); + app.conversation_frame.close_buttons = Default::default(); + let verb = match action { + Action::CloseTab => "Close", + Action::Archive => "Archive", + }; + app.sidebar_mut().close = Some(Confirmation { + target, + action, + message: format!( + "This will {}. {verb} “{}”?", + losses.join(" and "), + crate::line_truncation::truncate_line_with_ellipsis_if_overflow( + title.into(), + /*max_width*/ 24 + ) + ), + confirm: false, + }); + tui.frame_requester().schedule_frame(); + Ok(()) +} + +pub(super) async fn handle( + app: &mut App, + tui: &mut tui::Tui, + server: &mut AppServerSession, + event: &TuiEvent, +) -> bool { + let parent = app.sidebar_parent(); + let available = app.conversation_focus_available(tui); + let Some(pane) = app.sidebars.get_mut(&parent) else { + return false; + }; + let Some(confirmation) = pane.close.as_mut() else { + return false; + }; + if !available { + pane.close = None; + tui.frame_requester().schedule_frame(); + return matches!( + event, + TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::Mouse(_) + ); + } + let buttons = app.conversation_frame.close_buttons; + let accept = match event { + TuiEvent::Key(key) if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => { + match key.code { + KeyCode::Tab | KeyCode::Left | KeyCode::Right => { + confirmation.confirm = !confirmation.confirm; + tui.frame_requester().schedule_frame(); + return true; + } + KeyCode::Esc => false, + KeyCode::Enter if buttons[1].is_empty() => return true, + KeyCode::Enter => confirmation.confirm, + _ => return true, + } + } + TuiEvent::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { + let Some(index) = buttons + .iter() + .position(|button| button.contains((mouse.column, mouse.row).into())) + else { + return true; + }; + index == 1 + } + TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::Mouse(_) => return true, + _ => return false, + }; + let target = confirmation.target; + let action = confirmation.action; + pane.close = None; + tui.frame_requester().schedule_frame(); + if accept && let Err(err) = execute(app, tui, server, target, action).await { + app.chat_widget.add_error_message(err.to_string()); + } + if let TuiEvent::Key(key) = event { + app.chat_widget.suppress_current_view_key_until_release( + KeyBinding::from_event(*key), + Instant::now(), + crate::activation_key_guard::ActivationKeySuppression::ViewTransition, + ); + } + true +} + +async fn execute( + app: &mut App, + tui: &mut tui::Tui, + server: &mut AppServerSession, + target: ThreadId, + action: Action, +) -> Result<()> { + let Some(side) = app.side_threads.get(&target) else { + return Ok(()); + }; + if app.current_displayed_thread_id() == Some(target) { + let next = match action { + Action::CloseTab => app.sidebar().and_then(|pane| pane.previous_chat(target)), + Action::Archive => None, + } + .unwrap_or(side.parent_thread_id); + app.select_agent_thread(tui, server, next).await?; + if app.current_displayed_thread_id() != Some(next) { + return Ok(()); + } + if action == Action::Archive { + app.focus_returned_parent_composer(next).await?; + } + } + let Some(side) = app.side_threads.get_mut(&target) else { + return Ok(()); + }; + if side.close_request.is_some() { + return Ok(()); + } + let request = uuid::Uuid::new_v4(); + side.close_request = Some(request); + let turn = app.active_turn_id_for_thread(target).await; + let attachment = app + .thread_event_channels + .get(&target) + .map(ThreadEventChannel::attachment); + let mut view = server.request_view(); + let tx = app.app_event_tx.clone(); + tokio::spawn(async move { + let result = match action { + Action::Archive => view + .thread_archive(target) + .await + .map_err(|err| format!("Failed to archive side conversation {target}: {err}")), + Action::CloseTab => { + async { + super::side::interrupt_for_close(&mut view, target, turn, attachment) + .await + .map_err(|err| { + format!("Failed to interrupt side conversation {target}: {err}") + })?; + view.thread_unsubscribe(target) + .await + .map_err(|err| format!("Failed to close side conversation {target}: {err}")) + } + .await + } + }; + tx.send(AppEvent::SideChatRemovalFinished { + backend_id: view.backend_id(), + target, + request, + action, + result, + }); + }); + tui.frame_requester().schedule_frame(); + Ok(()) +} + +/// Reconcile both locally requested archives and notifications from another client. +pub(super) async fn forget_archived( + app: &mut App, + tui: &mut tui::Tui, + server: &mut AppServerSession, + target: ThreadId, +) -> Result<()> { + let Some(parent) = app + .side_threads + .get(&target) + .map(|side| side.parent_thread_id) + .or_else(|| { + app.sidebars + .iter() + .find_map(|(parent, pane)| pane.chat_tabs().choice_index(target).and(*parent)) + }) + else { + return Ok(()); + }; + if app.current_displayed_thread_id() == Some(target) { + app.select_agent_thread(tui, server, parent).await?; + if app.current_displayed_thread_id() == Some(parent) { + app.focus_returned_parent_composer(parent).await?; + } + } + let hide = app + .sidebars + .get(&Some(parent)) + .and_then(sidebar::SidebarPane::visible_content) + == Some(sidebar::SidebarContent::Chat(target)); + app.abandoned_side_threads.insert(target); + app.discard_thread_local_state(target).await; + if let Some(pane) = app.sidebars.get_mut(&Some(parent)) { + pane.set_saved_chats_request(/*request*/ None); + pane.remove_choice(target); + if hide { + pane.hide(); + } + } + tui.frame_requester().schedule_frame(); + Ok(()) +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap index 2b1c063293..62bfaa89a5 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__actions__delete_task_confirmation.snap @@ -1,6 +1,6 @@ --- source: tui/src/app/agents_overview_actions_tests.rs -expression: "render_bottom_popup(&app.chat_widget, 80)" +expression: "normalize_agent_center_snapshot(render_bottom_popup(&app.chat_widget, 80))" --- Agent command center Group: Project g All 1 Needs you 0 Working 0 Ready 1 Inactive 0 @@ -9,10 +9,10 @@ expression: "render_bottom_popup(&app.chat_widget, 80)" /tmp/project 1 › ○ Current task Ready 2m ago - Permanently delete “Current task”? - This stops any running work in this task and its child agents, then - permanently deletes their history. This cannot be undone. + Tasks with saved side chats stay intact. Otherwise, deleting this + task stops its running work and permanently deletes its history and + its child agents’ history. › 1. Cancel diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 40f2c46eed..15f7f4532f 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -1532,7 +1532,8 @@ See the Codex keymap documentation for supported actions and examples." parked_side_completions.entry(*backend_id).or_default().push(event); continue; } - AppEvent::StartupThreadStarted { backend_id, .. } => { + AppEvent::SavedSideChatsLoaded { backend_id, .. } + | AppEvent::StartupThreadStarted { backend_id, .. } => { backends.slot(*backend_id).unwrap_or(displayed_session) } _ => displayed_session, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 75bb254dc9..88e0a94a66 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3325,6 +3325,356 @@ async fn docked_replay_finishes_while_skills_refresh_is_pending() -> Result<()> Ok(()) } +#[tokio::test] +async fn sidebar_reopens_saved_side_chat_after_restart() -> Result<()> { + use crate::app_server_session::ResumeModelSettings; + use crate::app_server_session::SideChatStorage; + use codex_app_server_client::AppServerEvent; + + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let model = core_test_support::responses::start_mock_server().await; + app_test_support::MockResponsesConfig::new(&model.uri()).write(&app.config.codex_home)?; + app.config = app + .rebuild_config_for_cwd(app.config.cwd.to_path_buf()) + .await?; + let config = app.config.clone(); + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; + let parent = server.start_thread(&config).await?.session.thread_id; + side_chat_policy_tests::ask(&mut server, &model, parent, "Parent-only prompt").await?; + server.thread_set_name(parent, "New name".into()).await?; + let incomplete = server + .fork_side_thread( + &app.local_settings, + config.clone(), + parent, + /*selected_profile*/ None, + SideChatStorage::Attached, + ) + .await? + .session + .thread_id; + let fork = server + .sidebar_fork( + app.local_settings.clone(), + config.clone(), + parent, + /*profile*/ None, + SideChatStorage::Attached, + ) + .await?; + assert_eq!(fork.boundary, Ok(())); + let child = server + .fork_side_thread( + &app.local_settings, + config.clone(), + parent, + /*selected_profile*/ None, + SideChatStorage::Attached, + ) + .await? + .session + .thread_id; + // Older daemons copy the parent's name before the client can write the side-chat boundary. + server.thread_set_name(child, "New name".into()).await?; + server + .thread_inject_items( + child, + codex_context_fragments::SideConversationPolicy::Attached.fork_items(), + ) + .await?; + let children = [fork.thread.session.thread_id, child]; + server.shutdown().await?; + + let child_id = child.to_string(); + // A cold resume before the first prompt can seed the summary from the parent's history. + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; + let started = server + .resume_thread( + &app.local_settings, + config.clone(), + child, + ResumeModelSettings::PreserveExistingThread, + ) + .await?; + assert!( + started.turns.is_empty(), + "inherited turns should stay hidden" + ); + side_chat_policy_tests::ask(&mut server, &model, child, "Child-only prompt").await?; + let expected_child_turn = server + .thread_read(child, /*include_turns*/ true) + .await? + .turns + .pop() + .expect("completed child turn"); + server.shutdown().await?; + + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; + let started = server + .resume_thread( + &app.local_settings, + config, + parent, + ResumeModelSettings::PreserveExistingThread, + ) + .await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + while events.try_recv().is_ok() {} + let mut tui = crate::tui::test_support::make_test_tui()?; + enable_side_retention(&mut app)?; + app.local_settings.tui.animations = false; + tui.set_owned_screen(/*owned*/ true)?; + + for key in [ + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + KeyCode::Char('t').into(), + ] { + boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))).await?; + } + let size = Size::new(/*width*/ 208, /*height*/ 12); + let loading = side_frame(&mut app, &mut tui, size)?; + let (_, loading) = loading + .lines() + .find_map(|line| { + line.split_once('│') + .filter(|(_, pane)| pane.contains("Loading side chats")) + }) + .expect("pending side-chat discovery"); + insta::assert_snapshot!(loading.trim(), @"Loading side chats…"); + loop { + let event = time::timeout(Duration::from_secs(5), events.recv()) + .await? + .expect("event stream"); + let loaded = matches!(event, AppEvent::SavedSideChatsLoaded { .. }); + boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?; + if loaded { + break; + } + } + let frame = side_frame(&mut app, &mut tui, size)?; + let picker = frame + .lines() + .filter_map(|line| line.split_once('│').map(|(_, pane)| pane.trim_end())) + .skip_while(|line| !line.contains("Open in sidebar")) + .take_while(|line| !line.contains(" open · ")) + .collect::>() + .join("\n"); + insta::assert_snapshot!(picker.trim_end(), @" + Open in sidebar + + + New side chat Start from the main chat + + Side chats 2 + › ○ Side chat 1 + ○ Child-only prompt + "); + app.local_settings.tui.keymap = toml::from_str("[list]\naccept = ['f5 g']")?; + enable_side_retention(&mut app)?; + for key in [KeyCode::Char('j'), KeyCode::F(5), KeyCode::Char('g')] { + boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key.into()))) + .await?; + } + assert_eq!(app.current_displayed_thread_id(), Some(child)); + let tabs = app.sidebar().unwrap().chat_tabs(); + assert_eq!( + [tabs.choice(/*index*/ 1), tabs.choice(/*index*/ 2)], + [Some(children[0]), Some(child)] + ); + assert_eq!(app.active_side_parent_thread_id(), Some(parent)); + let channel = &app.thread_event_channels[&child]; + assert_eq!(channel.attachment(), ThreadEventAttachment::Live); + { + let store = channel.store.lock().await; + let [restored] = store.turns.as_slice() else { + panic!("expected only the completed child turn"); + }; + assert_eq!(restored.items, expected_child_turn.items); + } + drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?; + let release = KeyEvent::new_with_kind( + KeyCode::Char('g'), + KeyModifiers::NONE, + KeyEventKind::Release, + ); + boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(release))).await?; + app.chat_widget.insert_str("/rename New name"); + boxed_future!(app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Key(KeyCode::Enter.into()) + )) + .await?; + drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?; + // The name notification still updates the tab after focus returns to the parent. + app.select_agent_thread(&mut tui, &mut server, parent) + .await?; + drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?; + loop { + let event = time::timeout(Duration::from_secs(/*secs*/ 5), server.next_event()) + .await? + .expect("app-server event stream"); + let renamed = matches!( + &event.event, + AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::ThreadNameUpdated(update) + if update.thread_id == child_id && update.thread_name.as_deref() == Some("New name")) + ); + app.handle_app_server_event(&server, event.event).await; + if renamed { + break; + } + } + let frame = side_frame(&mut app, &mut tui, size)?; + assert!( + frame.lines().next().unwrap().contains("New name"), + "{frame}" + ); + let saved = server + .list_saved_side_chats(parent, app.config.codex_home.as_path()) + .await?; + assert_eq!( + saved + .iter() + .find(|chat| chat.id == child) + .and_then(|chat| chat.name.as_deref()), + Some("New name") + ); + app.select_agent_thread(&mut tui, &mut server, child) + .await?; + drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?; + let archive = KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL); + boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(archive))).await?; + let frame = side_frame(&mut app, &mut tui, size)?; + let prompt = frame + .lines() + .filter_map(|line| line.split_once('│')) + .map(|(_, pane)| pane.trim()) + .find(|line| line.contains("remove it")) + .unwrap_or_else(|| panic!("expected the archive confirmation in {frame}")); + insta::assert_snapshot!(prompt, @"This will remove it from Side chats. Archive “New name”?"); + let enter = TuiEvent::Key(KeyCode::Enter.into()); + boxed_future!(app.handle_tui_event(&mut tui, &mut server, enter)).await?; + assert_eq!(app.current_displayed_thread_id(), Some(child)); + let rollout = server + .thread_read(child, /*include_turns*/ false) + .await? + .path + .expect("saved child"); + let destination = app + .config + .codex_home + .join("archived_sessions") + .join(rollout.file_name().unwrap()); + std::fs::create_dir_all(&destination)?; + for blocked in [true, false] { + boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(archive))).await?; + side_frame(&mut app, &mut tui, size)?; + for key in [KeyCode::Right.into(), KeyCode::Enter.into()] { + boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))).await?; + } + loop { + let event = time::timeout(Duration::from_secs(/*secs*/ 5), events.recv()) + .await? + .expect("archive result"); + let finished = matches!(&event, AppEvent::SideChatRemovalFinished { target, .. } if *target == child); + boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?; + if finished { + break; + } + } + assert_eq!(app.current_displayed_thread_id(), Some(parent)); + if blocked { + assert_eq!( + app.thread_event_channels[&child].attachment(), + ThreadEventAttachment::ReplayOnly + ); + std::fs::remove_dir(&destination)?; + app.select_agent_thread(&mut tui, &mut server, child) + .await?; + assert_eq!( + app.thread_event_channels[&child].attachment(), + ThreadEventAttachment::Live + ); + side_chat_policy_tests::ask(&mut server, &model, child, "After failed archive").await?; + } + } + let saved = server + .request_view() + .list_saved_side_chats(parent, app.config.codex_home.as_path()) + .await?; + for chats in [app.sidebar().unwrap().chat_tabs().choices, saved.as_slice()] { + assert_eq!( + chats.iter().map(|chat| chat.id).collect::>(), + [children[0]] + ); + } + // An archive performed outside this TUI also removes a saved, closed tab from the chooser. + server.request_view().thread_archive(children[0]).await?; + loop { + let event = time::timeout(Duration::from_secs(/*secs*/ 5), server.next_event()) + .await? + .expect("server event"); + let archived = matches!(&event.event, AppServerEvent::ServerNotification(n) + if matches!(n.as_ref(), ServerNotification::ThreadArchived(a) if a.thread_id == children[0].to_string())); + app.handle_app_server_event(&server, event.event).await; + if archived { + break; + } + } + drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?; + assert!(app.sidebar().unwrap().chat_tabs().choices.is_empty()); + + let other_attachment: codex_app_server_protocol::ThreadForkResponse = server + .request_handle() + .request_typed(ClientRequest::ThreadFork { + request_id: server.next_request_id(), + params: codex_app_server_protocol::ThreadForkParams { + thread_id: parent.to_string(), + attach_to_source: true, + exclude_turns: true, + ..Default::default() + }, + }) + .await?; + app.delete_current_thread(&mut tui, &mut server).await?; + let errors = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 160))) + } + _ => None, + }) + .collect::>(); + assert!( + errors + .iter() + .any(|message| message.contains("an attachment outside the TUI sidebar")), + "{errors:?}" + ); + let ids = [parent, incomplete, children[0], children[1]]; + for id in ids { + server.thread_read(id, /*include_turns*/ false).await?; + } + + server + .thread_delete(ThreadId::from_string(&other_attachment.thread.id)?) + .await?; + app.delete_current_thread(&mut tui, &mut server).await?; + for id in ids { + let error = server + .thread_read(id, /*include_turns*/ false) + .await + .expect_err("parent deletion removes the saved side chat"); + assert!( + format!("{error:#}").contains(&format!("thread not loaded: {id}")), + "{error:#}" + ); + } + server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()> { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; @@ -4507,11 +4857,10 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() // Open a sibling from the chooser, then revisit each retained conversation. let chooser_key = KeyEvent::from(KeyCode::Char('t')); - let [up, down, enter] = - [KeyCode::Char('k'), KeyCode::Char('j'), KeyCode::Enter].map(KeyEvent::from); + let [up, enter] = [KeyCode::Char('k'), KeyCode::Enter].map(KeyEvent::from); side_frame(&mut app, &mut tui, even)?; let sidebar_x = app.conversation_frame.chat_strip.unwrap().x; - keys!(prefix, chooser_key, down, enter); + keys!(prefix, chooser_key, up, enter); app.local_settings.tui.keymap = toml::from_str("[global]\nleader = 'f12'")?; enable_side_retention(&mut app)?; app.local_settings.tui.animations = false; @@ -4526,6 +4875,32 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; read_side_history(&mut app, &mut tui, "sibling")?; app.chat_widget.insert_str("sibling draft"); + for (thread, prompt) in [ + (side_thread_id, "Investigate rendering"), + (sibling, "Fix cache"), + (side_thread_id, "Later prompt"), + ] { + app.enqueue_thread_notification( + thread, + ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification::new( + thread.to_string(), + "side-turn".to_string(), + ThreadItemPayload::UserMessage { + id: prompt.to_string(), + client_id: None, + content: vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + }, + /*started_at_ms*/ None, + /*completed_at_ms*/ 0, + ), + ), + ) + .await?; + } let frame = side_frame(&mut app, &mut tui, even)?; let header = frame.lines().next().unwrap().split_once('│').unwrap().1; assert_app_snapshot!("side_chat_tabs", header.trim_end()); @@ -4650,6 +5025,120 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert!(app.conversation_frame.chooser_area().is_some()); assert_eq!(divider_at(&tui, 4), target); + // Closing from the chooser must not switch to the tab before confirmation. + let chooser_focus = app.chooser_focus(); + let chooser = app.conversation_frame.chooser_area().unwrap(); + let close_x = (chooser.x..chooser.right()) + .rfind(|&x| tui.terminal.previous_buffer()[(x, chooser.y)].symbol() == "×") + .expect("close button on the second tab"); + let click_close = mouse(down, close_x, chooser.y); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, click_close)).await?; + let frame = side_frame(&mut app, &mut tui, even)?; + let dialog = frame + .lines() + .filter_map(|row| row.split_once('│').map(|(_, s)| s.trim_end())) + .take(7) + .collect::>() + .join("\n"); + assert_app_snapshot!("close_side_chat_tab", dialog.trim_end()); + keys!(KeyCode::Esc.into()); + assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id)); + assert_eq!(app.chooser_focus(), chooser_focus); + side_frame(&mut app, &mut tui, even)?; + boxed_future!(app.handle_tui_event( + &mut tui, + &mut app_server, + mouse(down, close_x - 1, chooser.y) + )) + .await?; + drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "sibling draft" + ); + // An idle, saved tab with only a queued follow-up still needs confirmation. + app.side_threads.get_mut(&sibling).unwrap().storage = + crate::app_server_session::SideChatStorage::Attached; + app.chat_widget.apply_external_edit(String::new()); + app.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ true); + assert!(app.chat_widget.queue_user_message_with_options( + "queued follow-up".into(), + crate::bottom_pane::QueuedInputAction::Plain, + Vec::new(), + )); + keys!(prefix, chooser_key); + side_frame(&mut app, &mut tui, even)?; + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, mouse(down, close_x, chooser.y))) + .await?; + assert!(side_frame(&mut app, &mut tui, even)?.contains("discard unsent input")); + keys!(KeyCode::Esc.into()); + app.select_agent_thread(&mut tui, &mut app_server, sibling) + .await?; + keys!(prefix, KeyCode::Char('w').into()); + side_frame(&mut app, &mut tui, even)?; + keys!(KeyCode::Right.into(), enter); + while app.side_threads.contains_key(&sibling) { + let event = time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) + .await? + .expect("app event"); + boxed_future!(app.handle_event(&mut tui, &mut app_server, event)).await?; + } + assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id)); + + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn side_close_key_cannot_accept_a_replacing_approval() -> Result<()> { + let mut app = make_test_app().await; + enable_side_retention(&mut app)?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.set_owned_screen(/*owned*/ true)?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let parent = ThreadId::new(); + let side_thread_id = ThreadId::new(); + app.primary_thread_id = Some(parent); + app.active_thread_id = Some(side_thread_id); + app.side_threads + .insert(side_thread_id, SideThreadState::new(parent)); + app.sidebar_mut().show( + sidebar::SidebarContent::Chat(side_thread_id), + sidebar::PaneFocus::Sidebar, + ); + sidebar_close::request( + &mut app, + &mut tui, + &mut app_server, + side_thread_id, + sidebar_close::Action::CloseTab, + ) + .await?; + let even = Size::new(/*width*/ 180, /*height*/ 24); + let enter = KeyEvent::from(KeyCode::Enter); + side_frame(&mut app, &mut tui, even)?; + assert!(app.sidebar().unwrap().close.is_some()); + let request = exec_approval_request(side_thread_id, "turn", "call", /*approval_id*/ None); + let Some(ThreadInteractiveRequest::Approval(approval)) = app + .interactive_request_for_thread_request(side_thread_id, &request) + .await? + else { + panic!("command approval"); + }; + app.chat_widget.push_approval_request(approval); + assert!( + app.chat_widget.has_active_modal(), + "approval is visible to the input router" + ); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(enter))).await?; + assert!(app.chat_widget.has_active_modal()); + side_frame(&mut app, &mut tui, even)?; + assert!(app.sidebar().unwrap().close.is_none()); + let repeat = KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(repeat))).await?; + assert!(app.chat_widget.has_active_modal()); + app_server.shutdown().await?; Ok(()) } @@ -6631,7 +7120,8 @@ async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() { let original_approval_policy = app.config.permissions.approval_policy.value(); let original_sandbox_policy = app.config.legacy_sandbox_policy(); - let fork_config = app.side_fork_config(); + let mut fork_config = app.side_fork_config(); + crate::app_server_session::SideChatStorage::Temporary.configure_fork(&mut fork_config); assert!(fork_config.ephemeral); assert_eq!( diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 2e75cb5ae7..6ba5d7d41a 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -18,6 +18,7 @@ use crate::keymap::KeymapContext; use crate::pager_overlay::TranscriptHistoryState; use crate::test_support::test_path_display; use crate::tui::Tui; +use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ToolRequestUserInputOption; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputQuestion; @@ -1239,6 +1240,7 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> super::enable_side_retention(&mut app)?; tui.set_owned_screen(/*owned*/ true)?; let parent = ThreadId::new(); + server.remember_thread_history_mode(parent, ThreadHistoryMode::Legacy); 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()]); diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index e40950562c..81efcc263f 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -364,7 +364,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv while app.agents_overview.request_id.is_some() { let event = events.recv().await.expect("inventory refresh event"); if matches!(event, AppEvent::AgentsOverviewThreadsLoaded { .. }) { - app.handle_event(&mut tui, &mut session, event).await?; + Box::pin(app.handle_event(&mut tui, &mut session, event)).await?; } else { deferred.push(event); } @@ -498,7 +498,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv app.chat_widget.composer_text_with_pending(), "latest draft!" ); - app.handle_event(&mut tui, &mut session, AppEvent::OpenAgentsOverview) + Box::pin(app.handle_event(&mut tui, &mut session, AppEvent::OpenAgentsOverview)) .await?; assert!(app.chat_widget.has_active_view()); app.handle_tui_event( @@ -545,11 +545,11 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv ); } available.store(true, std::sync::atomic::Ordering::SeqCst); - app.handle_event( + Box::pin(app.handle_event( &mut tui, &mut session, AppEvent::SelectAgentsOverviewThread { thread_id: id }, - ) + )) .await?; assert!(!app.thread_unavailable(id)); assert_eq!( @@ -595,7 +595,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv .await?; while let Ok(event) = events.try_recv() { if matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })) { - app.handle_event(&mut tui, &mut session, event).await?; + Box::pin(app.handle_event(&mut tui, &mut session, event)).await?; } } let channel = app.thread_event_channels.get(&id).unwrap(); diff --git a/codex-rs/tui/src/app/tests/permission_selection_tests.rs b/codex-rs/tui/src/app/tests/permission_selection_tests.rs index 8258a15498..725ee66cf4 100644 --- a/codex-rs/tui/src/app/tests/permission_selection_tests.rs +++ b/codex-rs/tui/src/app/tests/permission_selection_tests.rs @@ -424,6 +424,7 @@ async fn custom_permission_selection_scenario() -> Result<()> { app.agents_overview .requested_permission_profiles .get(&thread_id), + crate::app_server_session::SideChatStorage::Temporary, ), ) .await?; diff --git a/codex-rs/tui/src/app/tests/safety_buffering.rs b/codex-rs/tui/src/app/tests/safety_buffering.rs index e13349f680..62f376ddda 100644 --- a/codex-rs/tui/src/app/tests/safety_buffering.rs +++ b/codex-rs/tui/src/app/tests/safety_buffering.rs @@ -140,7 +140,7 @@ async fn next_turn_started( } } -async fn wait_for_turn_completed( +pub(super) async fn wait_for_turn_completed( app: &mut App, app_server: &mut AppServerSession, thread_id: ThreadId, diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 18ec212684..d8c96a4942 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1635,6 +1635,7 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> { side_config.clone(), thread_id, /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await?; let side_id = side.session.thread_id; @@ -1651,7 +1652,8 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> { &app.local_settings, side_config, thread_id, - /*selected_profile*/ None + /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await .is_err() @@ -1946,6 +1948,7 @@ async fn archive_current_thread_returns_to_agents() -> Result<()> { side_config, thread_id, /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await? .session @@ -3901,6 +3904,7 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { side_config, paginated_thread_id, /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, )) .await?; @@ -4873,8 +4877,24 @@ terminal_visualization_instructions = true Ok(()) } -#[tokio::test] -async fn changing_directory_preserves_project_trust_permissions_history_and_hooks() -> Result<()> { +#[test] +fn changing_directory_preserves_project_trust_permissions_history_and_hooks() -> Result<()> { + // The TUI and embedded app-server load full configurations on the same test thread. + const TEST_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; + std::thread::Builder::new() + .name("tui-change-directory".into()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(boxed_future!(change_directory_scenario())) + })? + .join() + .expect("directory change test thread panicked") +} + +async fn change_directory_scenario() -> Result<()> { use codex_protocol::config_types::TrustLevel as T; use serde_json::json; use std::fs; diff --git a/codex-rs/tui/src/app/tests/side_chat_policy_tests.rs b/codex-rs/tui/src/app/tests/side_chat_policy_tests.rs index 0c1335dcde..5a83488083 100644 --- a/codex-rs/tui/src/app/tests/side_chat_policy_tests.rs +++ b/codex-rs/tui/src/app/tests/side_chat_policy_tests.rs @@ -11,7 +11,7 @@ use codex_app_server_protocol::TurnStartResponse; use core_test_support::responses; /// Captures one real model request and waits until its turn can be inherited by a fork. -async fn ask( +pub(super) async fn ask( server: &mut AppServerSession, model: &wiremock::MockServer, thread: ThreadId, @@ -77,6 +77,7 @@ async fn temporary_side_chat_sends_policy_on_its_first_request() -> Result<()> { app.side_fork_config(), parent, /*profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await?; fork.boundary.expect("side-chat context injection"); diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 2ef214d059..ccd5d3f1b8 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1074,6 +1074,7 @@ impl App { if self.abandoned_side_threads.contains(&thread_id) { return Ok(()); } + self.track_side_chat_title(thread_id, ¬ification); if self.current_displayed_thread_id() == Some(thread_id) && let ServerNotification::TurnCompleted(notification) = ¬ification { @@ -2146,7 +2147,9 @@ impl App { // thread, so unrelated shutdowns cannot consume this marker. self.pending_shutdown_exit_thread_id = None; } + // Sidebar chats use their first prompt until they receive an explicit name. let automatic_title_user_message = if self.chat_widget.thread_name().is_none() + && !(self.side_retention_enabled(tui) && self.active_side_parent_thread_id().is_some()) && let ThreadBufferedEvent::Notification(notification) = &event && let ServerNotification::ItemCompleted(notification) = notification.as_ref() && let ThreadItemPayload::UserMessage { content, .. } = ¬ification.item.payload diff --git a/codex-rs/tui/src/app/workspace_hud.rs b/codex-rs/tui/src/app/workspace_hud.rs index af35982665..7b4d27abfa 100644 --- a/codex-rs/tui/src/app/workspace_hud.rs +++ b/codex-rs/tui/src/app/workspace_hud.rs @@ -61,6 +61,10 @@ impl App { "toggle_pane_zoom" if self.zoomed_thread.is_some() => (2, "Restore layout"), "toggle_pane_zoom" => (2, "Zoom pane"), "move_divider_left" | "move_divider_right" => continue, + "close_sidebar_tab" if self.active_side_parent_thread_id().is_some() => { + (2, "Close tab") + } + "close_sidebar_tab" => continue, "hide_pane" if side => (2, "Hide pane"), "focus_pane_left" if side && can_switch => (1, "Pane left"), "focus_pane_right" if !side && can_switch && self.open_pane_focus().is_some() => { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index dde6190017..07f509dfb1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -466,7 +466,7 @@ pub(crate) enum AppEvent { /// Switch the active thread to the selected agent. SelectAgentThread(ThreadId), - /// Open a transient side conversation for the current thread. + /// Open a side conversation for the current thread. StartSide { parent_thread_id: ThreadId, user_message: Option, @@ -480,6 +480,25 @@ pub(crate) enum AppEvent { result: Result, String>, }, + SideChatRemovalFinished { + backend_id: crate::backend_id::BackendId, + target: ThreadId, + request: uuid::Uuid, + action: SideChatRemoval, + result: Result<(), String>, + }, + SideChatArchived { + backend_id: crate::backend_id::BackendId, + target: ThreadId, + }, + + SavedSideChatsLoaded { + 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, @@ -1949,3 +1968,10 @@ pub(crate) enum SideStartMode { Reuse, New, } + +/// Whether a sidebar action closes a live tab or also archives its saved conversation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SideChatRemoval { + CloseTab, + Archive, +} diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 37f42d3057..0bb59cda7a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -20,6 +20,8 @@ mod read_state; mod realtime; mod rollout_history; pub(crate) use read_state::unread_revision; +mod side_chat_history; +use side_chat_history::side_context_checkpoint; mod startup_launch; mod thread_list; pub(crate) mod user_message_submission; @@ -35,6 +37,8 @@ pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT; pub(crate) use history::HistoryHydrationScope; pub(crate) use history::INITIAL_HISTORY_TURN_LIMIT; pub(crate) use history::thread_items_page_params; +pub(crate) use side_chat_history::SideChat; +pub(crate) use side_chat_history::side_chat_prompt_preview; pub(crate) use startup_launch::StartupLaunchChoices; use crate::app_event::PermissionProfileSelection; @@ -202,7 +206,32 @@ enum EmptyCatalogFallback { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ForkPresentation { Regular, - SideConversation, + SideConversation(SideChatStorage), +} + +pub(crate) const ATTACHED_SIDE_CHAT_SOURCE: &str = "tui_side_chat"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SideChatStorage { + Temporary, + Attached, +} + +impl SideChatStorage { + fn policy(self) -> SideConversationPolicy { + match self { + Self::Temporary => SideConversationPolicy::Temporary, + Self::Attached => SideConversationPolicy::Attached, + } + } + + pub(crate) fn configure_fork(self, config: &mut Config) { + config.ephemeral = self == Self::Temporary; + config.developer_instructions = Some( + self.policy() + .instructions(config.developer_instructions.as_deref()), + ); + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -427,6 +456,8 @@ pub(crate) enum ResumeModelSettings { RestoreFromThread, /// Rejoins a loaded thread without changing any of its existing settings. PreserveExistingThread, + /// Restore the saved settings and reapply the side-chat instructions on cold resume. + PreserveSideConversation, } impl ThreadParamsMode { @@ -452,6 +483,7 @@ pub(crate) struct SidebarFork { pub(crate) thread: AppServerStartedThread, pub(crate) boundary: Result<(), String>, pub(crate) inherited_profile: bool, + pub(crate) storage: SideChatStorage, legacy: bool, } @@ -1216,6 +1248,7 @@ impl AppServerSession { config: Config, parent: ThreadId, profile: Option, + preferred_storage: SideChatStorage, ) -> impl Future> + Send + use<> { let mut view = self .request_view() @@ -1224,21 +1257,58 @@ impl AppServerSession { 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, - SideConversationPolicy::Temporary.fork_items(), - ) - .await - .map(|_| ()) - .map_err(|err| err.to_string()); + let mut storage = SideChatStorage::Temporary; + if preferred_storage == SideChatStorage::Attached + && view.attached_side_chat_query(parent).await?.is_some() + && { + let thread = view.thread_read(parent, /*include_turns*/ false).await?; + !thread.ephemeral && thread.history_mode == ThreadHistoryMode::Paginated + } + { + storage = SideChatStorage::Attached; + } + let fork = view + .fork_side_thread(&settings, config.clone(), parent, profile.as_ref(), storage) + .await; + let thread = match fork { + Err(err) + if storage == SideChatStorage::Attached + && side_chat_history::is_attached_thread_store_unsupported(&err) => + { + storage = SideChatStorage::Temporary; + view.fork_side_thread(&settings, config, parent, profile.as_ref(), storage) + .await? + } + result => result?, + }; + let child = thread.session.thread_id; + let items = storage.policy().fork_items(); + let boundary = match view.thread_inject_items(child, items).await { + Ok(_) if storage == SideChatStorage::Attached => { + match side_context_checkpoint(thread.session.rollout_path.as_deref()).await { + Ok(Some(_)) => Ok(()), + Ok(None) => Err("Side conversation context was not saved".to_string()), + Err(err) => Err(err.to_string()), + } + } + result => result.map(|_| ()).map_err(|err| err.to_string()), + }; + if let Err(message) = &boundary + && storage == SideChatStorage::Attached + { + if let Err(error) = view.thread_delete(child).await { + tracing::warn!(%child, %error, "failed to roll back attached side chat"); + } else { + color_eyre::eyre::bail!( + "Failed to prepare side conversation {child}: {message}" + ); + } + } Ok(SidebarFork { thread, boundary, inherited_profile: profile.is_some(), + storage, legacy: view.history_support == ThreadHistorySupport::LegacyOnly, }) } @@ -1256,10 +1326,12 @@ impl AppServerSession { pub(crate) async fn fork_side_thread( &mut self, local_settings: &LocalSettings, - config: Config, + mut config: Config, thread_id: ThreadId, selected_profile: Option<&PermissionProfileSelection>, + storage: SideChatStorage, ) -> Result { + storage.configure_fork(&mut config); self.fork_thread_at_with_presentation( local_settings, config, @@ -1267,7 +1339,7 @@ impl AppServerSession { /*last_turn_id*/ None, /*before_turn_id*/ None, ForkGoalContinuation::StartIfIdle, - ForkPresentation::SideConversation, + ForkPresentation::SideConversation(storage), selected_profile, ForkPermissionMode::InheritSaved, ForkConfigSource::Session, @@ -1301,13 +1373,13 @@ impl AppServerSession { .thread_read(thread_id, /*include_turns*/ false) .await .ok(), - ForkPresentation::SideConversation => None, + ForkPresentation::SideConversation(_) => None, }; let exclude_turns = self.history_support == ThreadHistorySupport::Paginated && (fork_parent .as_ref() .is_some_and(|thread| thread.history_mode == ThreadHistoryMode::Paginated) - || presentation == ForkPresentation::SideConversation); + || presentation != ForkPresentation::Regular); let request_id = self.next_request_id(); let session_config = if config.model.is_none() { // Avoid inferring a tier from the stale client default model. @@ -1316,6 +1388,8 @@ impl AppServerSession { self.session_config_with_effective_service_tier(&config) }; let mut params = ThreadForkParams { + attach_to_source: presentation + == ForkPresentation::SideConversation(SideChatStorage::Attached), last_turn_id, before_turn_id, defer_goal_continuation: goal_continuation == ForkGoalContinuation::DeferUntilNextTurn, @@ -1327,6 +1401,10 @@ impl AppServerSession { self.remote_cwd_override.as_deref(), ) }; + if params.attach_to_source { + params.thread_source = + Some(ThreadSource::Feature(ATTACHED_SIDE_CHAT_SOURCE.to_string())); + } params.model_provider = match config_source { ForkConfigSource::Local => self .model_provider_override @@ -1374,7 +1452,9 @@ impl AppServerSession { { Ok(response) => response, Err(TypedRequestError::Server { source, .. }) - if params.exclude_turns && is_history_pagination_unsupported(&source) => + if params.exclude_turns + && !params.attach_to_source + && is_history_pagination_unsupported(&source) => { self.history_support = ThreadHistorySupport::LegacyOnly; params.exclude_turns = false; @@ -1430,7 +1510,7 @@ impl AppServerSession { if let Some(permissions) = selected_permissions { started.session.permission_profile = permissions; } - if presentation == ForkPresentation::SideConversation { + if presentation != ForkPresentation::Regular { started.session.daybreak_enabled = false; } started.session.fork_parent_title = fork_parent.and_then(|thread| thread.name); @@ -2569,10 +2649,24 @@ fn thread_resume_params_from_config( permission_overrides: crate::resume_permissions::ResumePermissions, ) -> ThreadResumeParams { if thread_params_mode.settings_owner() == ThreadSettingsOwner::Server - || model_settings == ResumeModelSettings::PreserveExistingThread + || matches!( + model_settings, + ResumeModelSettings::PreserveExistingThread + | ResumeModelSettings::PreserveSideConversation + ) { return ThreadResumeParams { thread_id: thread_id.to_string(), + developer_instructions: if model_settings + == ResumeModelSettings::PreserveSideConversation + { + with_terminal_visualization_instructions( + &config, + config.developer_instructions.clone(), + ) + } else { + None + }, ..ThreadResumeParams::default() }; } @@ -2600,9 +2694,9 @@ fn thread_resume_params_from_config( config.model.clone(), provider_selection::explicit_provider(&config), ), - ResumeModelSettings::RestoreFromThread | ResumeModelSettings::PreserveExistingThread => { - (None, None) - } + ResumeModelSettings::RestoreFromThread + | ResumeModelSettings::PreserveExistingThread + | ResumeModelSettings::PreserveSideConversation => (None, None), }; let mut params = ThreadResumeParams { thread_id: thread_id.to_string(), @@ -4476,6 +4570,7 @@ mod tests { ephemeral_config, source_thread_id, /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await?; @@ -4603,6 +4698,7 @@ mod tests { side_config, source_thread_id, /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await?; @@ -4706,6 +4802,7 @@ mod tests { config, thread_id, /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await?; diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index b072d8f907..d55c7a6583 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -162,7 +162,9 @@ impl AppServerSession { } let session_config = if matches!( model_settings, - ResumeModelSettings::RestoreFromThread | ResumeModelSettings::PreserveExistingThread + ResumeModelSettings::RestoreFromThread + | ResumeModelSettings::PreserveExistingThread + | ResumeModelSettings::PreserveSideConversation ) { config.clone() } else { diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs index 71a652ad23..ee3ea649a0 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -164,6 +164,7 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override client_config, thread_id, /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await?; assert_eq!( diff --git a/codex-rs/tui/src/app_server_session/side_chat_history.rs b/codex-rs/tui/src/app_server_session/side_chat_history.rs new file mode 100644 index 0000000000..a7d44a82fd --- /dev/null +++ b/codex-rs/tui/src/app_server_session/side_chat_history.rs @@ -0,0 +1,328 @@ +//! Discovers local side chats from their own rollouts and reads their visible labels. + +use super::*; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadItemPayload; +use codex_app_server_protocol::ThreadItemsListResponse; +use codex_context_fragments::ContextualUserFragment; +use codex_context_fragments::SIDE_BOUNDARY_TITLE; +use codex_context_fragments::SideConversationPolicy; +use codex_protocol::models::ContentItem; +use codex_rollout::ResponseItemEnvelope; +use codex_rollout::RolloutItem; +use codex_rollout::read_session_meta_line; +use std::io; +use std::path::Path; + +pub(super) async fn side_context_checkpoint(path: Option<&Path>) -> io::Result> { + let Some(path) = path else { + return Ok(None); + }; + let mut reader = codex_rollout::open_rollout_line_reader(path).await?; + // The two bootstrap items are written before the first child turn. Inspect + // only the child's own records, even when its parent has side context. + let mut has_policy = false; + while let Some(line) = reader.next_line().await? { + let record = codex_rollout::parse_rollout_line(&line)?; + match record.item { + RolloutItem::SessionMeta(meta) if !meta.meta.attached_thread => return Ok(None), + RolloutItem::ResponseItem(ResponseItemEnvelope { + item: ResponseItem::Message { role, content, .. }, + .. + }) => { + let [ContentItem::InputText { text }] = content.as_slice() else { + continue; + }; + match role.as_str() { + "developer" => has_policy |= SideConversationPolicy::matches_text(text), + "user" if has_policy && text.lines().next() == Some(SIDE_BOUNDARY_TITLE) => { + return Ok(Some(record.timestamp)); + } + _ => {} + } + } + RolloutItem::EventMsg(codex_protocol::protocol::EventMsg::TurnStarted(_)) => break, + _ => {} + } + } + Ok(None) +} + +#[derive(Debug)] +pub(crate) struct SideChat { + pub(crate) id: ThreadId, + pub(crate) name: Option, + pub(crate) preview: String, +} + +impl From for SideChat { + fn from(id: ThreadId) -> Self { + Self { + id, + name: None, + preview: String::new(), + } + } +} + +pub(crate) fn side_chat_prompt_preview(content: &[UserInput]) -> Option<&str> { + if crate::chatwidget::realtime_delegation_input(content).is_some() { + return None; + } + content + .iter() + .find_map(|input| match input { + UserInput::Text { text, .. } => { + codex_protocol::protocol::strip_user_message_prefix(text) + .lines() + .next() + .map(str::trim) + } + _ => None, + }) + .or_else(|| { + content.iter().find_map(|input| match input { + UserInput::Image { .. } | UserInput::LocalImage { .. } => Some("[Image]"), + UserInput::Audio { .. } | UserInput::LocalAudio { .. } => Some("[Audio]"), + _ => None, + }) + }) +} + +pub(super) fn is_attached_thread_store_unsupported(err: &color_eyre::Report) -> bool { + matches!( + err.downcast_ref::(), + Some(TypedRequestError::Server { source, .. }) + if source.code == JSONRPC_METHOD_NOT_FOUND + && source.message == "attached threads is not supported yet" + ) +} + +impl AppServerSession { + pub(crate) fn can_list_saved_side_chats(&self, parent: ThreadId) -> bool { + !self.uses_remote_workspace() + && self.history_support == ThreadHistorySupport::Paginated + && self + .history_pagination + .get(&parent) + .is_none_or(|state| state.history_mode == ThreadHistoryMode::Paginated) + } + + pub(super) async fn attached_side_chat_query( + &mut self, + parent: ThreadId, + ) -> Result> { + if self.uses_remote_workspace() { + return Ok(None); + } + let query = ThreadListParams { + attached_to_thread_id: Some(parent.to_string()), + current_account_only: true, + archived: Some(false), + model_providers: Some(Vec::new()), + sort_direction: Some(SortDirection::Asc), + use_state_db_only: true, + cursor: None, + limit: None, + sort_key: None, + source_kinds: None, + section_id: None, + project_id: None, + cwd: None, + parent_thread_id: None, + ancestor_thread_id: None, + search_term: None, + originators: None, + excluded_thread_ids: None, + }; + if matches!(self.request_handle, AppServerRequestHandle::InProcess(_)) { + return Ok(Some(query)); + } + // Older local daemons can silently ignore unknown fields. Require the + // validation error for these incompatible filters before using them. + let probe = ThreadListParams { + parent_thread_id: query.attached_to_thread_id.clone(), + limit: Some(1), + ..query.clone() + }; + let Err(err) = self.thread_list(probe).await else { + return Ok(None); + }; + let Some(TypedRequestError::Server { source, .. }) = err.downcast_ref() else { + return Err(err); + }; + Ok((source.code == JSONRPC_INVALID_REQUEST + && source.message + == "attachedToThreadId cannot be combined with parentThreadId or ancestorThreadId") + .then_some(query)) + } + + pub(crate) async fn list_saved_side_chats( + &mut self, + parent: ThreadId, + codex_home: &Path, + ) -> Result> { + let Some(query) = self.attached_side_chat_query(parent).await? else { + return Ok(Vec::new()); + }; + let mut chats = Vec::new(); + let threads = self.list_attached_threads(query).await?; + let named_ids = threads + .iter() + .filter(|thread| thread.name.is_some()) + .filter_map(|thread| ThreadId::from_string(&thread.id).ok()) + .collect(); + let indexed_names = codex_rollout::find_thread_name_entries_by_ids(codex_home, &named_ids) + .await + .unwrap_or_else(|err| { + tracing::debug!(%err, "could not read side-chat name timestamps"); + Default::default() + }); + for thread in threads { + let Ok(id) = ThreadId::from_string(&thread.id) else { + continue; + }; + let checkpoint = match side_context_checkpoint(thread.path.as_deref()).await { + Ok(Some(timestamp)) => timestamp, + Ok(None) => continue, + Err(error) => { + tracing::warn!(%id, %error, "failed to read side chat context"); + continue; + } + }; + // Older servers write the inherited name during fork, before the TUI installs the + // side-chat boundary. A later name write is an explicit rename, even to the same text. + let name = thread.name.filter(|name| { + if let Some(entry) = indexed_names.get(&id) + && entry.thread_name == *name + && let Ok(written) = chrono::DateTime::parse_from_rfc3339(&entry.updated_at) + && let Ok(opened) = chrono::DateTime::parse_from_rfc3339(&checkpoint) + { + return written > opened; + } + true + }); + let preview = if name.is_none() { + self.first_side_chat_prompt(id) + .await + .unwrap_or_else(|error| { + tracing::debug!(%id, %error, "failed to load side-chat prompt"); + String::new() + }) + } else { + String::new() + }; + chats.push(SideChat { id, name, preview }); + } + Ok(chats) + } + + /// Delete the parent's TUI side chats before deleting the parent itself. + pub(crate) async fn delete_thread_with_saved_side_chats( + &mut self, + parent: ThreadId, + ) -> Result<()> { + if self.can_list_saved_side_chats(parent) + && let Some(mut query) = self.attached_side_chat_query(parent).await? + { + // Resolve the complete list before deleting anything. Archived and partially + // initialized side chats also block the app-server's parent delete. + let mut attached = self.list_attached_threads(query.clone()).await?; + query.archived = Some(true); + attached.extend(self.list_attached_threads(query).await?); + let mut children = HashSet::new(); + for thread in attached { + let child = ThreadId::from_string(&thread.id)?; + let path = thread.path.as_deref().ok_or_else(|| { + color_eyre::eyre::eyre!("No rollout found for attached thread {child}") + })?; + // The thread list omits fork origins. Verify the child's own metadata + // before passing its ID to the delete API. + let meta = read_session_meta_line(path) + .await + .wrap_err_with(|| format!("failed to verify attached thread {child}"))? + .meta; + color_eyre::eyre::ensure!( + meta.id == child + && meta.attached_thread + && meta.forked_from_id == Some(parent) + && matches!( + meta.thread_source.as_ref(), + Some(codex_protocol::protocol::ThreadSource::Feature(source)) + if source == ATTACHED_SIDE_CHAT_SOURCE + ), + "The conversation has an attachment outside the TUI sidebar. Remove it before deleting the conversation." + ); + children.insert(child); + } + for child in children { + self.thread_delete(child) + .await + .wrap_err_with(|| format!("failed to delete saved side chat {child}"))?; + } + } + self.thread_delete(parent).await + } + + async fn list_attached_threads(&mut self, mut query: ThreadListParams) -> Result> { + let mut threads = Vec::new(); + let mut seen = HashSet::new(); + let mut cursors = HashSet::new(); + loop { + let page = match self.thread_list(query.clone()).await { + Err(err) if is_attached_thread_store_unsupported(&err) => return Ok(Vec::new()), + result => result?, + }; + threads.extend( + page.data + .into_iter() + .filter(|thread| seen.insert(thread.id.clone())), + ); + let Some(cursor) = page.next_cursor else { + return Ok(threads); + }; + color_eyre::eyre::ensure!( + cursors.insert(cursor.clone()), + "Repeated side chat list cursor" + ); + query.cursor = Some(cursor); + } + } + + async fn first_side_chat_prompt(&mut self, id: ThreadId) -> Result { + // The summary preview can come from inherited model context. Item pages only contain + // the child's visible history, including for chats saved by older clients. + let mut cursor = None; + let mut cursors = HashSet::new(); + loop { + let mut params = + thread_items_page_params(id, /*turn_id*/ None, cursor, /*limit*/ 16); + params.sort_direction = Some(SortDirection::Asc); + let request_id = self.next_request_id(); + let page: ThreadItemsListResponse = self + .request_handle + .request_typed(ClientRequest::ThreadItemsList { request_id, params }) + .await?; + if let Some(preview) = page + .data + .iter() + .find_map(|entry| match &entry.item.payload { + ThreadItemPayload::UserMessage { content, .. } => { + side_chat_prompt_preview(content) + } + _ => None, + }) + { + return Ok(preview.to_owned()); + } + let Some(next) = page.next_cursor else { + return Ok(String::new()); + }; + color_eyre::eyre::ensure!( + cursors.insert(next.clone()), + "Repeated side chat item cursor" + ); + cursor = Some(next); + } + } +} diff --git a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs index 56d56be83a..2b8005a7ed 100644 --- a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs +++ b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs @@ -169,6 +169,7 @@ stream_max_retries = 0 session_config, thread_id, /*selected_profile*/ None, + crate::app_server_session::SideChatStorage::Temporary, ) .await?; assert_eq!(side.session.runtime_workspace_roots, expected_roots); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index b32c316f59..9718304228 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -633,7 +633,7 @@ pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> { if index > 0 { line.push_span(" · ".set_style(secondary_text_style())); } - if let Some((keys, action)) = [" to switch", " to close", " for side"] + if let Some((keys, action)) = [" to switch", " to close", " to archive", " for side"] .into_iter() .find_map(|action| part.strip_suffix(action).map(|keys| (keys, action))) { diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 4c455ed8c1..b3d3b5e7c4 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -339,6 +339,7 @@ mod tests { commands, vec![ SlashCommand::Ide, + SlashCommand::Rename, SlashCommand::Agents, SlashCommand::Copy, SlashCommand::Export, diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 75543bc04b..ee1c21bd40 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -291,7 +291,8 @@ impl ChatWidget { self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Delete this session?".to_string()), subtitle: Some( - "Cannot be undone. Subagent threads will also be deleted.".to_string(), + "Also deletes subagents and saved side chats. Cannot be undone." + .to_string(), ), footer_hint: Some(standard_popup_hint_line()), items: vec![ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_command_center.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_command_center.snap index cd9e610ef6..62c33cd4fa 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_command_center.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_delete_confirmation_command_center.snap @@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests/slash_commands.rs expression: popup --- Delete this session? -› As Cannot be undone. Subagent threads will also be deleted. +› As Also deletes subagents and saved side chats. Cannot be undone. GP › 1. No, keep this session Return to the current diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 566668d8b4..d1acdfcc5d 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -322,6 +322,12 @@ impl ChatWidget { None } + pub(crate) fn has_unsent_input(&self) -> bool { + !self.composer_is_empty() + || self.pending_image_submission.is_some() + || self.has_queued_follow_up_messages() + } + pub(crate) fn has_queued_follow_up_messages(&self) -> bool { self.input_queue.has_queued_follow_up_messages() } diff --git a/codex-rs/tui/src/chatwidget/user_messages.rs b/codex-rs/tui/src/chatwidget/user_messages.rs index d11c9bbd2c..eaa2b70c79 100644 --- a/codex-rs/tui/src/chatwidget/user_messages.rs +++ b/codex-rs/tui/src/chatwidget/user_messages.rs @@ -893,6 +893,12 @@ impl ThreadInputState { ); } + pub(crate) fn has_unsent_input(&self) -> bool { + self.composer.is_some() + || !self.queued_user_messages.is_empty() + || !self.rejected_steers_queue.is_empty() + } + pub(crate) fn has_unconfirmed_messages(&self) -> bool { self.queued_user_messages .iter() diff --git a/codex-rs/tui/src/keymap.rs b/codex-rs/tui/src/keymap.rs index 289d48cffb..81ef969f50 100644 --- a/codex-rs/tui/src/keymap.rs +++ b/codex-rs/tui/src/keymap.rs @@ -108,6 +108,7 @@ pub(crate) struct AppKeymap { pub(crate) focus_sidebar_content: Vec, pub(crate) focus_sidebar_tabs: Vec, pub(crate) hide_pane: Vec, + pub(crate) close_sidebar_tab: Vec, pub(crate) focus_pane_left: Vec, pub(crate) focus_pane_right: Vec, pub(crate) minimize_pane_width: Vec, @@ -710,6 +711,7 @@ impl RuntimeKeymap { classic_keymap.global.focus_sidebar_content = None; classic_keymap.global.focus_sidebar_tabs = None; classic_keymap.global.hide_pane = None; + classic_keymap.global.close_sidebar_tab = None; classic_keymap.global.focus_pane_left = None; classic_keymap.global.focus_pane_right = None; &classic_keymap @@ -785,6 +787,11 @@ impl RuntimeKeymap { &[], "tui.keymap.global.hide_pane", )?, + close_sidebar_tab: resolve_bindings( + keymap.global.close_sidebar_tab.as_ref(), + &[], + "tui.keymap.global.close_sidebar_tab", + )?, focus_pane_left: resolve_bindings( keymap.global.focus_pane_left.as_ref(), &[], @@ -1809,6 +1816,7 @@ impl RuntimeKeymap { focus_sidebar_content: Vec::new(), focus_sidebar_tabs: Vec::new(), hide_pane: Vec::new(), + close_sidebar_tab: Vec::new(), focus_pane_left: Vec::new(), focus_pane_right: Vec::new(), minimize_pane_width: Vec::new(), @@ -2189,6 +2197,7 @@ impl RuntimeKeymap { ("global.move_divider_right", &self.app.move_divider_right), ("global.toggle_pane_zoom", &self.app.toggle_pane_zoom), ("global.hide_pane", &self.app.hide_pane), + ("global.close_sidebar_tab", &self.app.close_sidebar_tab), ("chat.open_presets", &self.chat.open_presets), ("chat.toggle_voice", &self.chat.toggle_voice), ] { @@ -2239,6 +2248,7 @@ impl RuntimeKeymap { ("move_divider_right", divider_right), ("toggle_pane_zoom", self.app.toggle_pane_zoom.as_slice()), ("hide_pane", self.app.hide_pane.as_slice()), + ("close_sidebar_tab", self.app.close_sidebar_tab.as_slice()), ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), @@ -2426,6 +2436,7 @@ impl RuntimeKeymap { ("move_divider_right", divider_right), ("toggle_pane_zoom", self.app.toggle_pane_zoom.as_slice()), ("hide_pane", self.app.hide_pane.as_slice()), + ("close_sidebar_tab", self.app.close_sidebar_tab.as_slice()), ("open_agents", self.app.open_agents.as_slice()), ("open_transcript", self.app.open_transcript.as_slice()), ("find_transcript", self.app.find_transcript.as_slice()), @@ -2582,6 +2593,10 @@ impl RuntimeKeymap { self.app.toggle_pane_zoom.as_slice(), ), ("global.hide_pane", self.app.hide_pane.as_slice()), + ( + "global.close_sidebar_tab", + self.app.close_sidebar_tab.as_slice(), + ), ( "global.select_transcript", self.app.select_transcript.as_slice(), @@ -2645,6 +2660,10 @@ impl RuntimeKeymap { self.app.toggle_pane_zoom.as_slice(), ), ("global.hide_pane", self.app.hide_pane.as_slice()), + ( + "global.close_sidebar_tab", + self.app.close_sidebar_tab.as_slice(), + ), ("global.focus_activity", self.app.focus_activity.as_slice()), ( "global.find_transcript", diff --git a/codex-rs/tui/src/keymap/bindings.rs b/codex-rs/tui/src/keymap/bindings.rs index 062a306b28..e52d9434a7 100644 --- a/codex-rs/tui/src/keymap/bindings.rs +++ b/codex-rs/tui/src/keymap/bindings.rs @@ -111,6 +111,7 @@ impl KeymapActionId { | "focus_sidebar_content" | "focus_sidebar_tabs" | "hide_pane" + | "close_sidebar_tab" | "focus_pane_left" | "focus_pane_right" | "minimize_pane_width" @@ -319,6 +320,7 @@ define_runtime_action_bindings! { focus_sidebar_content, focus_sidebar_tabs, hide_pane, + close_sidebar_tab, focus_pane_left, focus_pane_right, minimize_pane_width, diff --git a/codex-rs/tui/src/keymap/chords/leader_defaults.rs b/codex-rs/tui/src/keymap/chords/leader_defaults.rs index ec13899715..1e7831ce8c 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, "close_sidebar_tab", Char('w'), "w"), (Global, "focus_sidebar_tabs", Char('t'), "t"), ] { if remaining == 0 { diff --git a/codex-rs/tui/src/keymap_setup/actions.rs b/codex-rs/tui/src/keymap_setup/actions.rs index 3dc4732624..ef6d869610 100644 --- a/codex-rs/tui/src/keymap_setup/actions.rs +++ b/codex-rs/tui/src/keymap_setup/actions.rs @@ -122,6 +122,7 @@ define_keymap_actions! { 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.", + close_sidebar_tab [Workspace] => "Close the active sidebar tab.", focus_pane_left [Workspace] => "Focus the parent conversation.", focus_pane_right [Workspace] => "Focus the open side conversation.", minimize_pane_width [Workspace] => "Minimize the focused pane width.", diff --git a/codex-rs/tui/src/managed_worktree_tools.rs b/codex-rs/tui/src/managed_worktree_tools.rs index 87d6c9873b..9e2069f00c 100644 --- a/codex-rs/tui/src/managed_worktree_tools.rs +++ b/codex-rs/tui/src/managed_worktree_tools.rs @@ -2,6 +2,7 @@ //! Durable ownership and attachments survive the bounded, process-local operation registry. //! Creation does not change the caller's cwd or grant filesystem permissions. +use crate::app_server_session::ATTACHED_SIDE_CHAT_SOURCE; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use codex_app_server_client::AppServerRequestHandle; @@ -15,6 +16,7 @@ use codex_app_server_protocol::ThreadAttachmentListParams; use codex_app_server_protocol::ThreadAttachmentListResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadSource; use codex_worktree::CreateWorktree; use codex_worktree::WorktreeManager; use codex_worktree::WorktreeSettings; @@ -185,8 +187,13 @@ impl ManagedWorktreeTools { }) .await .map_err(|error| error.to_string())?; - if response.thread.ephemeral { - return Err("Ephemeral side conversations cannot retain worktree attachments. Request a new worktree in the main conversation.".to_owned()); + if response.thread.ephemeral + || matches!( + response.thread.thread_source.as_ref(), + Some(ThreadSource::Feature(source)) if source == ATTACHED_SIDE_CHAT_SOURCE + ) + { + return Err("Side conversations cannot retain worktree attachments. Request a new worktree in the main conversation.".to_owned()); } // Verify capability before allocating a checkout, including SQLite availability. attachments(&handle, &thread_id, /*cursor*/ None).await?; diff --git a/codex-rs/tui/src/managed_worktree_tools_tests.rs b/codex-rs/tui/src/managed_worktree_tools_tests.rs index bdcaeca323..fc328f5a69 100644 --- a/codex-rs/tui/src/managed_worktree_tools_tests.rs +++ b/codex-rs/tui/src/managed_worktree_tools_tests.rs @@ -1,5 +1,7 @@ use super::*; +use crate::app_server_session::SideChatStorage; use crate::legacy_core::config::ConfigOverrides; +use crate::local_settings::LocalSettings; use crate::test_support::boxed_future; use codex_config::LoaderOverrides; use pretty_assertions::assert_eq; @@ -140,16 +142,29 @@ async fn creation_attaches_to_original_task_and_survives_service_restart_scenari let mut ephemeral_config = config.clone(); ephemeral_config.ephemeral = true; let ephemeral = Box::pin(server.start_thread(&ephemeral_config)).await?; - let refused = tools - .execute( - server.request_handle(), - ephemeral.session.thread_id.to_string(), - "create_worktree", - json!({"allowAsync":true}), - ) - .await; - assert!(!refused.success, "{refused:?}"); - assert!(format!("{refused:?}").contains("Ephemeral side conversations")); + let saved_side = Box::pin(server.fork_side_thread( + &LocalSettings::from(&config), + config.clone(), + started.session.thread_id, + /*selected_profile*/ None, + SideChatStorage::Attached, + )) + .await?; + for blocked in [ephemeral, saved_side] { + let refused = tools + .execute( + server.request_handle(), + blocked.session.thread_id.to_string(), + "create_worktree", + json!({"allowAsync":true}), + ) + .await; + assert!(!refused.success, "{refused:?}"); + assert!( + format!("{refused:?}") + .contains("Side conversations cannot retain worktree attachments") + ); + } assert!(!tools.manager.settings().root.exists()); assert!(tools.operations.lock().await.is_empty()); let call = |tool: &'static str, arguments| { diff --git a/codex-rs/tui/src/sidebar_chooser.rs b/codex-rs/tui/src/sidebar_chooser.rs index 01516c89a8..1eff9671b8 100644 --- a/codex-rs/tui/src/sidebar_chooser.rs +++ b/codex-rs/tui/src/sidebar_chooser.rs @@ -1,9 +1,11 @@ //! The sidebar chooser belongs to its parent conversation and can remain open without taking input. +use crate::app_server_session::SideChat; use crate::footer_hint; use crate::keymap::KeymapContext; use crate::keymap::ListAction; use crate::keymap::RuntimeKeymap; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::sidebar_tab_strip::TabState; use crate::sidebar_tab_strip::TabStrip; use codex_protocol::ThreadId; @@ -92,25 +94,36 @@ pub(crate) fn footer( const CHAT_TAB: &str = " Side chat "; +pub(crate) fn close_cell(tab: Rect) -> Rect { + Rect::new( + tab.right().saturating_sub(1), + tab.y, + u16::from(tab.width >= 3), + tab.height, + ) +} + #[derive(Clone, Copy, Default)] pub(crate) struct ChatTabs<'a> { pub(crate) ids: &'a [ThreadId], + // Chooser index zero is reserved for New side chat. + pub(crate) choices: &'a [SideChat], pub(crate) selected: Option, } impl<'a> ChatTabs<'a> { pub(crate) fn choice_count(self) -> usize { - self.ids.len() + 1 + self.choices.len() + 1 } pub(crate) fn choice(self, index: usize) -> Option { - self.ids.get(index.checked_sub(1)?).copied() + Some(self.choices.get(index.checked_sub(1)?)?.id) } pub(crate) fn choice_index(self, id: ThreadId) -> Option { - self.ids + self.choices .iter() - .position(|choice| *choice == id) + .position(|choice| choice.id == id) .map(|index| index + 1) } @@ -131,14 +144,40 @@ pub(crate) fn tab_strip( hovered_tab: Option, buf: &mut Buffer, ) { - tabs.strip() - .render(area, buf, chooser, chat, hovered_tab, |index, _| { - if tabs.ids.len() > 1 { - Line::from(format!(" Side {} ", index + 1)) - } else { - Line::from(CHAT_TAB) - } - }); + tabs.strip().render( + area, + buf, + chooser, + chat, + hovered_tab, + |id, area, style, buf| { + let close = close_cell(area); + let label = Rect::new(area.x, area.y, area.width - close.width, area.height); + let title = match tabs.choice_index(id) { + Some(number) => match side_chat_label(&tabs.choices[number - 1]) { + Some(name) => truncate_line_with_ellipsis_if_overflow( + Line::from(format!(" {name}")), + usize::from(label.width), + ), + None if tabs.choices.len() > 1 => Line::from(format!(" Side {number} ")), + None => Line::from(CHAT_TAB), + }, + None => Line::from(CHAT_TAB), + }; + title.style(style).render(label, buf); + Line::from("×").style(style).render(close, buf); + }, + ); +} + +pub(crate) fn side_chat_label(chat: &SideChat) -> Option<&str> { + chat.name + .as_deref() + .unwrap_or(&chat.preview) + .trim() + .lines() + .next() + .map(str::trim) } struct ChoiceRows { @@ -245,24 +284,28 @@ pub(crate) fn paint( ); } for (offset, index) in rows.chats.enumerate() { - let id = tabs.ids[index - 1]; + let chat = &tabs.choices[index - 1]; let selected = index == choice && focused; let (marker, fill) = row_style(index); - let title = if count == 2 { + let title = if let Some(label) = side_chat_label(chat) { + label.to_owned() + } else if count == 2 { "Side chat".to_string() } else { format!("Side chat {index}") }; let title = if use_theme_colors && !selected { - let identity = crate::thread_color::thread_color(id); + let identity = crate::thread_color::thread_color(chat.id); title.fg(crate::style::readable_color_on(identity, fill.bg)) } else { title.into() }; let symbol = Span::styled("○ ", if selected { style } else { secondary }); + let y = rows.chat_start + offset as u16; + let line = Line::from(vec![format!("{marker} ").into(), symbol, title]).style(fill); paint( - rows.chat_start + offset as u16, - Line::from(vec![format!("{marker} ").into(), symbol, title]).style(fill), + y, + truncate_line_with_ellipsis_if_overflow(line, usize::from(row(area.y).width)), ); } if footer_y > area.y { diff --git a/codex-rs/tui/src/sidebar_tab_strip.rs b/codex-rs/tui/src/sidebar_tab_strip.rs index 3b41a3ddb7..583435d7ff 100644 --- a/codex-rs/tui/src/sidebar_tab_strip.rs +++ b/codex-rs/tui/src/sidebar_tab_strip.rs @@ -5,6 +5,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Position; use ratatui::layout::Rect; use ratatui::style::Color; +use ratatui::style::Style; use ratatui::text::Line; use ratatui::widgets::Widget; @@ -53,12 +54,14 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { (targets, Rect::new(area.x + available, area.y, plus, height)) } - pub(crate) fn hit(self, area: Rect, point: Position) -> Option> { + pub(crate) fn hit(self, area: Rect, point: Position) -> Option<(TabTarget, Rect)> { let (mut tabs, add) = self.strip_targets(area); if add.contains(point) { - Some(TabTarget::Add) + Some((TabTarget::Add, add)) } else { - tabs.find_map(|(_, id, rect)| rect.contains(point).then_some(TabTarget::Tab(id))) + tabs.find_map(|(_, id, rect)| { + rect.contains(point).then_some((TabTarget::Tab(id), rect)) + }) } } @@ -69,7 +72,7 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { add_state: TabState, selected_state: TabState, hovered_tab: Option, - label: impl Fn(usize, Id) -> Line<'a>, + paint: impl Fn(Id, Rect, Style, &mut Buffer), ) { let (tabs, add) = self.strip_targets(area); let style = |state| match state { @@ -80,7 +83,7 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { .not_underlined() .not_reversed(), }; - for (index, id, rect) in tabs { + for (_, id, rect) in tabs { let tab_style = if selected_state != TabState::Absent && Some(id) == self.selected { style(selected_state) } else if hovered_tab == Some(id) { @@ -95,7 +98,7 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> { } else { style(TabState::Absent) }; - label(index, id).style(tab_style).render(rect, buf); + paint(id, rect, tab_style, buf); } Line::from(" + ").style(style(add_state)).render(add, buf); } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5280846219..dd614df096 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -143,9 +143,7 @@ impl SlashCommand { SlashCommand::Goal => "set or view the goal for a long-running task", SlashCommand::Agents => "open the agent command center", SlashCommand::MultiAgents => "switch between this session's subagents", - SlashCommand::Side | SlashCommand::Btw => { - "start a side conversation in an ephemeral fork" - } + SlashCommand::Side | SlashCommand::Btw => "start a side conversation", SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::Keymap => "remap TUI shortcuts", SlashCommand::Vim => "toggle Vim mode for the composer", @@ -215,6 +213,7 @@ impl SlashCommand { | SlashCommand::Pwd | SlashCommand::Usage | SlashCommand::Ide + | SlashCommand::Rename ) } diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__close_side_chat_tab.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__close_side_chat_tab.snap new file mode 100644 index 0000000000..249f0f8562 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__close_side_chat_tab.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests.rs +expression: dialog.trim_end() +--- + + This will discard unsent input and lose this temporary chat’s history. Close “Fix cache”? + + Cancel Close + + ←/→ choose · Enter confirm · Esc cancel 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 a312e3a22b..8128b5188a 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,7 +23,7 @@ parent-29 New activity · esc latest 100 columns, reopened -parent-24 │ Side chat + +parent-24 │ Side chat× + │child-24 parent-25 │ │child-25 @@ -43,7 +43,7 @@ parent-29 │ │ Side from main thread · ⌃/ to swit 99 columns, reopened - Side chat + + Side chat× + child-24 child-25 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 2bb87d5669..8ad4f84061 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,7 +3,7 @@ source: tui/src/app/tests.rs expression: "format!(\"Zoomed\\n{zoomed}\\nRestored\\n{restored}\\nEqualized\\n{equalized}\")" --- Zoomed - Side chat + + Side chat× + • unfinished answer @@ -22,7 +22,7 @@ Zoomed GPT-6.1-Sol default · Agent tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close Restored -parent-24 │ Side chat + +parent-24 │ Side chat× + │• unfinished answer parent-25 │ │ @@ -41,7 +41,7 @@ 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 │ Side chat + +parent-24 │ Side chat× + │• unfinished answer parent-25 │ │ diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_chat_tabs.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_chat_tabs.snap index 1a1b0434ee..9ef72b59b2 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_chat_tabs.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__side_chat_tabs.snap @@ -1,6 +1,5 @@ --- source: tui/src/app/tests.rs -assertion_line: 4182 expression: header.trim_end() --- - Side 1 Side 2 + + Investig…× Fix cache× + 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 index 6e020e8c19..024de1128e 100644 --- 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 @@ -2,7 +2,7 @@ source: tui/src/app/tests.rs expression: "chooser.lines().filter_map(|line|\nline.split_once('│').map(|(_, sidebar)|\nsidebar.trim_end())).collect::>().join(\"\\n\")" --- - Side chat + + Side chat× + Open in sidebar 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 017ac79b7c..9f9ed3d540 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,7 +2,7 @@ source: tui/src/app/tests.rs expression: panel --- -parent-24 │ Side chat + +parent-24 │ Side chat× + │child-24 parent-25 │ │child-25 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 3c7737db97..7ec1e91f4c 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 @@ -6,6 +6,7 @@ Focus Main Composer | leader c (ctrl-x c) | Global focus_main_composer Focus Mai 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 +Close Sidebar Tab | leader w (ctrl-x w) | Global close_sidebar_tab Close Sidebar Tab Close the active sidebar tab. leader w (ctrl-x w) 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 Focus Pane Right | leader right (ctrl-x right), leader l (ctrl-x l) | Global focus_pane_right Focus Pane Right Focus the open side conversation. leader right (ctrl-x right), leader l (ctrl-x l) Default Minimize Pane Width | leader , (ctrl-x ,) | Global minimize_pane_width Minimize Pane Width Minimize the focused pane width. leader , (ctrl-x ,) Default @@ -14,4 +15,3 @@ Equalize Pane Widths | leader = (ctrl-x =) | Global equalize_pane_widths Equaliz Resize Panes | leader r (ctrl-x r) | Global resize_panes Resize Panes Enter keyboard resize mode for visible dividers. leader r (ctrl-x r) Default 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