Add a sidebar chooser and floating workspace shortcut panels (#59739)

## What changed

- Open a sidebar chooser when no side chat exists, with keyboard and mouse controls to start a new side chat while preserving the parent draft.
- Animate sidebar opening and closing, including reversing an unfinished transition, when animations are enabled.
- Show context-aware workspace shortcuts in a floating panel after a short delay, and render presets in the same overlay without moving the composer.
- Prevent chooser input from accepting an approval dialog before it has been painted.

## Testing

Add coverage for chooser activation and draft preservation, sidebar animation reversal, shortcut and preset snapshots, and approval input handling.

GitOrigin-RevId: 014112622a94798f4e611756c55112dfd8016051
This commit is contained in:
Felipe Coury
2026-11-05 21:05:51 +00:00
committed by copyberry
parent 9abfbcea0d
commit 194f2662b8
35 changed files with 1151 additions and 176 deletions
+55 -8
View File
@@ -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<ThreadId, SideThreadState>,
sidebars: HashMap<Option<ThreadId>, sidebar::SidebarPane>,
/// A parked parent view survives child cleanup until its own replay finishes.
side_companions: HashMap<ThreadId, SideCompanion>,
zoomed_thread: Option<ThreadId>,
@@ -888,10 +892,37 @@ impl App {
) -> Result<AppRunControl> {
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()
+1 -2
View File
@@ -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);
+8 -5
View File
@@ -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))?;
}
+12
View File
@@ -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
}
+8 -6
View File
@@ -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))
})
{
+91 -22
View File
@@ -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<Rect>,
pub(super) side_pane: Option<side_pane::SidePaneLayout>,
sidebar: Option<sidebar::SidebarLayout>,
pub(super) leader_hud: Option<Rect>,
/// A dialog can obscure neighboring content in the terminal's completed buffer.
behind_dialog: Option<ratatui::buffer::Buffer>,
caller_images: Vec<ImagePlacement>,
}
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<Rect> {
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<ComposerState>,
@@ -203,21 +229,26 @@ impl App {
) -> Result<Rect> {
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;
+39
View File
@@ -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<FrameRequester>) -> 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
}
}
+9 -4
View File
@@ -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)?;
}
}
+16 -4
View File
@@ -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();
+18 -9
View File
@@ -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<SideParentStatus>,
/// 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<ThreadId> {
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<ThreadId> {
@@ -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<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(message) = self.side_start_block_message() {
self.restore_side_user_message(user_message.take());
self.sync_side_thread_ui();
+99 -2
View File
@@ -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 {
+75 -55
View File
@@ -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<Sid
split_layout(app, tui, area)
}
fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option<SidePaneLayout> {
pub(super) fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option<SidePaneLayout> {
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<SidePaneLayout> {
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<SidePaneLayout> {
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<SidePaneLayout> {
}
_ => 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<DividerDrag>,
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<DividerDrag>,
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);
}
}
+124
View File
@@ -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<PaneFocus>,
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<SidebarContent> {
self.focus.map(|_| self.content)
}
}
impl App {
fn sidebar_parent(&self) -> Option<ThreadId> {
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<PaneFocus> {
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<SidebarLayout> {
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)
}
}
}
@@ -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
@@ -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
+1
View File
@@ -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,
+1
View File
@@ -124,6 +124,7 @@ pub(crate) async fn make_test_app() -> Box<App> {
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,
+65 -2
View File
@@ -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<App> {
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,
+67
View File
@@ -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::<String>())
.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;
@@ -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()),
@@ -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);
@@ -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 …
+3
View File
@@ -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;
+94
View File
@@ -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<ShortcutHud> {
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<String>)>::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(),
))
}
}
+1
View File
@@ -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<usize>,
thread_id: ThreadId,
area: ratatui::layout::Rect,
@@ -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>,
}
+122 -26
View File
@@ -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<Line<'static>>,
entries: HudEntries,
commands: Vec<Line<'static>>,
}
enum HudEntries {
Flat(Vec<Line<'static>>),
Grouped(Vec<(&'static str, Vec<Line<'static>>)>),
}
impl ShortcutHud {
pub(crate) fn new(
title: impl Into<String>,
@@ -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<Line<'static>>)>) -> Self {
Self {
title,
entries: HudEntries::Grouped(groups),
commands: vec!["Esc cancel".dim().into()],
}
}
pub(crate) fn item(key: impl Into<String>, label: Span<'static>) -> Line<'static> {
vec![
key.into().fg(accent_color()),
@@ -51,13 +67,18 @@ impl ShortcutHud {
}
fn body_lines(&self, width: u16) -> Vec<Line<'static>> {
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<Line<'static>>,
commands: Vec<Line<'static>>,
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<Line<'static>>)], width: u16) -> Vec<Line<'static>> {
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<Line<'static>> {
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::<Vec<_>>(), width)
}
fn column_lines(columns: &[impl AsRef<[Line<'static>]>], width: u16) -> Vec<Line<'static>> {
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));
}
}
+9 -2
View File
@@ -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();
}
}
+1 -1
View File
@@ -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()
@@ -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);
}
+34
View File
@@ -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<std::ops::Range<u16>>,
}
impl HintLine {
pub(crate) fn new(hints: impl IntoIterator<Item = Line<'static>>, 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<Item = Line<'static>>,
+40 -25
View File
@@ -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<Instant>,
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<Item = &RuntimeChordBinding> {
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<PendingChord> {
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<Vec<(String, String)>> {
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,
});
+1
View File
@@ -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;
+81
View File
@@ -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:<pad$}{detail}")).style(style));
} else {
paint(4, Line::from(title).style(style));
paint(5, Line::from(format!(" {detail}")).style(style));
}
if footer_y > area.y {
footer(keymap, area).line.render(row(footer_y), buf);
}
}
+14
View File
@@ -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 {