diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f55114805e..7210924ae0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -267,6 +267,7 @@ mod resume_config; mod resume_preparation; mod right_click_paste; pub(crate) use right_click_paste::PasteEnvironment; +mod pane_motion; mod safety_buffering; mod safety_recourse; mod server_version_notice; @@ -276,6 +277,7 @@ mod side; mod side_companion; mod side_focus; mod side_pane; +mod sidebar; mod startup; pub(crate) mod startup_bootstrap; mod startup_prompts; @@ -298,6 +300,7 @@ mod voice_owner; #[path = "app/warnings_tests.rs"] mod warnings_tests; mod working_directory; +mod workspace_hud; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; @@ -654,6 +657,7 @@ pub(crate) struct App { agent_navigation: AgentNavigationState, agents_overview: agents_overview::AgentsOverviewState, side_threads: HashMap, + sidebars: HashMap, sidebar::SidebarPane>, /// A parked parent view survives child cleanup until its own replay finishes. side_companions: HashMap, zoomed_thread: Option, @@ -888,10 +892,37 @@ impl App { ) -> Result { self.transcript_view .sync_patch_approval(self.chat_widget.current_patch_approval()); + // A chooser action must never decide a modal that has not been painted yet. + if self.conversation_frame.chooser_area().is_some() + && self.chat_widget.has_active_modal() + && matches!( + &event, + TuiEvent::Key(_) | TuiEvent::Mouse(_) | TuiEvent::Paste(_) + ) + { + tui.frame_requester().schedule_frame(); + return Ok(AppRunControl::Continue); + } tui.link_hover.observe(&event); if self.handle_pane_resize(tui, &event).await { return Ok(AppRunControl::Continue); } + if let TuiEvent::Mouse(mouse) = &event + && (self.key_chord_matcher.workspace_leader().is_some() + || self.chat_widget.quick_dial_menu_open()) + && !matches!( + mouse.kind, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) + && self.conversation_frame.hud_contains(*mouse) + { + if matches!(mouse.kind, crossterm::event::MouseEventKind::Down(_)) { + self.cancel_pending_key_chord(); + tui.frame_requester().schedule_frame(); + } + self.refresh_link_hover(tui)?; + return Ok(AppRunControl::Continue); + } self.refresh_link_hover(tui)?; self.invalidate_right_click_paste(&event); self.finish_clipboard(tui, &event); @@ -966,7 +997,7 @@ impl App { && crate::text_selection::is_copy_key(*key) && !transcript_owns_input && self.conversation_focus_available(tui) - && let Some(painted) = &self.conversation_frame.side_pane + && let Some(painted) = self.conversation_frame.side_pane() && let Some(current) = side_pane::layout(self, tui, tui.terminal.last_known_screen_size.into()) && current.passive_thread == painted.passive_thread @@ -1001,9 +1032,18 @@ impl App { self.handle_draw_pre_render(tui, screen_size)?; } - if matches!(&event, TuiEvent::Paste(_) | TuiEvent::FocusLost) - || matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != crossterm::event::MouseEventKind::Moved) - { + let dismiss_chord = match &event { + TuiEvent::Paste(_) | TuiEvent::FocusLost => true, + TuiEvent::Mouse(mouse) => match mouse.kind { + MouseEventKind::Moved => false, + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + self.key_chord_matcher.workspace_leader().is_none() + } + _ => true, + }, + _ => false, + }; + if dismiss_chord { self.cancel_pending_key_chord(); } if matches!(&event, TuiEvent::FocusLost) @@ -1047,21 +1087,26 @@ impl App { { return Ok(AppRunControl::Continue); } + if self.handle_chooser_input(tui, &event) { + return Ok(AppRunControl::Continue); + } if let TuiEvent::Mouse(press) = &event && press.kind == MouseEventKind::Down(MouseButton::Left) && self.conversation_focus_available(tui) - && let Some(painted) = &self.conversation_frame.side_pane + && let Some(painted) = self.conversation_frame.side_pane() && painted.passive.contains((press.column, press.row).into()) { // A hidden, resized or replaced pane can still be in the last painted frame. let size = tui.prepare_draw_size()?; if let Some(current) = side_pane::layout(self, tui, size.into()) && current.passive_thread == painted.passive_thread - && current.passive == painted.passive + && let Some(peer) = self.current_displayed_thread_id() + && painted.passive.union(painted.focused) == Rect::from(size) { + let painted_area = painted.passive; if let Some(url) = self.transcript_link_at(*press) { if press.modifiers.is_empty() { - self.passive_link_drag = Some((current.passive_thread, current.passive)); + self.passive_link_drag = Some((current.passive_thread, painted.passive)); self.handle_passive_transcript_event(tui, current.passive_thread, &event); } else { self.open_url_in_browser(url); @@ -1084,9 +1129,10 @@ impl App { self.chat_widget.add_error_message(err.to_string()); } else if self.current_displayed_thread_id() == Some(current.passive_thread) { self.app_event_tx.send(AppEvent::SidePanePressAfterReplay { + peer, draft_cursor, thread_id: current.passive_thread, - area: current.passive, + area: painted_area, press: *press, }); } @@ -1312,6 +1358,7 @@ impl App { let frozen = self.chat_widget.is_external_writer_view(); let visible = self.current_displayed_thread_id().filter(|&id| { tui.is_terminal_focused() + && !self.conversation_frame.chooser_covers_transcript() && matches!(self.overlay, None | Some(Overlay::Transcript(_))) && self.chat_widget.no_modal_or_popup_active() && self.initial_history_replay_buffer.is_none() diff --git a/codex-rs/tui/src/app/clipboard.rs b/codex-rs/tui/src/app/clipboard.rs index 28cdb5edcb..758402de35 100644 --- a/codex-rs/tui/src/app/clipboard.rs +++ b/codex-rs/tui/src/app/clipboard.rs @@ -68,8 +68,7 @@ impl App { } let passive = self .conversation_frame - .side_pane - .as_ref() + .side_pane() .map(|panes| panes.passive_thread); for (id, companion) in &mut self.side_companions { let visible = current && passive == Some(*id); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 126da3520b..ad3e8f3ba1 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1025,21 +1025,24 @@ impl App { tui.frame_requester().schedule_frame(); } } - AppEvent::SidePanePressAfterReplay { thread_id, area, mut press, draft_cursor } => { + AppEvent::SidePanePressAfterReplay { peer, thread_id, area, mut press, draft_cursor } => { if self.pending_thread_switch_resets == 0 && self.current_displayed_thread_id() == Some(thread_id) && self.conversation_focus_available(tui) { - // The native mouse handler draws at this reserved size before hit testing. let size = tui.prepare_draw_size()?; - if side_pane::layout(self, tui, size.into()).is_some_and(|panes| panes.focused == area) { + if side_pane::layout(self, tui, size.into()).is_some_and(|panes| panes.passive_thread == peer) { if let Some(cursor) = draft_cursor { - // Focus can expand or scroll the editor. Replay at the same text position. + // The editor can reflow during the slide. Keep the original text position. self.chat_widget.set_composer_cursor(cursor); - self.refresh_owned_transcript(tui)?; + } + self.refresh_owned_transcript(tui)?; + if draft_cursor.is_some() { let position = tui.terminal.last_known_cursor_pos; press.column = position.x; press.row = position.y; + } else if !self.conversation_frame.side_pane().is_some_and(|panes| panes.focused == area) { + return Ok(AppRunControl::Continue); } self.handle_owned_transcript_event(tui, app_server, &TuiEvent::Mouse(press))?; } diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 025a7c3916..b0a65e7f2f 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -186,6 +186,11 @@ impl App { if self.backtrack.primed && !self.backtrack.overlay_preview_active { self.reset_backtrack_state(); } + if self.key_chord_matcher.workspace_leader().is_some() { + tui.frame_requester() + .schedule_frame_in(super::workspace_hud::REVEAL_DELAY); + return None; + } self.set_key_chord_hint_override(self.key_chord_matcher.pending_hint_items( &self.keymap.chords, tui.terminal.last_known_screen_size.width, @@ -242,6 +247,11 @@ impl App { if !self.conversation_focus_available(tui) { return contexts; } + if self.chooser_focus() == Some(super::sidebar::PaneFocus::Sidebar) + && self.sidebar_chooser_available() + { + return crate::keymap::KeymapContextSet::focused_sidebar_chooser(); + } let mut contexts = contexts.with_pane_directions(); if self.open_pane_focus().is_some() { contexts = contexts.with_open_pane(); @@ -254,6 +264,8 @@ impl App { } if self.switchable_side_child().is_some() { contexts.with_side_pane() + } else if self.sidebar_chooser_available() { + contexts.with_sidebar_chooser() } else { contexts } diff --git a/codex-rs/tui/src/app/link_hover.rs b/codex-rs/tui/src/app/link_hover.rs index 3e98ac92cb..f208c958e2 100644 --- a/codex-rs/tui/src/app/link_hover.rs +++ b/codex-rs/tui/src/app/link_hover.rs @@ -26,7 +26,7 @@ impl App { { return None; } - let view = if let Some(panes) = &self.conversation_frame.side_pane + let view = if let Some(panes) = self.conversation_frame.side_pane() && panes.passive.contains((mouse.column, mouse.row).into()) { let passive = &self.side_companions.get(&panes.passive_thread)?.view; @@ -104,8 +104,7 @@ impl App { self.refresh_owned_transcript(tui)?; if self .conversation_frame - .side_pane - .as_ref() + .side_pane() .is_some_and(|panes| panes.passive_thread == id && panes.passive == area) { self.handle_passive_transcript_event(tui, id, event); @@ -128,7 +127,11 @@ impl App { } pub(super) fn refresh_link_hover(&self, tui: &mut Tui) -> std::io::Result<()> { - let shape = if !tui.is_owned_screen() + let shape = if tui + .link_hover + .mouse + .is_some_and(|mouse| self.conversation_frame.hud_contains(mouse)) + || !tui.is_owned_screen() || self.overlay.is_some() || !self.chat_widget.no_modal_or_popup_active() { @@ -136,8 +139,7 @@ impl App { } else if self.divider_drag.is_some() || tui.link_hover.mouse.is_some_and(|mouse| { self.conversation_frame - .side_pane - .as_ref() + .side_pane() .is_some_and(|panes| panes.divider_contains(mouse)) }) { diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 2d293813cf..cc2fb7b69f 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -7,6 +7,7 @@ //! geometry unchanged. Turn tips remain outside transcript history; their painted rows are selectable. //! Plain Enter returns an empty composer to latest after transcript interactions and prompt editing. +use super::sidebar::PaneFocus; use super::turn_tips::TipSurface; use super::*; use crate::bottom_pane::CommandPopupPlacement; @@ -32,19 +33,44 @@ use ratatui::widgets::Widget; #[derive(Default)] pub(super) struct ConversationFrame { inset: Option, - pub(super) side_pane: Option, + sidebar: Option, + pub(super) leader_hud: Option, /// A dialog can obscure neighboring content in the terminal's completed buffer. behind_dialog: Option, caller_images: Vec, } impl ConversationFrame { + pub(super) fn hud_contains(&self, mouse: crossterm::event::MouseEvent) -> bool { + self.leader_hud + .is_some_and(|area| area.contains((mouse.column, mouse.row).into())) + } + pub(super) fn has_caller_inset(&self) -> bool { - self.inset.is_some() && self.side_pane.is_none() + self.inset.is_some() && self.sidebar.is_none() + } + + pub(super) fn side_pane(&self) -> Option<&side_pane::SidePaneLayout> { + match &self.sidebar { + Some(sidebar::SidebarLayout::Chat(panes)) => Some(panes), + _ => None, + } + } + + pub(super) fn chooser_covers_transcript(&self) -> bool { + matches!(self.sidebar, Some(sidebar::SidebarLayout::Chooser { main, .. }) if main.is_empty()) + } + + pub(super) fn chooser_area(&self) -> Option { + match self.sidebar { + Some(sidebar::SidebarLayout::Chooser { sidebar, .. }) => Some(sidebar), + _ => None, + } } } pub(super) struct PreparedConversation { + floating_shortcuts: bool, area: Rect, transcript_width: u16, composer: Option, @@ -203,21 +229,26 @@ impl App { ) -> Result { self.transcript_view.image_protocol = self.prepare_frame_images(); let frames = tui.frame_requester(); - if self.conversation_frame.side_pane.is_some() { + if self.conversation_frame.sidebar.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 dock = side_pane::layout(self, tui, screen_size.into()); + 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 mut prepared = + self.prepare_conversation(screen_size.into(), caller_inset.or(live), &frames); + self.conversation_frame.sidebar = layout; if self.divider_drag.is_some_and(|drag| { - !drag.matches(screen_size.into(), dock.as_ref()) + !drag.matches(screen_size.into(), self.conversation_frame.side_pane()) || !self.conversation_focus_available(tui) }) { self.divider_drag = None; } - let inset = caller_inset.or_else(|| dock.as_ref().map(|panes| panes.focused)); - let prepared = self.prepare_conversation(screen_size.into(), inset, &frames); - self.conversation_frame.side_pane = dock; let highlight_divider = self.pane_resize.is_some() || tui.link_hover.shape == tui::PointerShape::Resize; // An input refresh opens a fresh terminal buffer. Carry the outer owner's @@ -228,6 +259,8 @@ impl App { .take() .unwrap_or_else(|| tui.terminal.previous_buffer().clone()) }); + let hud = self.workspace_hud(tui, screen_size.into()); + prepared.floating_shortcuts = hud.is_some(); let mut painted = PaintedConversation::default(); tui.draw(screen_size.height, |frame| { if let Some(backdrop) = backdrop { @@ -247,19 +280,45 @@ impl App { } } painted = self.paint_conversation(frame, &prepared); - if let Some(panes) = &self.conversation_frame.side_pane - && let Some(companion) = self.side_companions.get_mut(&panes.passive_thread) - { - companion.view.image_protocol = self.transcript_view.image_protocol; - panes.paint_companion( - companion, - frame, - self.divider_drag, - highlight_divider, - &frames, - ); + let divider = match &self.conversation_frame.sidebar { + Some(sidebar::SidebarLayout::Chat(panes)) => { + if let Some(companion) = self.side_companions.get_mut(&panes.passive_thread) { + companion.view.image_protocol = self.transcript_view.image_protocol; + panes.paint_companion(companion, frame, &frames); + } + Some(panes.divider) + } + Some(sidebar::SidebarLayout::Chooser { + main, + sidebar, + focus, + }) => { + let focused = *focus == PaneFocus::Sidebar; + crate::sidebar_chooser::paint(focused, *sidebar, frame.buffer, &self.keymap); + frame + .image_placements + .retain(|image| !sidebar.intersects(image.area)); + if focused { + frame.cursor_position = None; + } + (!main.is_empty()).then_some(main.right()) + } + None => None, + }; + if let Some(x) = divider { + side_pane::paint_divider(frame, x, self.divider_drag, highlight_divider); } self.paint_centered_dialog(frame); + self.conversation_frame.leader_hud = hud.map(|hud| { + let area = hud.render_overlay(frame.area(), frame.buffer); + if frame.cursor_position.is_some_and(|pos| area.contains(pos)) { + frame.cursor_position = None; + } + frame + .image_placements + .retain(|image| !area.intersects(image.area)); + area + }); })?; self.finish_conversation(tui, painted) } @@ -271,7 +330,7 @@ impl App { frames: &tui::FrameRequester, ) -> PreparedConversation { self.conversation_frame.inset = conversation_inset; - self.conversation_frame.side_pane = None; + self.conversation_frame.sidebar = None; let area = self.conversation_area(viewport); if let Some((previous, reflowed)) = self.chat_widget.resize_owned_conversation(area.width) { self.transcript_view @@ -316,6 +375,7 @@ impl App { frames.schedule_frame(); } PreparedConversation { + floating_shortcuts: false, area, transcript_width, composer, @@ -373,6 +433,7 @@ impl App { let options = |interacting| ComposerRenderOptions { max_height: Some(max_composer_height), overlay_top: area.top(), + floating_shortcuts: prepared.floating_shortcuts, command_popup_placement: if interacting { CommandPopupPlacement::Hidden } else { @@ -430,6 +491,7 @@ impl App { let mut footer = prompt_footer .or_else(|| view.footer_with_navigation(footer_width, motion, latest_navigation)); if let Some(footer) = footer.as_mut().filter(|footer| footer.is_interactive) + && self.key_chord_matcher.workspace_leader().is_none() && let Some(items) = self .key_chord_matcher .pending_hint_items(&self.keymap.chords, footer_width) @@ -558,7 +620,7 @@ impl App { return; }; let area = frame.area(); - if self.conversation_frame.side_pane.is_some() { + if self.conversation_frame.sidebar.is_some() { let covered = dialog.area(area); frame .image_placements @@ -659,9 +721,16 @@ impl App { if mouse.kind != crossterm::event::MouseEventKind::Moved { self.refresh_owned_transcript(tui)?; } + if self + .conversation_frame + .chooser_area() + .is_some_and(|area| area.contains((mouse.column, mouse.row).into())) + { + return Ok(true); + } if (is_vertical_wheel || mouse.kind == MouseEventKind::Down(MouseButton::Right)) && self.chat_widget.no_modal_or_popup_active() - && let Some(panes) = &self.conversation_frame.side_pane + && let Some(panes) = self.conversation_frame.side_pane() && panes.passive.contains((mouse.column, mouse.row).into()) { let id = panes.passive_thread; diff --git a/codex-rs/tui/src/app/pane_motion.rs b/codex-rs/tui/src/app/pane_motion.rs new file mode 100644 index 0000000000..dceaa27837 --- /dev/null +++ b/codex-rs/tui/src/app/pane_motion.rs @@ -0,0 +1,39 @@ +//! Slide the sidebar from its last painted width, independently of keyboard focus. + +use std::time::Duration; +use tokio::time::Instant; + +use crate::tui::FrameRequester; +use crate::tui::TARGET_FRAME_INTERVAL; + +const SLIDE_DURATION: Duration = Duration::from_millis(180); + +#[derive(Clone, Debug, Default)] +pub(super) struct PaneMotion { + fraction: f64, + target: f64, + slide: Option<(Instant, f64)>, +} + +impl PaneMotion { + /// Without a frame requester, snap to the target immediately. + pub(super) fn advance(&mut self, open: bool, frames: Option) -> f64 { + let target = f64::from(open); + if self.target != target { + self.target = target; + self.slide = Some((Instant::now(), self.fraction)); + } + if let (Some((start, from)), Some(frames)) = (self.slide, frames) { + let progress = start.elapsed().as_secs_f64() / SLIDE_DURATION.as_secs_f64(); + if progress < 1.0 && from != target { + let eased = progress * progress * (3.0 - 2.0 * progress); + self.fraction = from + (target - from) * eased; + frames.schedule_frame_in(TARGET_FRAME_INTERVAL); + return self.fraction; + } + } + self.slide = None; + self.fraction = target; + target + } +} diff --git a/codex-rs/tui/src/app/pets.rs b/codex-rs/tui/src/app/pets.rs index a39862d4f9..63548447ba 100644 --- a/codex-rs/tui/src/app/pets.rs +++ b/codex-rs/tui/src/app/pets.rs @@ -11,10 +11,15 @@ impl App { ) -> Result<()> { if self.chat_widget.ambient_pet_image_enabled() { let ambient_pet_area = self.conversation_area(screen_size.into()); - if let Err(err) = tui.draw_ambient_pet_image( - self.chat_widget - .ambient_pet_draw(ambient_pet_area, rendered_area.bottom()), - ) { + let request = self + .chat_widget + .ambient_pet_draw(ambient_pet_area, rendered_area.bottom()) + .filter(|pet| { + self.conversation_frame + .leader_hud + .is_none_or(|hud| !hud.intersects(pet.area())) + }); + if let Err(err) = tui.draw_ambient_pet_image(request) { self.handle_ambient_pet_image_render_error(tui, err)?; } } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index f5f6e0dfe1..108ea97414 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -747,10 +747,21 @@ impl App { }); } self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?; - if self.side_retention_enabled(tui) - && let Some(side) = self.side_threads.get_mut(&thread_id) - { - side.pane_open = true; + if self.side_retention_enabled(tui) { + if self.side_threads.contains_key(&thread_id) { + self.sidebar_mut().show( + sidebar::SidebarContent::Chat(thread_id), + sidebar::PaneFocus::Sidebar, + ); + } else if let Some(sidebar::SidebarContent::Chat(child)) = self + .sidebar() + .and_then(sidebar::SidebarPane::visible_content) + { + self.sidebar_mut().show( + sidebar::SidebarContent::Chat(child), + sidebar::PaneFocus::Main, + ); + } } if is_replay_only && self @@ -918,6 +929,7 @@ impl App { .values() .any(|side| side.parent_thread_id == *id) }); + self.sidebars.remove(&None); self.pending_realtime_speech_replay.clear(); self.pending_realtime_transcript_replay.clear(); self.realtime_replay_order.clear(); diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index cf615f2576..960de36f11 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -215,8 +215,6 @@ 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, - /// A hidden pane keeps its child and parked display for the next focus switch. - pub(super) pane_open: bool, } impl SideThreadState { @@ -224,7 +222,6 @@ impl SideThreadState { Self { parent_thread_id, parent_status: None, - pane_open: true, } } } @@ -332,7 +329,8 @@ impl App { pub(super) fn open_pane_focus(&self) -> Option { let focused = self.current_displayed_thread_id()?; let child = self.side_pair_key(focused)?; - self.side_threads.get(&child)?.pane_open.then_some(focused) + (self.sidebar()?.visible_content() == Some(sidebar::SidebarContent::Chat(child))) + .then_some(focused) } pub(super) fn switchable_side_child(&self) -> Option { @@ -487,10 +485,8 @@ impl App { }; let parent = side.parent_thread_id; self.select_agent_thread(tui, app_server, parent).await?; - if self.current_displayed_thread_id() == Some(parent) - && let Some(side) = self.side_threads.get_mut(&child) - { - side.pane_open = false; + if self.current_displayed_thread_id() == Some(parent) { + self.sidebar_mut().hide(); tui.frame_requester().schedule_frame(); self.focus_returned_parent_composer(parent).await?; } @@ -607,7 +603,15 @@ impl App { self.thread_event_channels.remove(&thread_id); self.pending_server_profiles.remove(&thread_id); self.agents_overview.activity.remove(&thread_id); - self.side_threads.remove(&thread_id); + self.sidebars.remove(&Some(thread_id)); + if let Some(side) = self.side_threads.remove(&thread_id) + && self + .sidebars + .get(&Some(side.parent_thread_id)) + .is_some_and(|pane| pane.contains_chat(thread_id)) + { + self.sidebars.remove(&Some(side.parent_thread_id)); + } self.side_companions.remove(&thread_id); self.agent_navigation.remove(thread_id); if self.active_thread_id == Some(thread_id) { @@ -786,6 +790,11 @@ impl App { parent_thread_id: ThreadId, mut user_message: Option, ) -> Result { + // The fork and selection finish in this event. On success the next frame + // paints the chat at the chooser's width; on failure it slides closed. + if self.chooser_focus().is_some() { + self.sidebar_mut().hide(); + } if let Some(message) = self.side_start_block_message() { self.restore_side_user_message(user_message.take()); self.sync_side_thread_ui(); diff --git a/codex-rs/tui/src/app/side_focus.rs b/codex-rs/tui/src/app/side_focus.rs index 0beb2c928f..b5bfa7020e 100644 --- a/codex-rs/tui/src/app/side_focus.rs +++ b/codex-rs/tui/src/app/side_focus.rs @@ -1,11 +1,82 @@ //! Composer and side-conversation focus shortcuts. Thread selection replaces the widget //! immediately, but Park and Finish transfer transcript views through the app event queue. +use super::sidebar::PaneFocus; +use super::sidebar::SidebarContent; use super::*; use crate::activation_key_guard::ActivationKeySuppression; use crate::transcript_view::TranscriptView; impl App { + pub(super) fn sidebar_chooser_available(&self) -> bool { + !self.reconnect.offline + && !self.conversation_frame.has_caller_inset() + && !self.chat_widget.has_active_modal() + && 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() + { + return false; + } + let open = match event { + TuiEvent::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { + tui.frame_requester().schedule_frame(); + let Some(area) = self + .conversation_frame + .chooser_area() + .filter(|area| area.contains((mouse.column, mouse.row).into())) + else { + self.sidebar_mut() + .show(SidebarContent::Chooser, PaneFocus::Main); + return false; + }; + self.sidebar_mut() + .show(SidebarContent::Chooser, PaneFocus::Sidebar); + if crate::sidebar_chooser::choice_contains(area, mouse.row) { + 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. + _ => return true, + } + } else { + return true; + } + } + _ 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, + ); + tui.frame_requester().schedule_frame(); + key.code == KeyCode::Enter + } + _ => return matches!(event, TuiEvent::Key(_) | TuiEvent::Paste(_)), + }; + 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(); + } + true + } + pub(super) async fn handle_side_focus_key( &mut self, tui: &mut tui::Tui, @@ -50,7 +121,11 @@ impl App { return false; } let child = self.switchable_side_child(); - if (sidebar || hide) && child.is_none() { + let chooser = self.chooser_focus(); + if child.is_none() + && ((hide && chooser != Some(PaneFocus::Sidebar)) + || (sidebar && !self.sidebar_chooser_available())) + { return false; } if key.kind != KeyEventKind::Press { @@ -77,10 +152,26 @@ 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() { + if hide || (sidebar && chooser == Some(PaneFocus::Sidebar)) { + self.sidebar_mut().hide(); + } else { + if main { + self.focus_current_parent_view(tui); + } + let focus = if main || left { + PaneFocus::Main + } else { + PaneFocus::Sidebar + }; + self.sidebar_mut().show(SidebarContent::Chooser, focus); + } + tui.frame_requester().schedule_frame(); + Ok(()) } else if main { self.focus_main_composer(tui, app_server).await } else if (left && on_child) - || (right && !on_child && child.is_some_and(|id| self.side_threads[&id].pane_open)) + || (right && !on_child && child.is_some() && self.open_pane_focus().is_some()) { // Switching threads parks and restores each pane's transcript interaction. self.toggle_side_conversation(tui, app_server).await @@ -94,6 +185,12 @@ impl App { self.focus_current_parent_view(tui); } result + } else if sidebar && child.is_none() { + self.focus_current_parent_view(tui); + self.sidebar_mut() + .show(SidebarContent::Chooser, PaneFocus::Sidebar); + tui.frame_requester().schedule_frame(); + Ok(()) } else if hide || sidebar { self.hide_focused_side_pane(tui, app_server).await } else { diff --git a/codex-rs/tui/src/app/side_pane.rs b/codex-rs/tui/src/app/side_pane.rs index 935ce5c796..aea4ba9cb8 100644 --- a/codex-rs/tui/src/app/side_pane.rs +++ b/codex-rs/tui/src/app/side_pane.rs @@ -3,6 +3,7 @@ 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::custom_terminal::Frame; use crate::legacy_core::config::edit::ConfigEdit; @@ -79,7 +80,7 @@ pub(super) struct SidePaneLayout { pub(super) focused: Rect, pub(super) passive_thread: ThreadId, pub(super) passive: Rect, - divider: u16, + pub(super) divider: u16, } /// A wide terminal can show a pair once the outgoing conversation has been captured. @@ -90,10 +91,25 @@ pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option Option { +pub(super) fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option { + let SidebarContent::Chat(child) = app.sidebar()?.visible_content()? else { + return None; + }; if area.width < 100 || app.conversation_frame.has_caller_inset() || !app.side_retention_enabled(tui) + { + return None; + } + pair_layout(app, child, sidebar_areas(app, tui, area, /*fraction*/ 1.0)?) +} + +pub(super) fn pair_layout( + app: &App, + child: ThreadId, + [parent, sidebar]: [Rect; 2], +) -> Option { + if parent.is_empty() || app .chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) @@ -102,33 +118,41 @@ fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option { return None; } let active = app.current_displayed_thread_id()?; - let child = app.side_pair_key(active)?; let side = app.side_threads.get(&child)?; - if !side.pane_open { - return None; - } - let focused_child = active == child; - let passive_thread = if focused_child { - side.parent_thread_id + let (focused, passive, passive_thread) = if active == child { + (sidebar, parent, side.parent_thread_id) } else { - child + (parent, sidebar, child) }; if !app.side_companions.contains_key(&passive_thread) { return None; } + Some(SidePaneLayout { + focused, + passive, + passive_thread, + divider: parent.right(), + }) +} + +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 + .active_side_parent_thread_id() + .or_else(|| active.and_then(|id| app.side_thread_for_parent(id))); let preview = app .divider_drag .filter(|drag| { !drag.guide_only && drag.screen == area - && drag.passive_thread == passive_thread + && Some(drag.passive_thread) == passive && app.conversation_focus_available(tui) }) .and_then(|drag| drag.percent()); let saved = app.local_settings.tui.sidebar_width_percent; let keyboard_width = app .pane_resize - .filter(|mode| mode.focused == active && mode.companion == passive_thread) + .filter(|mode| Some(mode.focused) == active && Some(mode.companion) == passive) .and_then(|mode| mode.preview_width); let child_width = match (keyboard_width, preview, app.exact_sidebar_width) { (Some(width), _, _) => sidebar_width(i32::from(width), area.width), @@ -139,20 +163,13 @@ fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option { } _ => preferred_width(area.width, preview.or(saved)), }; - let [parent, child] = + // A newly focused composer needs a cell even on the first opening frame. + let open = app.sidebar()?.visible_content().is_some(); + let child_width = ((f64::from(child_width) * fraction).round() as u16).max(u16::from(open)); + (child_width > 0).then(|| { Layout::horizontal([Constraint::Fill(1), Constraint::Length(child_width)]) .spacing(GAP) - .areas(area); - let (focused, passive) = if focused_child { - (child, parent) - } else { - (parent, child) - }; - Some(SidePaneLayout { - focused, - passive, - passive_thread, - divider: parent.right(), + .areas(area) }) } @@ -328,7 +345,7 @@ impl App { } match mouse.kind { Down(Left) => { - let Some(painted) = &self.conversation_frame.side_pane else { + let Some(painted) = self.conversation_frame.side_pane() else { return Ok(false); }; if !painted.divider_contains(*mouse) { @@ -385,8 +402,6 @@ impl SidePaneLayout { &self, companion: &mut SideCompanion, frame: &mut Frame<'_>, - drag: Option, - highlight_divider: bool, frames: &FrameRequester, ) { companion.flush_answer(); @@ -458,33 +473,38 @@ impl SidePaneLayout { ) .render(footer, frame.buffer); } - - // Draw after both panes registered their images. Clearing a guide to the left - // of retained pixels can erase the rest of that terminal row. - let guide = drag - .filter(|drag| drag.guide_only) - .map_or(self.divider, |drag| { - drag.screen.right() - - drag.percent().map_or(drag.initial_width, |p| { - preferred_width(drag.screen.width, Some(p)) - }) - - GAP - }); - let style = if highlight_divider || drag.is_some() { - crate::style::accent_style() - } else { - ratatui::style::Style::default().dim() - }; - for y in area.y..area.bottom() { - let x = if guide != self.divider - && frame.image_placements.iter().any(|image| { - (image.area.y..image.area.bottom()).contains(&y) && guide < image.area.right() - }) { - self.divider - } else { - guide - }; - frame.buffer[(x, y)].set_symbol("│").set_style(style); - } + } +} + +pub(super) fn paint_divider( + frame: &mut Frame<'_>, + divider: u16, + drag: Option, + highlight_divider: bool, +) { + // Draw after both panes registered their images. Clearing a guide to the left + // of retained pixels can erase the rest of that terminal row. + let guide = drag.filter(|drag| drag.guide_only).map_or(divider, |drag| { + drag.screen.right() + - drag.percent().map_or(drag.initial_width, |p| { + preferred_width(drag.screen.width, Some(p)) + }) + - GAP + }); + let style = if highlight_divider || drag.is_some() { + crate::style::accent_style() + } else { + ratatui::style::Style::default().dim() + }; + for y in frame.area().y..frame.area().bottom() { + let x = if guide != divider + && frame.image_placements.iter().any(|image| { + (image.area.y..image.area.bottom()).contains(&y) && guide < image.area.right() + }) { + divider + } else { + guide + }; + frame.buffer[(x, y)].set_symbol("│").set_style(style); } } diff --git a/codex-rs/tui/src/app/sidebar.rs b/codex-rs/tui/src/app/sidebar.rs new file mode 100644 index 0000000000..7f26feabbe --- /dev/null +++ b/codex-rs/tui/src/app/sidebar.rs @@ -0,0 +1,124 @@ +//! Each parent owns one sidebar. Its content survives hiding so the same surface +//! can paint the closing frames and reverse direction if reopened mid-slide. + +use super::App; +use super::pane_motion::PaneMotion; +use super::side_pane; +use super::side_pane::SidePaneLayout; +use crate::tui::Tui; +use codex_protocol::ThreadId; +use ratatui::layout::Rect; + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub(super) enum SidebarContent { + #[default] + Chooser, + Chat(ThreadId), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PaneFocus { + Main, + Sidebar, +} + +#[derive(Default)] +pub(super) struct SidebarPane { + content: SidebarContent, + focus: Option, + motion: PaneMotion, +} + +impl SidebarPane { + pub(super) fn show(&mut self, content: SidebarContent, focus: PaneFocus) { + self.content = content; + self.focus = Some(focus); + } + + pub(super) fn hide(&mut self) { + self.focus = None; + } + + pub(super) fn contains_chat(&self, id: ThreadId) -> bool { + self.content == SidebarContent::Chat(id) + } + + pub(super) fn visible_content(&self) -> Option { + self.focus.map(|_| self.content) + } +} + +impl App { + fn sidebar_parent(&self) -> Option { + self.active_side_parent_thread_id() + .or_else(|| self.current_displayed_thread_id()) + } + + pub(super) fn sidebar(&self) -> Option<&SidebarPane> { + self.sidebars.get(&self.sidebar_parent()) + } + + pub(super) fn sidebar_mut(&mut self) -> &mut SidebarPane { + self.sidebars.entry(self.sidebar_parent()).or_default() + } + + pub(super) fn chooser_focus(&self) -> Option { + let pane = self.sidebar()?; + pane.focus + .filter(|_| pane.content == SidebarContent::Chooser) + } +} + +pub(super) enum SidebarLayout { + Chooser { + main: Rect, + sidebar: Rect, + focus: PaneFocus, + }, + Chat(SidePaneLayout), +} + +/// Step motion once per frame. Input and saved width calculations use the settled split. +pub(super) fn paint_layout(app: &mut App, tui: &Tui, area: Rect) -> Option { + let zoomed = + app.zoomed_thread.is_some() && app.zoomed_thread == app.current_displayed_thread_id(); + let allowed = + !zoomed && app.side_retention_enabled(tui) && !app.conversation_frame.has_caller_inset(); + let animate = allowed + && area.width >= 100 + && app.local_settings.tui.animations + && app.pane_resize.is_none() + && app.divider_drag.is_none(); + let parent = app.sidebar_parent(); + let pane = app.sidebars.get_mut(&parent)?; + let open = pane.focus.is_some(); + let fraction = pane + .motion + .advance(open && allowed, animate.then(|| tui.frame_requester())); + if !allowed || (!open && fraction == 0.0) { + return None; + } + let content = pane.content; + let focus = pane.focus.unwrap_or(PaneFocus::Main); + let [main, sidebar] = if area.width < 100 { + if focus == PaneFocus::Main { + return None; + } + [Rect::default(), area] + } else { + 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::Chat(child) => { + side_pane::pair_layout(app, child, [main, sidebar]).map(SidebarLayout::Chat) + } + } +} diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_leader_hints.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_leader_hints.snap new file mode 100644 index 0000000000..8f0585d5af --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_leader_hints.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/app/tests.rs +expression: "panel_text(&app, &tui)" +--- +─ f12 · Conversation ─────────────────────────────────────────────────────────────────────────────── + + 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 + . Maximum width + + Esc cancel diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_presets.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_presets.snap new file mode 100644 index 0000000000..1ac2b0a0b3 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__tests__workspace_presets.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/app/tests.rs +expression: "panel_text(&app, &tui)" +--- +─ Presets ────────────────────────────────────────────────────────────────────────────────────────── + + No presets saved. Press n to create one. + + n · new s · from settings e · edit d · delete esc · close diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 37cd485afb..084508de52 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -1063,6 +1063,7 @@ See the Codex keymap documentation for supported actions and examples." agent_navigation: AgentNavigationState::default(), agents_overview, side_threads: HashMap::new(), + sidebars: HashMap::new(), side_companions: HashMap::new(), zoomed_thread: None, exact_sidebar_width: None, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 742ea45db2..c0bca58734 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -124,6 +124,7 @@ pub(crate) async fn make_test_app() -> Box { agent_navigation: AgentNavigationState::default(), agents_overview: Default::default(), side_threads: HashMap::new(), + sidebars: HashMap::new(), side_companions: HashMap::new(), zoomed_thread: None, exact_sidebar_width: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index e9c39a9f48..2a9857e198 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3481,6 +3481,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert_eq!(app.active_thread_id, Some(side_thread_id)); assert_eq!(app.side_threads.len(), 1); enable_side_retention(&mut app)?; + app.local_settings.tui.animations = false; tui.set_owned_screen(/*owned*/ true)?; read_side_history(&mut app, &mut tui, "child")?; app.chat_widget.insert_str("child draft"); @@ -3696,6 +3697,27 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() .is_search_editing() ); app.pending_server_profiles.remove(&side_thread_id); + + app.local_settings.tui.animations = true; + tokio::time::pause(); + let mut dividers = Vec::new(); + for (key, focus) in [(minus, parent_thread_id), (side, side_thread_id)] { + keys!(prefix, key); + assert_eq!(app.current_displayed_thread_id(), Some(focus)); + for elapsed in [Duration::ZERO, Duration::from_millis(90)] { + tokio::time::advance(elapsed).await; + side_frame(&mut app, &mut tui, size)?; + let buffer = tui.terminal.previous_buffer(); + dividers.push( + (0..width) + .find(|&x| buffer[(x, 0)].symbol() == "│") + .expect("painted divider"), + ); + } + } + insta::assert_snapshot!(format!("{dividers:?}"), @"[63, 81, 81, 72]"); + tokio::time::resume(); + app.local_settings.tui.animations = false; } keys!(prefix, if width == 100 { minus } else { side }); assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id)); @@ -3728,7 +3750,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() assert!(footer.1 < end); keys!(prefix, KeyCode::Right.into()); assert_eq!(app.transcript_view.search_footer(width), Some(footer)); - assert!(!app.side_threads[&side_thread_id].pane_open); + assert!(app.open_pane_focus().is_none()); app.transcript_view.cancel_search(); boxed_future!(app.handle_start_side( &mut tui, @@ -3815,7 +3837,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() keys!(prefix, KeyCode::Char('h').into()); assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id)); - assert!(app.side_threads[&side_thread_id].pane_open); + assert!(app.open_pane_focus().is_some()); side_frame(&mut app, &mut tui, Size::new(/*width*/ 99, /*height*/ 18))?; assert!(app.transcript_view.begin_select_mode(&app.transcript_cells)); keys!(KeyCode::Char('v').into(), KeyCode::Right.into()); @@ -4065,6 +4087,45 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() app.local_settings.tui.sidebar_width_percent = Some(30); side_frame(&mut app, &mut tui, even)?; assert_eq!(app.conversation_area(even.into()).width, 139); + tokio::time::pause(); + keys!(prefix); + side_frame(&mut app, &mut tui, even)?; + assert_eq!(app.conversation_frame.leader_hud, None); + tokio::time::advance(super::workspace_hud::REVEAL_DELAY).await; + let composer = app.render_owned_transcript(&mut tui, even)?; + let panel_text = |app: &App, tui: &tui::Tui| { + let mut panel = ratatui::buffer::Buffer::empty( + app.conversation_frame + .leader_hud + .expect("visible shortcut panel"), + ); + for pos in panel.area.positions() { + panel[pos] = tui.terminal.previous_buffer()[pos].clone(); + } + buffer_text(&panel) + }; + app.transcript_view.jump_to_latest(); + let hud = app + .conversation_frame + .leader_hud + .expect("visible shortcut panel"); + let scroll = mouse(MouseEventKind::ScrollUp, hud.left(), hud.top()); + boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, scroll)).await?; + app.render_owned_transcript(&mut tui, even)?; + assert!(!app.transcript_view.is_following()); + insta::assert_snapshot!("workspace_leader_hints", panel_text(&app, &tui)); + app.reconnect.offline = true; + app.render_owned_transcript(&mut tui, even)?; + let offline = panel_text(&app, &tui); + assert!(!offline.contains("Pane right") && offline.contains("Zoom pane")); + app.reconnect.offline = false; + let draft = app.chat_widget.composer_text_with_pending(); + keys!(KeyCode::Char('p').into()); + assert_eq!(app.render_owned_transcript(&mut tui, even)?, composer); + insta::assert_snapshot!("workspace_presets", panel_text(&app, &tui)); + keys!(KeyCode::Esc.into()); + assert_eq!(app.chat_widget.composer_text_with_pending(), draft); + tokio::time::resume(); keys!(prefix, KeyCode::Char('r').into(), KeyCode::Left.into()); let resizing = side_frame(&mut app, &mut tui, even)?; let hint = resizing @@ -7568,6 +7629,7 @@ async fn make_test_app() -> Box { pending_server_profiles: HashMap::new(), agents_overview: Default::default(), side_threads: HashMap::new(), + sidebars: HashMap::new(), side_companions: HashMap::new(), zoomed_thread: None, exact_sidebar_width: None, @@ -7706,6 +7768,7 @@ pub(super) async fn make_test_app_with_channels() -> ( pending_server_profiles: HashMap::new(), agents_overview: Default::default(), side_threads: HashMap::new(), + sidebars: HashMap::new(), side_companions: HashMap::new(), zoomed_thread: None, exact_sidebar_width: None, diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 9b5acde43e..a5e27ece45 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -1195,6 +1195,73 @@ async fn pane_direction_without_neighbor_preserves_fresh_typing() -> Result<()> Ok(()) } +#[tokio::test] +async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let mut server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + super::enable_side_retention(&mut app)?; + tui.set_owned_screen(/*owned*/ true)?; + let parent = ThreadId::new(); + app.chat_widget + .handle_thread_session(test_thread_session(parent, app.config.cwd.to_path_buf())); + app.chat_widget.insert_str("parent draft"); + + let size = ratatui::layout::Size::new(120, 12); + app.local_settings.tui.animations = true; + tokio::time::pause(); + let open_chooser = [ctrl('x'), KeyCode::Char('s').into()]; + // The second opening reverses a half-finished close. + for (key, widths) in [(KeyCode::Esc, [1, 20, 40]), (KeyCode::Enter, [20, 30, 40])] { + for stroke in open_chooser { + Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?; + } + for (elapsed, width) in [0, 90, 90].into_iter().zip(widths) { + tokio::time::advance(std::time::Duration::from_millis(elapsed)).await; + super::side_frame(&mut app, &mut tui, size)?; + assert_eq!(app.conversation_frame.chooser_area().unwrap().width, width); + } + if key == KeyCode::Esc { + let frame = super::side_frame(&mut app, &mut tui, size)?; + let panel: Vec<_> = frame + .lines() + .map(|line| line.chars().skip(80).collect::()) + .filter(|line| !line.trim().is_empty()) + .collect(); + insta::assert_snapshot!("sidebar_new_chat", panel.join("\n")); + for stroke in [ + ctrl('x'), + KeyCode::Char('c').into(), + KeyCode::Char('!').into(), + KeyCode::Left.into(), // Flush buffered typing before the focus change. + ] { + Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?; + } + assert!(super::side_frame(&mut app, &mut tui, size)?.contains("Open in sidebar")); + for stroke in open_chooser { + Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?; + } + } + Box::pin(press(&mut app, &mut tui, &mut server, key.into())).await?; + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "parent draft!" + ); + let dispatched = std::iter::from_fn(|| events.try_recv().ok()) + .any(|event| matches!(event, AppEvent::StartSide { parent_thread_id, .. } if parent_thread_id == parent)); + assert_eq!(dispatched, key == KeyCode::Enter); + if key == KeyCode::Esc { + super::side_frame(&mut app, &mut tui, size)?; + tokio::time::advance(std::time::Duration::from_millis(90)).await; + super::side_frame(&mut app, &mut tui, size)?; + assert_eq!(app.conversation_frame.chooser_area().unwrap().width, 20); + } + } + tokio::time::resume(); + server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn key_capture_receives_voice_shortcut_and_chord_prefix() -> Result<()> { let (mut app, mut rx, _op_rx) = super::make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs index 1b5eb683df..ee2b1d7ed3 100644 --- a/codex-rs/tui/src/app/tests/pagination_completion_tests.rs +++ b/codex-rs/tui/src/app/tests/pagination_completion_tests.rs @@ -164,6 +164,10 @@ async fn older_page_updates_the_parked_parent_without_moving_focus() -> Result<( app.chat_widget .handle_side_thread_session(test_thread_session(child, app.config.cwd.to_path_buf())); app.active_thread_id = Some(child); + app.sidebar_mut().show( + sidebar::SidebarContent::Chat(child), + sidebar::PaneFocus::Sidebar, + ); let size = Size::new(/*width*/ 120, /*height*/ 18); tui.terminal = crate::tui::Terminal::with_screen_size_and_cursor_position_for_test( ratatui::backend::CrosstermBackend::new(std::io::stdout()), diff --git a/codex-rs/tui/src/app/tests/patch_approval_tests.rs b/codex-rs/tui/src/app/tests/patch_approval_tests.rs index 283be96d30..b1c10f9b0a 100644 --- a/codex-rs/tui/src/app/tests/patch_approval_tests.rs +++ b/codex-rs/tui/src/app/tests/patch_approval_tests.rs @@ -174,9 +174,23 @@ async fn active_patch_approval_pager_preserves_changes_and_accepts_once() -> Res let mut tui = crate::tui::test_support::make_test_tui()?; let mut app_server = start_config_write_test_app_server(&app).await?; enqueue_pending_patch(&mut app, thread_id).await?; - drain_pending_patch(&mut app, &mut tui).await?; - + enable_side_retention(&mut app)?; tui.set_owned_screen(/*owned*/ true)?; + app.sidebar_mut().show( + sidebar::SidebarContent::Chooser, + sidebar::PaneFocus::Sidebar, + ); + let narrow = Size::new(/*width*/ 80, /*height*/ 24); + assert!(side_frame(&mut app, &mut tui, narrow)?.contains("Open in sidebar")); + drain_pending_patch(&mut app, &mut tui).await?; + app.handle_tui_event( + &mut tui, + &mut app_server, + TuiEvent::Key(KeyCode::Enter.into()), + ) + .await?; + assert!(app.chat_widget.has_active_modal()); + assert!(!side_frame(&mut app, &mut tui, narrow)?.contains("Open in sidebar")); while let Ok(event) = rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event { app.insert_history_cell(&mut tui, cell); diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap new file mode 100644 index 0000000000..100c1c7b47 --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__key_chords__sidebar_new_chat.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/app/tests/key_chords.rs +assertion_line: 1131 +expression: "panel.join(\"\\n\")" +--- + + + Open in sidebar + › + New side chat + Start from this conversation + Enter open · Esc back · ↑/↓ move … diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 91c22cabff..97e15e0f40 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -114,6 +114,9 @@ impl App { } else { None }; + if let Some(startup_pane) = self.sidebars.remove(&None) { + self.sidebars.entry(Some(thread_id)).or_insert(startup_pane); + } self.active_thread_id = Some(thread_id); self.active_thread_rx = receiver; self.refresh_pending_thread_approvals().await; diff --git a/codex-rs/tui/src/app/workspace_hud.rs b/codex-rs/tui/src/app/workspace_hud.rs new file mode 100644 index 0000000000..dc7fb0cc77 --- /dev/null +++ b/codex-rs/tui/src/app/workspace_hud.rs @@ -0,0 +1,94 @@ +//! Show the active conversation’s leader shortcuts after the reveal delay. +//! Dispatch stays with the originating control, including before the reveal delay elapses. + +use super::App; +use crate::bottom_pane::ShortcutHud; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use std::time::Duration; + +/// Let fast two-key shortcuts complete before showing the panel. +pub(super) const REVEAL_DELAY: Duration = Duration::from_millis(350); + +impl App { + pub(super) fn workspace_hud(&self, tui: &crate::tui::Tui, area: Rect) -> Option { + if !self.conversation_focus_available(tui) || self.conversation_frame.has_caller_inset() { + return None; + } + if self.chat_widget.quick_dial_menu_open() { + return Some(self.chat_widget.quick_dial_hud()); + } + let leader = self.key_chord_matcher.workspace_leader()?; + let wait = REVEAL_DELAY.saturating_sub(leader.started_at.elapsed()); + if !wait.is_zero() { + tui.frame_requester().schedule_frame_in(wait); + return None; + } + let side = self.active_side_parent_thread_id().is_some() + || self.chooser_focus() == Some(super::sidebar::PaneFocus::Sidebar); + let can_switch = self.chooser_focus().is_some() || self.switchable_side_child().is_some(); + let can_size = super::side_pane::split_layout(self, tui, area).is_some(); + let can_resize = can_size && side && self.zoomed_thread.is_none(); + let mut actions = Vec::<(&str, Vec)>::new(); + for binding in leader.completions(&self.keymap.chords) { + let action = binding.action.action; + let key = binding.chord.completion.display_label(); + if let Some((_, keys)) = actions.iter_mut().find(|(id, _)| *id == action) { + keys.push(key); + } else { + actions.push((action, vec![key])); + } + } + let mut groups = [ + ("Panes", Vec::new()), + ("Move", Vec::new()), + ("This pane", Vec::new()), + ]; + for (action, keys) in actions { + let (group, label) = match action { + "focus_command_center" => (0, "Command Center"), + "focus_main_composer" => (0, "Main composer"), + "focus_sidebar_content" if side => (0, "Hide sidebar"), + "focus_sidebar_content" => (0, "Sidebar content"), + "resize_panes" if can_size => (2, "Resize panes"), + "resize_panes" => continue, + "minimize_pane_width" if can_size => (2, "Minimum width"), + "maximize_pane_width" if can_size => (2, "Maximum width"), + "equalize_pane_widths" if can_size => (2, "Equal widths"), + "minimize_pane_width" | "maximize_pane_width" | "equalize_pane_widths" => continue, + "move_divider_left" if can_resize => (2, "Divider left"), + "move_divider_right" if can_resize => (2, "Divider right"), + "toggle_pane_zoom" if self.zoomed_thread.is_some() => (2, "Restore layout"), + "toggle_pane_zoom" => (2, "Zoom pane"), + "move_divider_left" | "move_divider_right" => continue, + "hide_pane" if side => (2, "Hide pane"), + "focus_pane_left" if side && can_switch => (1, "Pane left"), + "focus_pane_right" if !side && can_switch && self.open_pane_focus().is_some() => { + (1, "Pane right") + } + "hide_pane" | "focus_pane_left" | "focus_pane_right" => continue, + "open_presets" => (2, "Presets ›"), + "find_transcript" => (2, "Find text"), + "select_transcript" => (2, "Select transcript"), + "focus_conversation_composer" => (2, "Composer"), + other => (2, other), + }; + let key = keys.join(" / "); + groups[group].1.push( + vec![ + format!("{key:<7} ").fg(crate::style::accent_color()), + label.replace('_', " ").into(), + ] + .into(), + ); + } + let origin = if side { "Sidebar" } else { "Conversation" }; + Some(ShortcutHud::leader( + format!("{} · {origin}", leader.prefix.display_label()), + groups + .into_iter() + .filter(|(_, items)| !items.is_empty()) + .collect(), + )) + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fb7e395795..885ff973e8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -705,6 +705,7 @@ pub(crate) enum AppEvent { /// Deliver the original pointer press after the target conversation's replay finishes. SidePanePressAfterReplay { + peer: ThreadId, draft_cursor: Option, thread_id: ThreadId, area: ratatui::layout::Rect, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index 85ba7e945e..ec84b6def4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -84,6 +84,8 @@ pub(crate) struct ComposerRenderOptions<'a> { pub(crate) separate_status_line: bool, /// Topmost frame row available for suggestions overlaying the transcript. pub(crate) overlay_top: u16, + /// The workspace paints the open Presets menu over the completed frame. + pub(crate) floating_shortcuts: bool, pub(crate) command_popup_placement: CommandPopupPlacement, pub(crate) footer: Option<&'a TranscriptFooter>, } diff --git a/codex-rs/tui/src/bottom_pane/shortcut_hud.rs b/codex-rs/tui/src/bottom_pane/shortcut_hud.rs index ff2e66d01d..b9de524523 100644 --- a/codex-rs/tui/src/bottom_pane/shortcut_hud.rs +++ b/codex-rs/tui/src/bottom_pane/shortcut_hud.rs @@ -1,10 +1,12 @@ -//! Passive shortcut hints arranged in responsive columns above the composer. +//! Passive shortcut hints arranged in responsive columns for composer menus and floating workspace overlays. use super::selection_popup_common::menu_surface_inset; use super::selection_popup_common::menu_surface_padding_height; use super::selection_popup_common::render_menu_surface; use crate::line_truncation::line_width; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::Insets; +use crate::render::RectExt as _; use crate::render::renderable::Renderable; use crate::style::accent_color; use crate::style::secondary_text_style; @@ -14,6 +16,7 @@ use ratatui::style::Styled; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; @@ -24,10 +27,15 @@ const SINGLE_COLUMN_ENTRY_LIMIT: usize = 5; pub(crate) struct ShortcutHud { title: String, - entries: Vec>, + entries: HudEntries, commands: Vec>, } +enum HudEntries { + Flat(Vec>), + Grouped(Vec<(&'static str, Vec>)>), +} + impl ShortcutHud { pub(crate) fn new( title: impl Into, @@ -36,11 +44,19 @@ impl ShortcutHud { ) -> Self { Self { title: title.into(), - entries, + entries: HudEntries::Flat(entries), commands, } } + pub(crate) fn leader(title: String, groups: Vec<(&'static str, Vec>)>) -> Self { + Self { + title, + entries: HudEntries::Grouped(groups), + commands: vec!["Esc cancel".dim().into()], + } + } + pub(crate) fn item(key: impl Into, label: Span<'static>) -> Line<'static> { vec![ key.into().fg(accent_color()), @@ -51,13 +67,18 @@ impl ShortcutHud { } fn body_lines(&self, width: u16) -> Vec> { - let mut lines = vec![self.title.clone().bold().into(), Line::default()]; - let max_columns = if self.entries.len() > SINGLE_COLUMN_ENTRY_LIMIT { - 2 - } else { - 1 - }; - lines.extend(grid_lines(&self.entries, width, max_columns)); + let mut lines = vec![Line::default()]; + lines.extend(match &self.entries { + HudEntries::Flat(entries) => { + let limit = if entries.len() > SINGLE_COLUMN_ENTRY_LIMIT { + 2 + } else { + 1 + }; + grid_lines(entries, width, limit) + } + HudEntries::Grouped(groups) => grouped_lines(groups, width), + }); lines } @@ -81,12 +102,51 @@ impl ShortcutHud { }) .collect() } -} -impl Renderable for ShortcutHud { - fn render(&self, area: Rect, buf: &mut Buffer) { - let content = render_menu_surface(area, buf); - let commands = self.command_lines(content.width); + pub(crate) fn render_overlay(self, viewport: Rect, buf: &mut Buffer) -> Rect { + let bounds = viewport.inset(Insets::tlbr( + /*top*/ 0, /*left*/ 0, /*bottom*/ 1, /*right*/ 2, + )); + let width = viewport.width.div_ceil(2).max(32).min(bounds.width); + let inner_width = width.saturating_sub(4); + let body = self.body_lines(inner_width); + let commands = self.command_lines(inner_width); + // Reserve a row for the title and a gap above the commands. + let height = u16::try_from(body.len() + 2 + commands.len()) + .unwrap_or(u16::MAX) + .min(bounds.height); + let area = Rect::new( + bounds.right() - width, + bounds.bottom() - height, + width, + height, + ); + Clear.render(area, buf); + buf.set_style(area, crate::style::shortcut_hud_style()); + Line::from(vec![ + "─ ".dim(), + self.title.bold(), + " ".into(), + "─".repeat(usize::from(width)).dim(), + ]) + .render(area, buf); + Self::render_lines( + body, + commands, + area.inset(Insets::tlbr( + /*top*/ 1, /*left*/ 2, /*bottom*/ 0, /*right*/ 2, + )), + buf, + ); + area + } + + fn render_lines( + lines: Vec>, + commands: Vec>, + content: Rect, + buf: &mut Buffer, + ) { let command_height = u16::try_from(commands.len()) .unwrap_or(u16::MAX) .min(content.height); @@ -103,9 +163,19 @@ impl Renderable for ShortcutHud { height: command_height, ..content }; - render_clipped(self.body_lines(content.width), body, buf); + render_clipped(lines, body, buf); Paragraph::new(commands).render(command_area, buf); } +} + +impl Renderable for ShortcutHud { + fn render(&self, area: Rect, buf: &mut Buffer) { + let content = render_menu_surface(area, buf); + let commands = self.command_lines(content.width); + let mut lines = self.body_lines(content.width); + lines.insert(0, self.title.clone().bold().into()); + Self::render_lines(lines, commands, content, buf); + } fn desired_height(&self, width: u16) -> u16 { let content_width = menu_surface_inset(Rect::new( @@ -115,7 +185,7 @@ impl Renderable for ShortcutHud { /*height*/ u16::MAX, )) .width; - let body_height = self.body_lines(content_width).len(); + let body_height = 1 + self.body_lines(content_width).len(); let command_height = self.command_lines(content_width).len(); u16::try_from(body_height + 1 + command_height) .unwrap_or(u16::MAX) @@ -123,22 +193,48 @@ impl Renderable for ShortcutHud { } } +fn grouped_lines(groups: &[(&'static str, Vec>)], width: u16) -> Vec> { + let count = column_count(width, groups.len()); + let groups_per_column = groups.len().div_ceil(count).max(1); + let mut columns = vec![Vec::new(); count]; + for (index, (title, entries)) in groups.iter().enumerate() { + let column = &mut columns[index / groups_per_column]; + if !column.is_empty() { + column.push(Line::default()); + } + column.push((*title).dim().into()); + column.extend(entries.iter().cloned()); + } + column_lines(&columns, width) +} + +fn column_count(width: u16, limit: usize) -> usize { + ((usize::from(width) + COLUMN_GAP) / (MIN_COLUMN_WIDTH + COLUMN_GAP)).clamp(1, limit.max(1)) +} + fn grid_lines(items: &[Line<'static>], width: u16, max_columns: usize) -> Vec> { - let width = usize::from(width); - let columns = ((width + COLUMN_GAP) / (MIN_COLUMN_WIDTH + COLUMN_GAP)) - .clamp(/*min*/ 1, max_columns) - .min(items.len().max(/*other*/ 1)); - let rows = items.len().div_ceil(columns); - let cell_width = width.saturating_sub(COLUMN_GAP * (columns - 1)) / columns; + let count = column_count(width, max_columns.min(items.len())); + let rows = items.len().div_ceil(count).max(1); + column_lines(&items.chunks(rows).collect::>(), width) +} + +fn column_lines(columns: &[impl AsRef<[Line<'static>]>], width: u16) -> Vec> { + let rows = columns + .iter() + .map(|column| column.as_ref().len()) + .max() + .unwrap_or_default(); + let count = columns.len().max(1); + let cell_width = usize::from(width).saturating_sub(COLUMN_GAP * (count - 1)) / count; (0..rows) .map(|row| { let mut line = Line::default(); - for column in 0..columns { - let item = items.get(column * rows + row).cloned().unwrap_or_default(); + for (index, column) in columns.iter().enumerate() { + let item = column.as_ref().get(row).cloned().unwrap_or_default(); let item = truncate_line_with_ellipsis_if_overflow(item, cell_width); let padding = cell_width.saturating_sub(line_width(&item)) + COLUMN_GAP; line.extend(item.spans); - if column + 1 < columns { + if index + 1 < count { line.push_span(" ".repeat(padding)); } } diff --git a/codex-rs/tui/src/chatwidget/quick_dials.rs b/codex-rs/tui/src/chatwidget/quick_dials.rs index 69f0bdbf31..7c77531253 100644 --- a/codex-rs/tui/src/chatwidget/quick_dials.rs +++ b/codex-rs/tui/src/chatwidget/quick_dials.rs @@ -49,7 +49,11 @@ fn quick_dial_label(dial: &QuickDial) -> String { } impl ChatWidget { - pub(super) fn quick_dial_hud(&self) -> ShortcutHud { + pub(crate) fn quick_dial_menu_open(&self) -> bool { + self.quick_dial_menu + } + + pub(crate) fn quick_dial_hud(&self) -> ShortcutHud { let mut entries: Vec<_> = (1..=9) .chain(std::iter::once(/*value*/ 0)) .filter_map(|slot| { @@ -505,8 +509,11 @@ impl ChatWidget { if !self.bottom_pane.no_modal_or_popup_active() { return; } - self.set_footer_hint_override(Some(Vec::new())); + if !self.local_settings.workspace_enabled(&self.config) { + self.set_footer_hint_override(Some(Vec::new())); + } self.quick_dial_menu = true; + self.request_redraw(); } } diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index 06764559c7..1a78db7a24 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -291,7 +291,7 @@ impl ChatWidget { options.separate_status_line = options.command_popup_placement != crate::bottom_pane::CommandPopupPlacement::AboveComposer; let bottom_pane = self.bottom_pane.backdrop_with_options(options); - let above_composer = if self.quick_dial_menu { + let above_composer = if self.quick_dial_menu && !options.floating_shortcuts { RenderableItem::Owned(Box::new(self.quick_dial_hud())) } else if let Some(pending) = &self.safety_recourse && !self.bottom_pane.has_active_view() diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 2774779fa6..fbaf304067 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -117,6 +117,10 @@ impl ChatWidget { }); } + pub(crate) fn sidebar_chooser_available(&self) -> bool { + !self.review.is_review_mode && !self.is_external_writer_view() + } + fn request_empty_side_conversation(&mut self, cmd: SlashCommand) { let Some(parent_thread_id) = self.thread_id else { let command = cmd.command(); @@ -157,7 +161,7 @@ impl ChatWidget { || (cmd == SlashCommand::Export && self.input_queue.suppress_queue_autosend) } - pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) { + pub(crate) fn dispatch_command(&mut self, cmd: SlashCommand) { self.dispatch_command_from_source(cmd, SlashCommandDispatchSource::Live); } diff --git a/codex-rs/tui/src/footer_hint.rs b/codex-rs/tui/src/footer_hint.rs index 5ae854aa02..1a3a696594 100644 --- a/codex-rs/tui/src/footer_hint.rs +++ b/codex-rs/tui/src/footer_hint.rs @@ -28,6 +28,40 @@ pub(crate) fn shortcut(keys: &str, label: &str) -> Line<'static> { Line::from(spans) } +/// The same measured spans determine which hints fit and where each one can be clicked. +pub(crate) struct HintLine { + pub(crate) line: Line<'static>, + pub(crate) items: Vec>, +} + +impl HintLine { + pub(crate) fn new(hints: impl IntoIterator>, width: u16) -> Self { + let mut hints = hints.into_iter().peekable(); + let mut line = Line::default(); + let mut items = Vec::new(); + let mut end = 0; + while let Some(hint) = hints.next() { + let start = end + if items.is_empty() { 0 } else { 3 }; + let next = start + hint.width(); + let overflow_space = if hints.peek().is_some() { 2 } else { 0 }; + if next + overflow_space > usize::from(width) { + if width > 0 { + let marker = if items.is_empty() { "…" } else { " …" }; + line.push_span(marker.set_style(secondary_text_style())); + } + break; + } + if !items.is_empty() { + line.push_span(" · ".set_style(secondary_text_style())); + } + line.extend(hint.spans); + items.push(start as u16..next as u16); + end = next; + } + Self { line, items } + } +} + /// Choose a complete hint variant so narrow surfaces never display half a shortcut. pub(crate) fn first_fitting_line( candidates: impl IntoIterator>, diff --git a/codex-rs/tui/src/keymap/chords.rs b/codex-rs/tui/src/keymap/chords.rs index e8d745fe70..82449698f7 100644 --- a/codex-rs/tui/src/keymap/chords.rs +++ b/codex-rs/tui/src/keymap/chords.rs @@ -59,6 +59,7 @@ const MAIN_COMPOSER_FOCUS: u32 = 1 << 23; const PANE_DIRECTION: u32 = 1 << 24; const OPEN_PANE: u32 = 1 << 25; const FOCUSED_SIDE_PANE: u32 = 1 << 26; +const SIDEBAR_CHOOSER: u32 = 1 << 27; const TRANSCRIPT_BROWSING: u32 = 1 << 14; @@ -177,6 +178,14 @@ impl KeymapContextSet { Self(self.0 | FOCUSED_SIDE_PANE) } + pub(crate) const fn focused_sidebar_chooser() -> Self { + Self(SIDE_PANE | PANE_DIRECTION | MAIN_COMPOSER_FOCUS) + } + + pub(crate) const fn with_sidebar_chooser(self) -> Self { + Self(self.0 | SIDEBAR_CHOOSER) + } + pub(crate) const fn with_side_pane(self) -> Self { Self(self.0 | SIDE_PANE) } @@ -196,6 +205,7 @@ impl KeymapContextSet { | "maximize_pane_width" | "resize_panes" => OPEN_PANE, "focus_main_composer" => MAIN_COMPOSER_FOCUS, + "focus_sidebar_content" => SIDE_PANE | SIDEBAR_CHOOSER, _ => SIDE_PANE, }; return self.0 & required != 0; @@ -524,13 +534,27 @@ pub(crate) enum KeyChordMatch { } #[derive(Clone, Copy, Debug)] -struct PendingChord { - // Persistent leader menus have no deadline. - started_at: Option, - prefix: KeyBinding, +pub(crate) struct PendingChord { + pub(crate) started_at: Instant, + persistent: bool, + pub(crate) prefix: KeyBinding, contexts: KeymapContextSet, } +impl PendingChord { + pub(crate) fn completions( + self, + keymap: &RuntimeChordKeymap, + ) -> impl Iterator { + keymap.bindings.iter().filter(move |binding| { + let (code, modifiers) = binding.chord.completion.parts(); + keymap + .binding_for_completion(self.prefix, KeyEvent::new(code, modifiers), self.contexts) + .is_some_and(|active| std::ptr::eq(active, *binding)) + }) + } +} + /// Tracks one pending chord or leader menu without buffering ordinary input. #[derive(Clone, Debug, Default)] pub(crate) struct KeyChordMatcher { @@ -542,6 +566,12 @@ impl KeyChordMatcher { self.pending.is_some() } + /// Only workspace leaders use the passive overlay. Other chord prefixes retain their footer. + pub(crate) fn workspace_leader(&self) -> Option { + self.pending + .filter(|p| p.persistent && p.contexts.0 & PANE_DIRECTION != 0) + } + /// Derive one presentation for the pending chord from the matcher that owns it. pub(crate) fn pending_hint_items( &self, @@ -550,23 +580,9 @@ impl KeyChordMatcher { ) -> Option> { let pending = self.pending?; let mut items = vec![(pending.prefix.display_label(), "then".to_string())]; - for binding in keymap - .bindings - .iter() - .filter(|binding| { - let (code, modifiers) = binding.chord.completion.parts(); - keymap - .binding_for_completion( - pending.prefix, - KeyEvent::new(code, modifiers), - pending.contexts, - ) - .is_some_and(|active| std::ptr::eq(active, *binding)) - }) - .take(/*n*/ 6) - { + for binding in pending.completions(keymap).take(/*n*/ 6) { let label = match binding.action.action { - "open_presets" if pending.started_at.is_none() => "presets", + "open_presets" if pending.persistent => "presets", "change_host" => "cycle", "focus_activity" => "activity", "find_transcript" => "find", @@ -610,9 +626,7 @@ impl KeyChordMatcher { pub(crate) fn expire(&mut self, contexts: KeymapContextSet) -> bool { if self.pending.is_some_and(|pending| { pending.contexts != contexts - || pending - .started_at - .is_some_and(|started_at| started_at.elapsed() >= KEY_CHORD_TIMEOUT) + || (!pending.persistent && pending.started_at.elapsed() >= KEY_CHORD_TIMEOUT) }) { self.pending = None; return true; @@ -641,7 +655,7 @@ impl KeyChordMatcher { if let Some(pending) = self.pending.take() { if crate::key_hint::plain(KeyCode::Esc).is_press(key_event) - || (pending.started_at.is_none() && chord_stroke_matches(pending.prefix, key_event)) + || (pending.persistent && chord_stroke_matches(pending.prefix, key_event)) { return KeyChordMatch::Cancelled; } @@ -674,7 +688,8 @@ impl KeyChordMatcher { && binding.action.context.overlaps(KeymapContext::Global))) }); self.pending = Some(PendingChord { - started_at: (!persistent).then(Instant::now), + started_at: Instant::now(), + persistent, prefix, contexts, }); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c77148e9e6..3e67749c8d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -211,6 +211,7 @@ mod session_resume; mod session_start; mod session_state; mod shortcut_help; +mod sidebar_chooser; mod skills_helpers; mod slash_command; mod startup_draft; diff --git a/codex-rs/tui/src/sidebar_chooser.rs b/codex-rs/tui/src/sidebar_chooser.rs new file mode 100644 index 0000000000..1ed3c35e89 --- /dev/null +++ b/codex-rs/tui/src/sidebar_chooser.rs @@ -0,0 +1,81 @@ +//! The empty sidebar belongs to its parent conversation and can remain open without taking input. + +use crate::footer_hint; +use crate::keymap::KeymapContext; +use crate::keymap::RuntimeKeymap; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::Widget; + +const WIDE_WIDTH: u16 = 58; + +fn body_row(area: Rect, y: u16) -> Rect { + Rect::new(area.x + 1, y, area.width.saturating_sub(3), 1) +} + +pub(crate) fn footer(keymap: &RuntimeKeymap, area: Rect) -> footer_hint::HintLine { + let hide = keymap + .primary_hint(KeymapContext::Global, "hide_pane") + .map(|hint| footer_hint::shortcut(&hint.display_label(), "hide pane")); + let hints = [ + footer_hint::shortcut("Enter", "open"), + footer_hint::shortcut("Esc", "back"), + footer_hint::shortcut("↑/↓", "move"), + ]; + footer_hint::HintLine::new(hints.into_iter().chain(hide), body_row(area, area.y).width) +} + +pub(crate) fn choice_contains(area: Rect, y: u16) -> bool { + let rows = if body_row(area, area.y).width < WIDE_WIDTH { + 4..6 + } else { + 4..5 + }; + 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) { + Clear.render(area, buf); + if area.is_empty() { + return; + } + let style = if focused { + crate::style::selection_style() + } 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() + } else { + crate::bottom_pane::active_tab_style() + }; + Line::from(" + ").style(tab_style).render(add, buf); + let row = |y| body_row(area, y); + let footer_y = area.bottom() - 1; + let mut paint = |offset, line: Line<'_>| { + let y = area.y.saturating_add(offset); + if y < footer_y { + line.render(row(y), buf); + } + }; + paint(2, Line::from("Open in sidebar").bold()); + let detail = "Start from this conversation"; + let title = "› + New side chat"; + let width = row(area.y).width; + if width >= WIDE_WIDTH { + let pad = usize::from(width - 1) - detail.len(); + paint(4, Line::from(format!("{title: area.y { + footer(keymap, area).line.render(row(footer_y), buf); + } +} diff --git a/codex-rs/tui/src/style.rs b/codex-rs/tui/src/style.rs index e4db1c1ebc..dd73a7ccf2 100644 --- a/codex-rs/tui/src/style.rs +++ b/codex-rs/tui/src/style.rs @@ -84,6 +84,20 @@ pub fn user_message_style() -> Style { user_message_style_for(default_bg()) } +/// Blend the floating HUD slightly away from the terminal background in either theme. +const SHORTCUT_HUD_TINT: f32 = 0.052; + +pub(crate) fn shortcut_hud_style() -> Style { + default_bg().map_or_else(Style::default, |bg| { + let shade = if is_light(bg) { 0 } else { 255 }; + Style::default().bg(best_color(blend( + (shade, shade, shade), + bg, + SHORTCUT_HUD_TINT, + ))) + }) +} + /// Submitted prompts use a lighter fill than the editable composer in either theme. pub(crate) fn history_prompt_style() -> Style { let Some(background) = default_bg() else {