Enable resizing the sidebar chooser (#59824)

## Why

Sidebar resizing previously required an open side chat, leaving the chooser without the same width controls.

## What changed

- Support keyboard resizing, width presets, and divider dragging for the sidebar chooser, with resize hints that fit narrow sidebars.
- Keep keyboard resize mode active across startup thread assignment and replacement of the chooser by a new side chat, so `Enter` finishes resizing without submitting the parent draft.
- Activate resize bindings only when a split is available, and prevent divider drags from starting while the split is animating.

## Testing

Add regression coverage for chooser resizing, width preservation when switching between the chooser and a chat, startup and side-chat creation transitions, draft preservation, and unavailable resize bindings in narrow terminals.

GitOrigin-RevId: 511cd4bb5894e6f33f0cf5d7472bb3013361ffe0
This commit is contained in:
Felipe Coury
2026-11-06 04:53:30 +00:00
committed by copyberry
parent faeb379646
commit 5fab289359
12 changed files with 411 additions and 101 deletions
+9 -1
View File
@@ -250,12 +250,20 @@ impl App {
if self.chooser_focus() == Some(super::sidebar::PaneFocus::Sidebar)
&& self.sidebar_chooser_available()
{
return crate::keymap::KeymapContextSet::focused_sidebar_chooser();
let contexts = crate::keymap::KeymapContextSet::focused_sidebar_chooser();
return if self.can_nudge_side_width(tui) {
contexts.with_sidebar_resize().with_focused_side_pane()
} else {
contexts
};
}
let mut contexts = contexts.with_pane_directions();
if self.open_pane_focus().is_some() {
contexts = contexts.with_open_pane();
}
if self.can_resize_sidebar(tui) {
contexts = contexts.with_sidebar_resize();
}
if self.can_nudge_side_width(tui) {
contexts = contexts.with_focused_side_pane();
}
+13 -11
View File
@@ -133,21 +133,23 @@ impl App {
.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()
{
PointerShape::Default
} else if self.divider_drag.is_some()
|| tui.link_hover.mouse.is_some_and(|mouse| {
self.conversation_frame
.side_pane()
.is_some_and(|panes| panes.divider_contains(mouse))
})
} else if self.conversation_focus_available(tui)
&& (self.divider_drag.is_some()
|| tui.link_hover.mouse.is_some_and(|mouse| {
self.conversation_frame
.divider_area()
.is_some_and(|area| area.contains((mouse.column, mouse.row).into()))
&& self.divider_is_settled(tui, tui.terminal.last_known_screen_size.into())
}))
{
PointerShape::Resize
} else if tui
.link_hover
.mouse
.is_some_and(|mouse| self.transcript_link_at(mouse).is_some())
} else if self.chat_widget.no_modal_or_popup_active()
&& tui
.link_hover
.mouse
.is_some_and(|mouse| self.transcript_link_at(mouse).is_some())
{
PointerShape::Link
} else {
+34 -17
View File
@@ -34,7 +34,7 @@ use ratatui::widgets::Widget;
#[derive(Default)]
pub(super) struct ConversationFrame {
inset: Option<Rect>,
sidebar: Option<sidebar::SidebarLayout>,
pub(super) sidebar: Option<sidebar::SidebarLayout>,
pub(super) chat_strip: Option<Rect>,
pub(super) sidebar_input: Option<(usize, Option<ThreadId>, usize)>,
pub(super) leader_hud: Option<Rect>,
@@ -70,6 +70,10 @@ impl ConversationFrame {
_ => None,
}
}
pub(super) fn divider_area(&self) -> Option<Rect> {
self.sidebar.as_ref()?.divider_area()
}
}
pub(super) struct PreparedConversation {
@@ -267,8 +271,10 @@ impl App {
self.conversation_frame.chat_strip = strip;
self.conversation_frame.sidebar_input =
self.sidebar().map(sidebar::SidebarPane::input_state);
let divider = self.conversation_frame.divider_area();
if self.divider_drag.is_some_and(|drag| {
!drag.matches(screen_size.into(), self.conversation_frame.side_pane())
divider.is_none()
|| !drag.matches(screen_size.into(), self)
|| !self.conversation_focus_available(tui)
}) {
self.divider_drag = None;
@@ -304,24 +310,31 @@ impl App {
}
}
painted = self.paint_conversation(frame, &prepared);
let divider = match &self.conversation_frame.sidebar {
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,
}) => {
Some(sidebar::SidebarLayout::Chooser { sidebar, focus, .. }) => {
let focused = *focus == PaneFocus::Sidebar;
let resize_hint = self.pane_resize.map(|_| side_pane::RESIZE_HINT);
if let Some(creation) = self.sidebar().and_then(|pane| pane.creation.as_ref()) {
use sidebar::CreationState;
ratatui::widgets::Clear.render(*sidebar, frame.buffer);
let body = sidebar.inner(ratatui::layout::Margin::new(1, 1));
let mut body = sidebar.inner(ratatui::layout::Margin::new(1, 1));
if let Some(hint) = resize_hint {
let footer = Rect::new(
body.x,
body.bottom().saturating_sub(1),
body.width,
body.height.min(1),
);
crate::sidebar_chooser::resize_hint(hint, footer.width)
.render(footer, frame.buffer);
body.height = body.height.saturating_sub(1);
}
let text = match &creation.state {
CreationState::Waiting { started, .. } => {
let mode = MotionMode::from_animations_enabled(
@@ -331,7 +344,11 @@ impl App {
format!("{glyph} Starting side chat…")
}
CreationState::Failed { message, .. } => {
format!("{message}\nEnter retry · Esc back")
if resize_hint.is_some() {
message.clone()
} else {
format!("{message}\nEnter retry · Esc back")
}
}
};
ratatui::widgets::Paragraph::new(text)
@@ -345,6 +362,7 @@ impl App {
&self.keymap,
pane.chat_tabs(),
pane.choice,
resize_hint,
);
}
frame
@@ -353,10 +371,9 @@ impl App {
if focused {
frame.cursor_position = None;
}
(!main.is_empty()).then_some(main.right())
}
None => None,
};
None => {}
}
if let Some(strip) = strip
&& let Some(pane) = self.sidebar()
{
@@ -368,8 +385,8 @@ impl App {
frame.buffer,
);
}
if let Some(x) = divider {
side_pane::paint_divider(frame, x, self.divider_drag, highlight_divider);
if let Some(divider) = divider {
side_pane::paint_divider(frame, divider.x, self.divider_drag, highlight_divider);
}
self.paint_centered_dialog(frame);
self.conversation_frame.leader_hud = hud.map(|hud| {
@@ -475,7 +492,7 @@ impl App {
.then(crate::bottom_pane::ComposerGap::default);
let prompt_footer = if self.pane_resize.is_some() {
Some(crate::bottom_pane::TranscriptFooter {
text: "Resize · ←/h →/l · Shift+←/→ 10 cols · Enter/Esc done".into(),
text: side_pane::RESIZE_HINT.into(),
cursor_column: None,
is_interactive: true,
})
+3 -2
View File
@@ -249,6 +249,7 @@ impl App {
} else {
0
};
let can_resize = self.can_resize_sidebar(tui);
let focused_percent = [
(&bindings.equalize_pane_widths, 50),
(&bindings.minimize_pane_width, 0),
@@ -256,7 +257,7 @@ impl App {
]
.into_iter()
.find_map(|(binding, percent)| binding.is_pressed(key).then_some(percent))
.filter(|_| self.open_pane_focus().is_some());
.filter(|_| can_resize);
let zoom_target = if bindings.toggle_pane_zoom.is_pressed(key) {
self.open_pane_focus()
} else {
@@ -265,7 +266,7 @@ impl App {
if width_change != 0 && !self.can_nudge_side_width(tui) {
return false;
}
let resize = bindings.resize_panes.is_pressed(key) && self.open_pane_focus().is_some();
let resize = bindings.resize_panes.is_pressed(key) && can_resize;
let resizing = resize || width_change != 0 || focused_percent.is_some();
if !(tabs || left || right || main || sidebar || hide || zoom_target.is_some() || resizing)
|| !self.conversation_focus_available(tui)
+87 -59
View File
@@ -1,9 +1,12 @@
//! Lay out the two conversations and resize their divider. Only the displayed thread owns a live composer.
//! Lay out paired conversations and resize the shared sidebar split.
use super::App;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::side_companion::SideCompanion;
use super::sidebar;
use super::sidebar::PaneFocus;
use super::sidebar::SidebarContent;
use super::sidebar::SidebarLayout;
use crate::bottom_pane::render_unfocused_status;
use crate::custom_terminal::Frame;
use crate::legacy_core::config::edit::ConfigEdit;
@@ -27,18 +30,25 @@ use ratatui::layout::Rect;
use ratatui::widgets::StatefulWidgetRef;
use std::time::Duration;
const GAP: u16 = 1;
pub(super) const GAP: u16 = 1;
pub(super) const RESIZE_HINT: &str = "Resize · ←/h →/l · Shift+←/→ 10 cols · Enter/Esc done";
#[derive(Clone, Copy, PartialEq, Eq)]
struct ResizeTarget {
displayed: Option<ThreadId>,
content: SidebarContent,
}
#[derive(Clone, Copy)]
pub(super) struct PaneResize {
focused: ThreadId,
companion: ThreadId,
// Keyboard resize belongs to the parent split, including while a fork replaces the chooser.
pub(super) parent: Option<ThreadId>,
preview_width: Option<u16>,
}
#[derive(Clone, Copy)]
pub(super) struct DividerDrag {
passive_thread: ThreadId,
target: ResizeTarget,
screen: Rect,
press: u16,
initial_width: u16,
@@ -47,8 +57,8 @@ pub(super) struct DividerDrag {
}
impl DividerDrag {
pub(super) fn matches(&self, screen: Rect, pane: Option<&SidePaneLayout>) -> bool {
screen == self.screen && pane.is_some_and(|pane| pane.passive_thread == self.passive_thread)
pub(super) fn matches(&self, screen: Rect, app: &App) -> bool {
screen == self.screen && app.sidebar_resize_target() == Some(self.target)
}
fn percent(&self) -> Option<u8> {
@@ -81,25 +91,35 @@ pub(super) struct SidePaneLayout {
pub(super) tab_strip: Rect,
}
/// A wide terminal can show a pair once the outgoing conversation has been captured.
pub(super) fn layout(app: &App, tui: &crate::tui::Tui, area: Rect) -> Option<SidePaneLayout> {
/// The settled chat split used to check pointer events against the last painted frame.
pub(super) fn layout(app: &App, tui: &Tui, area: Rect) -> Option<SidePaneLayout> {
match visible_split(app, tui, area)? {
SidebarLayout::Chat(panes) => Some(panes),
SidebarLayout::Chooser { .. } => None,
}
}
fn visible_split(app: &App, tui: &Tui, area: Rect) -> Option<SidebarLayout> {
if app.zoomed_thread.is_some() && app.zoomed_thread == app.current_displayed_thread_id() {
return None;
}
split_layout(app, tui, area)
}
pub(super) fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option<SidePaneLayout> {
let SidebarContent::Chat(child) = app.sidebar()?.visible_content()? else {
return None;
};
pub(super) fn split_layout(app: &App, tui: &Tui, area: Rect) -> Option<SidebarLayout> {
let content = app.sidebar()?.visible_content()?;
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)?)
sidebar::content_layout(
app,
content,
app.chooser_focus().unwrap_or(PaneFocus::Main),
sidebar_areas(app, tui, area, /*fraction*/ 1.0)?,
)
}
pub(super) fn pair_layout(
@@ -140,23 +160,17 @@ pub(super) fn tab_and_body(area: Rect) -> [Rect; 2] {
}
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 target = app.sidebar_resize_target();
let preview = app
.divider_drag
.filter(|drag| {
!drag.guide_only
&& drag.screen == area
&& Some(drag.passive_thread) == passive
&& app.conversation_focus_available(tui)
!drag.guide_only && drag.matches(area, app) && 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| Some(mode.focused) == active && Some(mode.companion) == passive)
.filter(|mode| mode.parent == app.sidebar_parent())
.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),
@@ -168,7 +182,7 @@ pub(super) fn sidebar_areas(app: &App, tui: &Tui, area: Rect, fraction: f64) ->
_ => preferred_width(area.width, preview.or(saved)),
};
// A newly focused composer needs a cell even on the first opening frame.
let open = app.sidebar()?.visible_content().is_some();
let open = target.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)])
@@ -178,18 +192,22 @@ pub(super) fn sidebar_areas(app: &App, tui: &Tui, area: Rect, fraction: f64) ->
}
impl App {
fn sidebar_resize_target(&self) -> Option<ResizeTarget> {
Some(ResizeTarget {
displayed: self.current_displayed_thread_id(),
content: self.sidebar()?.visible_content()?,
})
}
pub(super) fn begin_pane_resize(&mut self, tui: &mut Tui) -> Result<()> {
let screen = tui.prepare_draw_size()?.into();
if let Some(panes) = split_layout(self, tui, screen)
&& let Some(focused) = self.current_displayed_thread_id()
{
if split_layout(self, tui, screen).is_some() {
self.divider_drag = None;
self.passive_link_drag = None;
self.pending_right_click_paste = None;
self.zoomed_thread = None;
self.pane_resize = Some(PaneResize {
focused,
companion: panes.passive_thread,
parent: self.sidebar_parent(),
preview_width: None,
});
tui.frame_requester().schedule_frame();
@@ -206,11 +224,11 @@ impl App {
_ => tui.terminal.last_known_screen_size,
}
.into();
let Some(panes) = layout(self, tui, screen).filter(|panes| {
let divider = visible_split(self, tui, screen).and_then(|layout| layout.divider_area());
let Some(divider) = divider.filter(|_| {
!matches!(event, TuiEvent::FocusLost)
&& self.conversation_focus_available(tui)
&& self.current_displayed_thread_id() == Some(mode.focused)
&& panes.passive_thread == mode.companion
&& self.sidebar_parent() == mode.parent
}) else {
self.pane_resize = None;
tui.frame_requester().schedule_frame();
@@ -222,7 +240,7 @@ impl App {
if key.kind == KeyEventKind::Release {
return true;
}
let current = screen.right() - panes.divider - GAP;
let current = screen.right() - divider.right();
let step = match (key.code, key.modifiers) {
(KeyCode::Left | KeyCode::Char('h'), KeyModifiers::NONE) => 1,
(KeyCode::Right | KeyCode::Char('l'), KeyModifiers::NONE) => -1,
@@ -267,7 +285,7 @@ impl App {
if split_layout(self, tui, screen).is_none() {
return Ok(());
}
let side_percent = if self.active_side_parent_thread_id().is_some() {
let side_percent = if self.sidebar_has_focus() {
focused_percent
} else {
100 - focused_percent
@@ -286,22 +304,32 @@ impl App {
Ok(())
}
pub(super) fn can_resize_sidebar(&self, tui: &Tui) -> bool {
split_layout(self, tui, tui.terminal.last_known_screen_size.into()).is_some()
}
pub(super) fn divider_is_settled(&self, tui: &Tui, screen: Rect) -> bool {
self.conversation_frame.sidebar.is_some()
&& visible_split(self, tui, screen) == self.conversation_frame.sidebar
}
pub(super) fn can_nudge_side_width(&self, tui: &Tui) -> bool {
self.active_side_parent_thread_id().is_some()
&& layout(self, tui, tui.terminal.last_known_screen_size.into()).is_some()
self.sidebar_has_focus()
&& visible_split(self, tui, tui.terminal.last_known_screen_size.into()).is_some()
}
pub(super) async fn nudge_side_width(&mut self, tui: &mut Tui, percent: i32) -> Result<()> {
if self.active_side_parent_thread_id().is_some() {
if self.sidebar_has_focus() {
let screen: Rect = tui.prepare_draw_size()?.into();
if let Some(panes) = layout(self, tui, screen) {
let step = (i32::from(screen.width) * percent.abs() + 50) / 100;
let current = panes.focused.width;
let width =
sidebar_width(i32::from(current) + step * percent.signum(), screen.width);
if width != current {
self.save_sidebar_width(width_percent(width, screen.width))
.await;
if let Some(divider) =
visible_split(self, tui, screen).and_then(|layout| layout.divider_area())
{
let current = screen.right() - divider.right();
let total = screen.width;
let step = (i32::from(total) * percent.abs() + 50) / 100;
let next = sidebar_width(i32::from(current) + step * percent.signum(), total);
if next != current {
self.save_sidebar_width(width_percent(next, total)).await;
tui.frame_requester().schedule_frame();
}
}
@@ -349,19 +377,24 @@ impl App {
}
match mouse.kind {
Down(Left) => {
let Some(painted) = self.conversation_frame.side_pane() else {
let Some(divider) = self
.conversation_frame
.divider_area()
.filter(|area| area.contains((mouse.column, mouse.row).into()))
else {
return Ok(false);
};
if !painted.divider_contains(*mouse) {
return Ok(false);
}
let screen = tui.prepare_draw_size()?.into();
if layout(self, tui, screen).as_ref() != Some(painted) {
return Ok(false);
if !self.divider_is_settled(tui, screen) {
// The painted divider still owns this press while it moves.
return Ok(true);
}
let width = screen.right() - painted.divider - GAP;
let Some(target) = self.sidebar_resize_target() else {
return Ok(false);
};
let width = screen.right() - divider.right();
self.divider_drag = Some(DividerDrag {
passive_thread: painted.passive_thread,
target,
screen,
press: mouse.column,
initial_width: width,
@@ -377,7 +410,7 @@ impl App {
return Ok(false);
};
let screen = tui.prepare_draw_size()?.into();
if drag.matches(screen, layout(self, tui, screen).as_ref()) {
if drag.matches(screen, self) && visible_split(self, tui, screen).is_some() {
let width = i32::from(drag.initial_width) + i32::from(drag.press)
- i32::from(mouse.column);
drag.width = sidebar_width(width, screen.width);
@@ -396,11 +429,6 @@ impl App {
}
impl SidePaneLayout {
pub(super) fn divider_contains(&self, mouse: crossterm::event::MouseEvent) -> bool {
(self.divider..self.divider + GAP).contains(&mouse.column)
&& (self.focused.y..self.focused.bottom()).contains(&mouse.row)
}
/// Paint the divider, retained transcript and draft without claiming the terminal cursor.
pub(super) fn paint_companion(
&self,
+26 -1
View File
@@ -175,7 +175,7 @@ impl App {
})
}
fn sidebar_parent(&self) -> Option<ThreadId> {
pub(super) fn sidebar_parent(&self) -> Option<ThreadId> {
self.active_side_parent_thread_id()
.or_else(|| self.current_displayed_thread_id())
}
@@ -193,8 +193,14 @@ impl App {
pane.focus
.filter(|_| pane.content == SidebarContent::Chooser)
}
pub(super) fn sidebar_has_focus(&self) -> bool {
self.active_side_parent_thread_id().is_some()
|| self.chooser_focus() == Some(PaneFocus::Sidebar)
}
}
#[derive(PartialEq)]
pub(super) enum SidebarLayout {
Chooser {
main: Rect,
@@ -204,6 +210,16 @@ pub(super) enum SidebarLayout {
Chat(SidePaneLayout),
}
impl SidebarLayout {
pub(super) fn divider_area(&self) -> Option<Rect> {
let (x, pane) = match self {
Self::Chooser { main, .. } => (main.right(), *main),
Self::Chat(panes) => (panes.divider, panes.focused.union(panes.tab_strip)),
};
(!pane.is_empty()).then(|| Rect::new(x, pane.y, side_pane::GAP, pane.height))
}
}
/// 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 =
@@ -234,6 +250,15 @@ pub(super) fn paint_layout(app: &mut App, tui: &Tui, area: Rect) -> Option<Sideb
} else {
side_pane::sidebar_areas(app, tui, area, fraction)?
};
content_layout(app, content, focus, [main, sidebar])
}
pub(super) fn content_layout(
app: &App,
content: SidebarContent,
focus: PaneFocus,
[main, sidebar]: [Rect; 2],
) -> Option<SidebarLayout> {
match content {
SidebarContent::Chooser => (app.sidebar_chooser_available()
&& (focus == PaneFocus::Main || app.chat_widget.completion_can_yield_focus()))
+84 -2
View File
@@ -3326,7 +3326,7 @@ async fn docked_replay_finishes_while_skills_refresh_is_pending() -> Result<()>
#[tokio::test]
async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
// Both pane footers should fit the same way on Unix and Windows.
std::fs::write(
&app.local_settings.user_config_path,
@@ -3416,12 +3416,55 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
// Finish in the background, then change the parent's permissions before the first child turn.
app.sidebar_mut()
.show(sidebar::SidebarContent::Chooser, sidebar::PaneFocus::Main);
while app.side_thread_for_parent(parent_thread_id).is_none() {
let resize_size = Size::new(/*width*/ 120, /*height*/ 12);
let original_terminal = std::mem::replace(
&mut tui.terminal,
crate::tui::Terminal::with_screen_size_and_cursor_position_for_test(
ratatui::backend::CrosstermBackend::new(std::io::stdout()),
resize_size,
Default::default(),
),
);
app.chat_widget
.apply_external_edit("unsent parent draft".into());
side_frame(&mut app, &mut tui, resize_size)?;
app.begin_pane_resize(&mut tui)?;
assert!(app.pane_resize.is_some());
loop {
let event = time::timeout(Duration::from_secs(5), app_event_rx.recv())
.await?
.expect("event stream");
let created = matches!(event, AppEvent::SideCreated { .. });
boxed_future!(app.handle_event(&mut tui, &mut app_server, event)).await?;
if created {
assert!(app.side_thread_for_parent(parent_thread_id).is_some());
break;
}
}
// SideCreated has just replaced the chooser. Enter still finishes the resize.
boxed_future!(app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyCode::Enter.into())
))
.await?;
assert!(app.pane_resize.is_none());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"unsent parent draft"
);
while let Ok(event) = app_event_rx.try_recv() {
assert!(!matches!(
event,
AppEvent::CodexOp(AppCommand::UserTurn { .. })
));
boxed_future!(app.handle_event(&mut tui, &mut app_server, event)).await?;
}
assert!(
!std::iter::from_fn(|| op_rx.try_recv().ok()).any(|op| matches!(op, Op::UserTurn { .. }))
);
tui.terminal = original_terminal;
app.chat_widget.apply_external_edit(String::new());
let side_thread_id = app.side_thread_for_parent(parent_thread_id).unwrap();
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert_eq!(
@@ -4507,6 +4550,45 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
assert_eq!(app.current_displayed_thread_id(), Some(sibling));
assert_eq!(app.side_threads.len(), 2);
keys!(prefix, chooser_key, prefix, KeyCode::Char('=').into());
side_frame(&mut app, &mut tui, even)?;
assert_eq!(app.conversation_frame.chooser_area().unwrap().width, 100);
keys!(prefix, KeyCode::Char('r').into(), KeyCode::Left.into());
let frame = side_frame(&mut app, &mut tui, even)?;
let (_, hint) = frame
.lines()
.last()
.and_then(|row| row.split_once('│'))
.expect("sidebar footer");
insta::assert_snapshot!(hint.trim(), @"Resize · ←/h →/l · Shift+←/→ 10 cols · Enter/Esc done");
keys!(KeyCode::Esc.into());
side_frame(&mut app, &mut tui, even)?;
let divider = divider_at(&tui, 4);
// An even sidebar width at 200 columns survives the saved percentage round trip.
let target = divider - 11;
let left = MouseButton::Left;
for (kind, x) in [
(MouseEventKind::Down(left), divider),
(MouseEventKind::Drag(left), target),
(MouseEventKind::Up(left), target),
] {
boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, mouse(kind, x, /*row*/ 4)))
.await?;
}
assert!(side_frame(&mut app, &mut tui, even)?.contains("Open in sidebar"));
assert_eq!(divider_at(&tui, 4), target);
keys!(enter);
side_frame(&mut app, &mut tui, even)?;
assert!(app.conversation_frame.side_pane().is_some());
assert_eq!(divider_at(&tui, 4), target);
// A focused child has only its tab strip at this height.
let strip = side_pane::split_layout(&app, &tui, Rect::new(0, 0, even.width, 1)).unwrap();
assert_eq!(strip.divider_area(), Some(Rect::new(target, 0, 1, 1)));
keys!(prefix, chooser_key);
side_frame(&mut app, &mut tui, even)?;
assert!(app.conversation_frame.chooser_area().is_some());
assert_eq!(divider_at(&tui, 4), target);
app_server.shutdown().await?;
Ok(())
}
+121
View File
@@ -1195,6 +1195,41 @@ async fn pane_direction_without_neighbor_preserves_fresh_typing() -> Result<()>
Ok(())
}
#[tokio::test]
async fn sidebar_chooser_resize_survives_startup_thread_assignment() -> Result<()> {
let (mut app, mut tui, mut server) = chord_app().await?;
super::enable_side_retention(&mut app)?;
tui.set_owned_screen(/*owned*/ true)?;
app.local_settings.tui.animations = false;
let size = ratatui::layout::Size::new(/*width*/ 165, /*height*/ 12);
tui.terminal = crate::tui::Terminal::with_screen_size_and_cursor_position_for_test(
ratatui::backend::CrosstermBackend::new(std::io::stdout()),
size,
Default::default(),
);
assert_eq!(app.sidebar_parent(), None);
for stroke in [
ctrl('x'),
KeyCode::Char('t').into(),
ctrl('x'),
KeyCode::Char('r').into(),
] {
Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?;
}
app.activate_thread_channel(ThreadId::new()).await;
let frame = super::side_frame(&mut app, &mut tui, size)?;
assert_eq!(app.conversation_frame.chooser_area().unwrap().width, 55);
let (_, footer) = frame.lines().last().unwrap().split_once('│').unwrap();
insta::assert_snapshot!(footer.trim(), @"←/→ resize · Enter/Esc done");
assert!(
app.handle_pane_resize(&tui, &TuiEvent::Key(KeyCode::Enter.into()))
.await
);
assert!(app.pane_resize.is_none());
server.shutdown().await?;
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;
@@ -1206,9 +1241,18 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() ->
app.chat_widget
.handle_thread_session(test_thread_session(parent, app.config.cwd.to_path_buf()));
app.chat_widget.local_settings.tui.status_line = Some(vec!["model-with-reasoning".into()]);
app.chat_widget.refresh_status_line();
app.chat_widget.insert_str("parent draft");
let size = ratatui::layout::Size::new(120, 12);
let terminal_at = |size| {
crate::tui::Terminal::with_screen_size_and_cursor_position_for_test(
ratatui::backend::CrosstermBackend::new(std::io::stdout()),
size,
Default::default(),
)
};
tui.terminal = terminal_at(size);
app.local_settings.tui.animations = true;
tokio::time::pause();
let open_chooser = [ctrl('x'), KeyCode::Char('s').into()];
@@ -1221,10 +1265,42 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() ->
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 && elapsed == 90 && width == 20 {
let divider = app.conversation_frame.divider_area().unwrap();
for kind in [
crossterm::event::MouseEventKind::Moved,
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
] {
let event = TuiEvent::Mouse(crossterm::event::MouseEvent {
kind,
column: divider.x,
row: divider.y,
modifiers: KeyModifiers::NONE,
});
Box::pin(app.handle_tui_event(&mut tui, &mut server, event)).await?;
assert!(tui.link_hover.shape == crate::tui::PointerShape::Default);
assert!(app.divider_drag.is_none());
assert_eq!(
app.chooser_focus(),
Some(crate::app::sidebar::PaneFocus::Sidebar)
);
}
}
}
if key == KeyCode::Esc {
let frame = super::side_frame(&mut app, &mut tui, size)?;
insta::assert_snapshot!("sidebar_new_chat", frame);
app.begin_pane_resize(&mut tui)?;
let resizing = super::side_frame(&mut app, &mut tui, size)?;
let footer = resizing.lines().last().unwrap().split_once('│').unwrap().1;
insta::assert_snapshot!(footer.trim(), @"←/→ resize · Enter/Esc done");
Box::pin(press(
&mut app,
&mut tui,
&mut server,
KeyCode::Enter.into(),
))
.await?;
for stroke in [
ctrl('x'),
KeyCode::Char('c').into(),
@@ -1240,6 +1316,30 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() ->
Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?;
}
assert!(super::side_frame(&mut app, &mut tui, size)?.contains("Open in sidebar"));
app.keymap = RuntimeKeymap::for_session(
&toml::from_str("[global]\nresize_panes = ['8','f12 r']")?,
/*workspace*/ true,
)
.unwrap();
let narrow = ratatui::layout::Size::new(/*width*/ 80, /*height*/ 12);
// Navigation can request a fresh backend sample between keystrokes.
tui.terminal = terminal_at(narrow);
super::side_frame(&mut app, &mut tui, narrow)?;
assert!(app.conversation_frame.chooser_area().is_none());
let prefix = KeyEvent::from(KeyCode::F(12));
assert_eq!(app.route_key_chord_event(&mut tui, prefix), Some(prefix));
for code in [KeyCode::End, KeyCode::Char('8'), KeyCode::End] {
Box::pin(press(&mut app, &mut tui, &mut server, code.into())).await?;
}
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"parent draft!8"
);
assert!(!app.key_chord_matcher.is_pending());
app.chat_widget.apply_external_edit("parent draft!".into());
super::enable_side_retention(&mut app)?;
tui.terminal = terminal_at(size);
super::side_frame(&mut app, &mut tui, size)?;
for stroke in open_chooser {
Box::pin(press(&mut app, &mut tui, &mut server, stroke)).await?;
}
@@ -1298,6 +1398,27 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() ->
.await?;
let narrow = super::side_frame(&mut app, &mut tui, ratatui::layout::Size::new(30, 8))?;
insta::assert_snapshot!("sidebar_failed_creation", narrow);
super::side_frame(&mut app, &mut tui, size)?;
app.begin_pane_resize(&mut tui)?;
assert!(app.pane_resize.is_some());
let resizing = super::side_frame(&mut app, &mut tui, size)?;
let hints: Vec<_> = resizing
.lines()
.filter_map(|row| row.split_once('│'))
.map(|(_, sidebar)| sidebar.trim())
.filter(|row| !row.is_empty())
.collect();
insta::assert_snapshot!(hints.join("\n"), @"The side conversation could not be
prepared.
←/→ resize · Enter/Esc done");
Box::pin(press(
&mut app,
&mut tui,
&mut server,
KeyCode::Enter.into(),
))
.await?;
assert!(app.sidebar().unwrap().creation.is_some());
Box::pin(press(&mut app, &mut tui, &mut server, KeyCode::Esc.into())).await?;
assert!(
super::side_frame(&mut app, &mut tui, ratatui::layout::Size::new(30, 8))?
+10 -2
View File
@@ -114,8 +114,16 @@ impl App {
} else {
None
};
if let Some(startup_pane) = self.sidebars.remove(&None) {
self.sidebars.entry(Some(thread_id)).or_insert(startup_pane);
if let Some(startup_pane) = self.sidebars.remove(&None)
&& let std::collections::hash_map::Entry::Vacant(entry) =
self.sidebars.entry(Some(thread_id))
{
entry.insert(startup_pane);
if let Some(mode) = &mut self.pane_resize
&& mode.parent.is_none()
{
mode.parent = Some(thread_id);
}
}
self.active_thread_id = Some(thread_id);
self.active_thread_rx = receiver;
+1 -2
View File
@@ -24,8 +24,7 @@ impl App {
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 side = self.sidebar_has_focus();
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();
+8 -3
View File
@@ -60,6 +60,7 @@ 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 SIDEBAR_RESIZE: u32 = 1 << 28;
const TRANSCRIPT_BROWSING: u32 = 1 << 14;
@@ -174,6 +175,10 @@ impl KeymapContextSet {
Self(self.0 | OPEN_PANE)
}
pub(crate) const fn with_sidebar_resize(self) -> Self {
Self(self.0 | SIDEBAR_RESIZE)
}
pub(crate) const fn with_focused_side_pane(self) -> Self {
Self(self.0 | FOCUSED_SIDE_PANE)
}
@@ -199,11 +204,11 @@ impl KeymapContextSet {
let required = match action.action {
"focus_pane_left" | "focus_pane_right" => PANE_DIRECTION,
"move_divider_left" | "move_divider_right" => FOCUSED_SIDE_PANE,
"toggle_pane_zoom"
| "equalize_pane_widths"
"toggle_pane_zoom" => OPEN_PANE,
"equalize_pane_widths"
| "minimize_pane_width"
| "maximize_pane_width"
| "resize_panes" => OPEN_PANE,
| "resize_panes" => SIDEBAR_RESIZE,
"focus_main_composer" => MAIN_COMPOSER_FOCUS,
"focus_sidebar_content" | "focus_sidebar_tabs" => SIDE_PANE | SIDEBAR_CHOOSER,
_ => SIDE_PANE,
+15 -1
View File
@@ -19,6 +19,15 @@ fn body_row(area: Rect, y: u16) -> Rect {
Rect::new(area.x + 1, y, area.width.saturating_sub(3), 1)
}
pub(crate) fn resize_hint(full: &str, width: u16) -> Line<'_> {
let full = Line::from(full);
if full.width() <= usize::from(width) {
full.dim()
} else {
Line::from("←/→ resize · Enter/Esc done").dim()
}
}
pub(crate) fn footer(keymap: &RuntimeKeymap, area: Rect) -> footer_hint::HintLine {
let hide = keymap
.primary_hint(KeymapContext::Global, "hide_pane")
@@ -84,6 +93,7 @@ pub(crate) fn paint(
keymap: &RuntimeKeymap,
tabs: TabStrip<'_, ThreadId>,
choice: usize,
resize: Option<&str>,
) {
Clear.render(area, buf);
if area.is_empty() {
@@ -143,6 +153,10 @@ pub(crate) fn paint(
}
}
if footer_y > area.y {
footer(keymap, area).line.render(row(footer_y), buf);
let line = match resize {
Some(hint) => resize_hint(hint, row(footer_y).width),
None => footer(keymap, area).line,
};
line.render(row(footer_y), buf);
}
}