mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
|
||||
+8
@@ -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
|
||||
+13
-7
@@ -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 …
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
|
||||
+13
-13
@@ -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!
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user