Persist local workspace side chats and add tab close controls (#60152)

## What changed

- Save side chats as attachments to their parent conversation when local workspace history supports them, and let users reopen them from the sidebar after a restart.
- Show chat names or first-prompt previews in sidebar labels, and allow saved side chats to be renamed with `/rename`.
- Add tab close buttons, middle-click closing, and the configurable `close_sidebar_tab` shortcut, defaulting to `Ctrl-X W`. Closing a saved chat keeps it available in the picker; archiving removes it from Side chats.
- Confirm actions that stop running work, discard unsent input, or lose temporary history. Delete saved TUI side chats when deleting their parent conversation.

## Testing

Add coverage for reopening saved side chats after restart, archive handling, close confirmations, and preventing close-confirmation keys from accepting a replacement approval dialog.

GitOrigin-RevId: 3d47533d007286a7354660c08df13a83dd48d5d7
This commit is contained in:
Felipe Coury
2026-11-07 17:31:27 +00:00
committed by copyberry
parent 57a808a392
commit 57e64682f8
54 changed files with 2047 additions and 254 deletions
+2
View File
@@ -145,6 +145,8 @@ pub struct TuiGlobalKeymap {
pub focus_sidebar_tabs: Option<KeybindingsSpec>,
/// Hide the focused sidebar pane.
pub hide_pane: Option<KeybindingsSpec>,
/// Close the active sidebar tab.
pub close_sidebar_tab: Option<KeybindingsSpec>,
/// Focus the parent conversation.
pub focus_pane_left: Option<KeybindingsSpec>,
/// Focus the open side conversation.
+10
View File
@@ -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,
+1
View File
@@ -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;
+13 -1
View File
@@ -173,6 +173,18 @@ pub async fn find_thread_names_by_ids(
codex_home: &Path,
thread_ids: &HashSet<ThreadId>,
) -> std::io::Result<HashMap<ThreadId, String>> {
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<ThreadId>,
) -> std::io::Result<HashMap<ThreadId, SessionIndexEntry>> {
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;
}
+15 -1
View File
@@ -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);
}
+12
View File
@@ -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);
@@ -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",
),
};
+55 -1
View File
@@ -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 {
+3 -1
View File
@@ -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;
}
+37
View File
@@ -38,6 +38,7 @@ pub(super) struct ConversationFrame {
pub(super) chat_strip: Option<Rect>,
pub(super) sidebar_input: Option<(usize, Option<ThreadId>, usize)>,
pub(super) leader_hud: Option<Rect>,
pub(super) close_buttons: [Rect; 2],
/// A dialog can obscure neighboring content in the terminal's completed buffer.
behind_dialog: Option<ratatui::buffer::Buffer>,
caller_images: Vec<ImagePlacement>,
@@ -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);
+38 -7
View File
@@ -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) {
+117 -86
View File
@@ -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<SideParentStatus>,
pub(super) opening_message: Option<crate::chatwidget::UserMessage>,
pub(super) close_request: Option<uuid::Uuid>,
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::<TurnInterruptResponse>(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::<TurnInterruptResponse>(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::<ThreadUnsubscribeResponse>(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<String>,
attachment: Option<ThreadEventAttachment>,
) -> 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,
}
}
+122 -48
View File
@@ -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 {
+112 -14
View File
@@ -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<SideCreation>,
pub(super) close: Option<super::sidebar_close::Confirmation>,
content: SidebarContent,
tabs: Vec<ThreadId>,
// The chooser keeps the same order when an attached chat becomes a live tab.
choices: Vec<SideChat>,
pub(super) saved_chats_request: Option<uuid::Uuid>,
pub(super) closing_chats: HashMap<ThreadId, JoinHandle<()>>,
// Visit order is independent of the order in which tabs appear.
visits: Vec<ThreadId>,
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<uuid::Uuid>) {
self.saved_chats_request = request;
self.tabs_revision += 1;
}
pub(super) fn update_saved_chats(&mut self, mut chats: Vec<SideChat>) {
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<ThreadId> {
self.visits.iter().rev().copied().find(|id| *id != target)
}
pub(super) fn selected_chat(&self) -> Option<ThreadId> {
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<SidebarContent> {
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
+325
View File
@@ -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(())
}
@@ -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
+2 -1
View File
@@ -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,
+494 -4
View File
@@ -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::<Vec<_>>()
.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::<Vec<_>>(),
[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::<Vec<_>>();
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::<Vec<_>>()
.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!(
+2
View File
@@ -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()]);
@@ -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();
@@ -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?;
@@ -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,
@@ -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;
@@ -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");
+3
View File
@@ -1074,6 +1074,7 @@ impl App {
if self.abandoned_side_threads.contains(&thread_id) {
return Ok(());
}
self.track_side_chat_title(thread_id, &notification);
if self.current_displayed_thread_id() == Some(thread_id)
&& let ServerNotification::TurnCompleted(notification) = &notification
{
@@ -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, .. } = &notification.item.payload
+4
View File
@@ -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() => {
+27 -1
View File
@@ -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<UserMessage>,
@@ -480,6 +480,25 @@ pub(crate) enum AppEvent {
result: Result<Box<crate::app_server_session::SidebarFork>, 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<Vec<crate::app_server_session::SideChat>, 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,
}
+119 -22
View File
@@ -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<PermissionProfileSelection>,
preferred_storage: SideChatStorage,
) -> impl Future<Output = Result<SidebarFork>> + 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<AppServerStartedThread> {
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?;
@@ -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 {
@@ -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!(
@@ -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<Option<String>> {
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<String>,
pub(crate) preview: String,
}
impl From<ThreadId> 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::<TypedRequestError>(),
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<Option<ThreadListParams>> {
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<Vec<SideChat>> {
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<Vec<Thread>> {
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<String> {
// 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);
}
}
}
@@ -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);
+1 -1
View File
@@ -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)))
{
@@ -339,6 +339,7 @@ mod tests {
commands,
vec![
SlashCommand::Ide,
SlashCommand::Rename,
SlashCommand::Agents,
SlashCommand::Copy,
SlashCommand::Export,
@@ -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![
@@ -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
@@ -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()
}
@@ -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()
+19
View File
@@ -108,6 +108,7 @@ pub(crate) struct AppKeymap {
pub(crate) focus_sidebar_content: Vec<KeyBinding>,
pub(crate) focus_sidebar_tabs: Vec<KeyBinding>,
pub(crate) hide_pane: Vec<KeyBinding>,
pub(crate) close_sidebar_tab: Vec<KeyBinding>,
pub(crate) focus_pane_left: Vec<KeyBinding>,
pub(crate) focus_pane_right: Vec<KeyBinding>,
pub(crate) minimize_pane_width: Vec<KeyBinding>,
@@ -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",
+2
View File
@@ -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,
@@ -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 {
+1
View File
@@ -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.",
+9 -2
View File
@@ -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?;
@@ -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| {
+60 -17
View File
@@ -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<ThreadId>,
}
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<ThreadId> {
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<usize> {
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<ThreadId>,
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 {
+9 -6
View File
@@ -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<TabTarget<Id>> {
pub(crate) fn hit(self, area: Rect, point: Position) -> Option<(TabTarget<Id>, 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<Id>,
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);
}
+2 -3
View File
@@ -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
)
}
@@ -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
@@ -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
@@ -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 │
│
@@ -1,6 +1,5 @@
---
source: tui/src/app/tests.rs
assertion_line: 4182
expression: header.trim_end()
---
Side 1 Side 2 +
Investig…× Fix cache× +
@@ -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::<Vec<_>>().join(\"\\n\")"
---
Side chat +
Side chat× +
Open in sidebar
@@ -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
@@ -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