feat(tui): add drag-to-resize sidebar width

Add drag-to-resize functionality for the TUI sidebar width, allowing users to interactively resize the sidebar.
This commit is contained in:
Hanmiao Li
2026-06-03 11:47:18 +08:00
committed by GitHub
parent 2721b2a077
commit 1781312c7a
7 changed files with 292 additions and 2 deletions
+2 -1
View File
@@ -5329,7 +5329,8 @@ Welcome — and thank you.
- Hooks system and config profiles
- Example skills and launch assets
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...HEAD
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...HEAD
[0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51
[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50
[0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49
[0.8.48]: https://github.com/Hmbown/CodeWhale/compare/v0.8.47...v0.8.48
+2 -1
View File
@@ -5304,7 +5304,8 @@ Welcome — and thank you.
- Hooks system and config profiles
- Example skills and launch assets
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...HEAD
[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...HEAD
[0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51
[0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50
[0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49
[0.8.48]: https://github.com/Hmbown/CodeWhale/compare/v0.8.47...v0.8.48
+6
View File
@@ -545,6 +545,12 @@ impl Settings {
Ok(())
}
/// Update and persist sidebar width percentage (10-50) — used by the
/// drag-to-resize handle in the TUI.
pub fn update_sidebar_width(&mut self, percent: u16) {
self.sidebar_width_percent = percent.clamp(10, 50);
}
/// Set a single setting by key
pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
match key {
+22
View File
@@ -1299,6 +1299,21 @@ pub struct App {
pub sidebar_hover_tooltip: Option<String>,
/// Last known mouse position for tooltip placement.
pub last_mouse_pos: Option<(u16, u16)>,
/// Whether the user is currently dragging the sidebar resize handle.
pub sidebar_resizing: bool,
/// Mouse column at the start of a sidebar-resize drag.
pub sidebar_resize_anchor_x: u16,
/// Sidebar width in columns at the start of a sidebar-resize drag.
pub sidebar_resize_anchor_width: u16,
/// Last sidebar area rendered (for mouse hit-testing the resize handle).
pub last_sidebar_area: Option<Rect>,
/// Handle rect painted on the left edge of the sidebar (1 col).
pub last_sidebar_handle_area: Option<Rect>,
/// Total horizontal space (chat + sidebar) used to compute the percentage
/// during sidebar resize drag.
pub sidebar_resize_total_width: u16,
/// Sidebar width changed during this drag and needs persistence.
pub sidebar_width_dirty: bool,
/// Whether the session-context panel is enabled (#504).
pub context_panel: bool,
/// File-tree pane state. `None` when hidden; `Some` when visible.
@@ -2020,6 +2035,13 @@ impl App {
sidebar_hover: SidebarHoverState::default(),
sidebar_hover_tooltip: None,
last_mouse_pos: None,
sidebar_resizing: false,
sidebar_resize_anchor_x: 0,
sidebar_resize_anchor_width: 0,
last_sidebar_area: None,
last_sidebar_handle_area: None,
sidebar_resize_total_width: 0,
sidebar_width_dirty: false,
context_panel: settings.context_panel,
file_tree: None,
file_tree_visible: false,
+47
View File
@@ -42,6 +42,47 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) ->
}
}
/// Handle mouse events on the sidebar resize handle (the 1-col vertical bar
/// between the chat area and the sidebar). Returns true when the event was
/// consumed so other handlers skip it.
fn handle_sidebar_resize_mouse(app: &mut App, mouse: MouseEvent) -> bool {
let Some(handle) = app.last_sidebar_handle_area else {
return false;
};
let hit = mouse.column == handle.x
&& mouse.row >= handle.y
&& mouse.row < handle.y.saturating_add(handle.height);
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) if hit => {
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = mouse.column;
app.sidebar_resize_anchor_width = app.last_sidebar_area.map(|a| a.width).unwrap_or(28);
app.needs_redraw = true;
true
}
MouseEventKind::Drag(MouseButton::Left) if app.sidebar_resizing => {
let delta = app.sidebar_resize_anchor_x as i32 - mouse.column as i32;
let new_width = (app.sidebar_resize_anchor_width as i32 + delta).max(24) as u16;
let total = app.sidebar_resize_total_width.max(1);
let new_pct = ((new_width as u32 * 100) / total as u32).clamp(10, 50) as u16;
if new_pct != app.sidebar_width_percent {
app.sidebar_width_percent = new_pct;
app.needs_redraw = true;
}
true
}
MouseEventKind::Up(MouseButton::Left) if app.sidebar_resizing => {
app.sidebar_resizing = false;
app.sidebar_width_dirty = true;
app.needs_redraw = true;
true
}
_ => false,
}
}
/// Map a mouse (column, row) within the composer area to a char index
/// in the composer input string. Uses the inner content rect (border-aware)
/// for coordinate mapping, and accounts for vertical padding and scroll offset.
@@ -216,6 +257,12 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
return app.view_stack.handle_mouse(mouse);
}
// Sidebar resize handle — check before composer so it doesn't compete
// with text selection / scrolling.
if handle_sidebar_resize_mouse(app, mouse) {
return Vec::new();
}
// Composer mouse events take priority over transcript.
if handle_composer_mouse(app, mouse) {
return Vec::new();
+65
View File
@@ -60,6 +60,7 @@ use crate::session_manager::{
OfflineQueueState, QueuedSessionMessage, SavedSession, SessionManager,
create_saved_session_with_id_and_mode, create_saved_session_with_mode, update_session,
};
use crate::settings::Settings;
use crate::task_manager::{
NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, TaskSummary,
};
@@ -2619,6 +2620,14 @@ async fn run_event_loop(
{
return Ok(());
}
// Persist sidebar width when the user finishes a drag-to-resize.
if app.sidebar_width_dirty {
app.sidebar_width_dirty = false;
if let Ok(mut settings) = Settings::load() {
settings.update_sidebar_width(app.sidebar_width_percent);
let _ = settings.save();
}
}
continue;
}
@@ -3020,6 +3029,14 @@ async fn run_event_loop(
{
return Ok(());
}
// Persist sidebar width when the user finishes a drag-to-resize.
if app.sidebar_width_dirty {
app.sidebar_width_dirty = false;
if let Ok(mut settings) = Settings::load() {
settings.update_sidebar_width(app.sidebar_width_percent);
let _ = settings.save();
}
}
continue;
}
@@ -6552,6 +6569,8 @@ fn render(f: &mut Frame, app: &mut App) {
};
if let Some(sidebar_width) = sidebar_width_for_chat_area(app, chat_area.width) {
// Record total width for drag-to-resize percentage calculation.
app.sidebar_resize_total_width = chat_area.width;
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(1), Constraint::Length(sidebar_width)])
@@ -6569,8 +6588,54 @@ fn render(f: &mut Frame, app: &mut App) {
chat_widget.render(chat_area, buf);
if let Some(sidebar_area) = sidebar_area {
// Store sidebar area for mouse hit-testing (resize handle).
app.last_sidebar_area = Some(sidebar_area);
// Render sidebar
super::sidebar::render_sidebar(f, sidebar_area, app);
// Paint resize handle (1-col draggable bar) on the left edge of
// the sidebar, over the sidebar content. Mouse drag on this strip
// adjusts sidebar_width_percent in real time.
let handle_rect = Rect {
x: sidebar_area.x,
y: sidebar_area.y,
width: 1,
height: sidebar_area.height,
};
// Store for mouse event handler.
app.last_sidebar_handle_area = Some(handle_rect);
let mouse_over = app.last_mouse_pos.is_some_and(|(col, row)| {
row >= handle_rect.y
&& row < handle_rect.y.saturating_add(handle_rect.height)
&& col == handle_rect.x
});
let handle_style = if app.sidebar_resizing {
Style::default()
.bg(palette::DEEPSEEK_BLUE)
.fg(palette::TEXT_PRIMARY)
} else if mouse_over {
Style::default()
.bg(palette::STATUS_WARNING)
.fg(palette::TEXT_MUTED)
} else {
Style::default()
.bg(palette::DEEPSEEK_SLATE)
.fg(palette::TEXT_MUTED)
};
let buf = f.buffer_mut();
for row in handle_rect.y..handle_rect.y.saturating_add(handle_rect.height) {
if row < buf.area().height {
buf[(handle_rect.x, row)]
.set_char('│')
.set_style(handle_style);
}
}
// Render sidebar hover tooltip if active.
if let Some(ref tooltip_text) = app.sidebar_hover_tooltip
&& let Some((mouse_col, mouse_row)) = app.last_mouse_pos
+148
View File
@@ -2894,6 +2894,154 @@ fn hidden_sidebar_focus_suppresses_sidebar_split_even_when_wide() {
assert_eq!(sidebar_width_for_chat_area(&app, 120), None);
}
// ── Sidebar resize-handle mouse tests ──────────────────────────────
fn setup_resize_handle(app: &mut App, handle_x: u16, sidebar_width: u16, total_width: u16) {
let y = 2;
let h = 10;
app.last_sidebar_handle_area = Some(Rect {
x: handle_x,
y,
width: 1,
height: h,
});
app.last_sidebar_area = Some(Rect {
x: handle_x,
y,
width: sidebar_width,
height: h,
});
app.sidebar_resize_total_width = total_width;
app.sidebar_width_percent = 28;
}
#[test]
fn sidebar_resize_down_on_handle_starts_resizing() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 80,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(
app.sidebar_resizing,
"should start resizing on handle click"
);
assert_eq!(app.sidebar_resize_anchor_x, 80);
assert_eq!(app.sidebar_resize_anchor_width, 33);
}
#[test]
fn sidebar_resize_down_outside_handle_does_not_start_resizing() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 79, // one column left of handle
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(
!app.sidebar_resizing,
"should not resize on non-handle click"
);
}
#[test]
fn sidebar_resize_drag_adjusts_width_percent() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
// 33 / 120 * 100 ≈ 27.5 → initial percent = 28 (the setup defaults to 28)
app.sidebar_width_percent = 28;
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
// Drag left by 4 cols (making sidebar wider): 33 + 4 = 37 → 37/120*100 ≈ 30
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
let expected = ((37u32 * 100) / 120) as u16; // ~30
assert_eq!(app.sidebar_width_percent, expected);
}
#[test]
fn sidebar_resize_drag_clamps_to_10_50_range() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
// Drag far right → sidebar should shrink but not below 10%
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 200,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.sidebar_width_percent >= 10);
// Drag far left → sidebar should grow but not above 50%
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: 0,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(app.sidebar_width_percent <= 50);
}
#[test]
fn sidebar_resize_up_ends_resizing_and_marks_dirty() {
let mut app = create_test_app();
setup_resize_handle(&mut app, 80, 33, 120);
app.sidebar_resizing = true;
app.sidebar_resize_anchor_x = 80;
app.sidebar_resize_anchor_width = 33;
handle_mouse_event(
&mut app,
MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: 76,
row: 5,
modifiers: KeyModifiers::NONE,
},
);
assert!(!app.sidebar_resizing, "should stop resizing on mouse up");
assert!(
app.sidebar_width_dirty,
"should mark width dirty for persistence"
);
}
fn make_subagent(
id: &str,
status: crate::tools::subagent::SubAgentStatus,