diff --git a/CHANGELOG.md b/CHANGELOG.md index c203a971..acb5ec7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 801dd9ee..efb14a59 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -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 diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index e00c349b..22bc135f 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -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 { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 5c9553b2..8307b5b8 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1299,6 +1299,21 @@ pub struct App { pub sidebar_hover_tooltip: Option, /// 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, + /// Handle rect painted on the left edge of the sidebar (1 col). + pub last_sidebar_handle_area: Option, + /// 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, diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 8fc0e3c5..8e455d42 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -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= 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 diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index c1a89cf8..80ac2bb3 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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,