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