Add sidebar tabs and nonblocking side-chat creation (#59767)

## Why

Starting a side chat waits for fork and setup requests. Keep the workspace responsive during those requests and let users return to an existing side chat through the sidebar chooser.

## What changed

- Add a clickable `Side chat` tab and `+` chooser, plus the configurable `focus_sidebar_tabs` action bound to `leader t` by default.
- Create workspace side chats in the background, showing loading and failure states with `Enter` to retry and `Esc` to go back. Preserve opening prompts and avoid taking focus when the user has moved away.
- Retain inherited permission profiles and defer startup notifications until the side chat is registered on its owning backend.
- Quiet the parent composer while the chooser has focus and prevent completion popups or repeated activation keys from consuming chooser input.

## Testing

Extend tests and snapshots for loading and failure states, returning to an existing chat, draft preservation, inherited permissions, focus transitions, and deferred MCP startup notifications.

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