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