From f0fc62d76df4fcbe2a633656ad80ff58f5920e63 Mon Sep 17 00:00:00 2001 From: butcool Date: Mon, 17 Aug 2026 19:34:57 +0800 Subject: [PATCH 1/2] feat(layout): enable continuous pane resizing with persisted split sizes Requires Mouse support to be enabled to allow this feature. The split sizes will be saved in the configuration file, and restored across sessions. --- README.md | 4 +- crates/spectatui-core/src/layout.rs | 57 +++++ crates/spectatui/src/app.rs | 139 ++++++++++++- crates/spectatui/src/config.rs | 5 +- crates/spectatui/src/main.rs | 19 ++ crates/spectatui/src/ui/layout_geometry.rs | 229 +++++++++++++++++++++ crates/spectatui/src/ui/mod.rs | 97 +++++++-- specs/001-spectatui-dashboard-mvp/plan.md | 20 +- specs/001-spectatui-dashboard-mvp/spec.md | 13 +- specs/001-spectatui-dashboard-mvp/tasks.md | 29 +++ 10 files changed, 585 insertions(+), 27 deletions(-) create mode 100644 crates/spectatui/src/ui/layout_geometry.rs diff --git a/README.md b/README.md index 4be1c11..90e3096 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ A terminal UI dashboard for [GitHub Spec-Kit](https://github.com/github/spec-kit - ๐Ÿ–ฅ๏ธ **Agent output pane** โ€” Live tail of the tmux agent session with attach/refresh/kill controls - ๐Ÿ“ค **CLI job output popup** โ€” Dedicated scrollable view for spawned CLI job output - ๐ŸŽฏ **Command palette** โ€” Quick-navigate and execute commands (`:` or `Ctrl-K`) -- ๐Ÿ—‚๏ธ **Multi-pane dashboard** โ€” Overview, Coding, Audit, and Custom layouts (switch with `1`โ€“`4`) +- ๐Ÿ—‚๏ธ **Multi-pane dashboard** โ€” Overview, Coding, Audit, and Custom layouts (switch with `1`โ€“`4`); drag panel borders for continuous resizing - ๐ŸŽจ **Custom layout editor** โ€” Reorder, resize, and toggle visibility of panes; save as your own layout - โš™๏ธ **Settings editor** โ€” In-app settings: theme, accent, dashboard layout, mouse support, tmux prefix, and more, persited globally or per-project - ๐ŸŒ“ **Dark & light themes** โ€” Toggle with `t` @@ -181,6 +181,8 @@ Options: | `C` | Open Constitution viewer | | `a` | Go to Session attach | +With **Mouse support** enabled, drag any border between adjacent dashboard panels to resize them. Sizes are saved when the mouse button is released and are restored as relative proportions when the terminal is resized. Existing Custom-layout keyboard controls remain available in the layout editor. + **Spec browser**: | Key | Action | diff --git a/crates/spectatui-core/src/layout.rs b/crates/spectatui-core/src/layout.rs index 2a4d2df..3b71e72 100644 --- a/crates/spectatui-core/src/layout.rs +++ b/crates/spectatui-core/src/layout.rs @@ -125,3 +125,60 @@ impl CustomLayout { } } } + +/// Continuous sizes for the fixed dashboard layouts. Zero keeps that layout's +/// historical default until the user drags its divider for the first time. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DashboardSizes { + pub overview_sidebar: u16, + pub overview_workflow: u16, + pub coding_split: u16, + pub audit_split: u16, + pub custom_sidebar: u16, + pub custom_pane_heights: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomPaneHeight { + pub kind: PaneKind, + pub size: u16, +} + +impl DashboardSizes { + /// Runtime drag sizes deliberately live outside `CustomLayout`: the layout + /// editor continues to own order, visibility, and its four default sizes. + pub fn custom_pane_height(&self, kind: PaneKind, editor_default: u8) -> u16 { + self.custom_pane_heights + .iter() + .find(|pane| pane.kind == kind) + .map(|pane| pane.size) + .unwrap_or(editor_default as u16 * 100) + } + + pub fn set_custom_pane_height(&mut self, kind: PaneKind, size: u16) { + if let Some(pane) = self + .custom_pane_heights + .iter_mut() + .find(|pane| pane.kind == kind) + { + pane.size = size; + } else { + self.custom_pane_heights + .push(CustomPaneHeight { kind, size }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_custom_height_does_not_change_editor_default() { + let mut sizes = DashboardSizes::default(); + sizes.set_custom_pane_height(PaneKind::WorkflowTimeline, 275); + + assert_eq!(sizes.custom_pane_height(PaneKind::WorkflowTimeline, 2), 275); + assert_eq!(sizes.custom_pane_height(PaneKind::AgentOutput, 2), 200); + } +} diff --git a/crates/spectatui/src/app.rs b/crates/spectatui/src/app.rs index 3d7ed60..3803622 100644 --- a/crates/spectatui/src/app.rs +++ b/crates/spectatui/src/app.rs @@ -4,7 +4,7 @@ use std::cell::{Cell, RefCell}; use std::collections::HashSet; use ratatui::layout::Rect; -use spectatui_core::layout::CustomLayout; +use spectatui_core::layout::{CustomLayout, PaneKind}; use spectatui_core::speckit::cli::{CliAction, CliEvent, CliJob, JobStatus}; use spectatui_core::speckit::registry::{CatalogSource, CatalogTarget}; use spectatui_core::speckit::{ @@ -16,6 +16,23 @@ use tokio::sync::mpsc; use crate::config::{self, AppConfig}; use crate::theme::{Accent, Theme, ThemeMode}; +fn rect_contains(rect: Rect, col: u16, row: u16) -> bool { + col >= rect.x + && col < rect.x.saturating_add(rect.width) + && row >= rect.y + && row < rect.y.saturating_add(rect.height) +} + +fn split_percent(position: u16, length: u16, min_before: u16, min_after: u16) -> u16 { + if length == 0 { + return 5_000; + } + let upper = length.saturating_sub(min_after); + let lower = min_before.min(upper); + let position = position.clamp(lower, upper); + ((position as u32 * 10_000) / length as u32) as u16 +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { Dashboard, @@ -33,6 +50,34 @@ pub enum DashboardLayout { Custom, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DividerTarget { + OverviewSidebar, + OverviewWorkflow, + CodingSplit, + AuditSplit, + CustomSidebar, + CustomStack(PaneKind, PaneKind), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DividerAxis { + Vertical, + Horizontal, +} + +#[derive(Debug, Clone, Copy)] +pub struct ResizeDivider { + pub target: DividerTarget, + pub axis: DividerAxis, + /// Parent area that is split by this divider. + pub bounds: Rect, + pub min_before: u16, + pub min_after: u16, + /// The narrow border region that starts a resize gesture. + pub hitbox: Rect, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Pane { FeatureList, @@ -278,6 +323,10 @@ pub struct App { pub custom_layout: CustomLayout, pub layout_editor_index: usize, pub layout_editor_active: bool, + /// Dividers rendered in the last dashboard frame. Kept alongside click + /// regions so mouse hit testing always uses the exact rendered geometry. + pub resize_dividers: RefCell>, + pub resize_drag: Option, // Agent output pub agent_lines: Vec, @@ -398,6 +447,8 @@ impl App { custom_layout, layout_editor_index: 0, layout_editor_active: false, + resize_dividers: RefCell::new(Vec::new()), + resize_drag: None, agent_lines: Vec::new(), @@ -458,6 +509,7 @@ impl App { pub fn clear_click_regions(&self) { self.click_regions.borrow_mut().clear(); + self.resize_dividers.borrow_mut().clear(); } /// Hit-test a terminal cell (column,row) against registered regions, @@ -471,6 +523,91 @@ impl App { .map(|(_, a)| *a) } + pub fn register_resize_divider(&self, divider: ResizeDivider) { + self.resize_dividers.borrow_mut().push(divider); + } + + pub fn begin_resize(&mut self, col: u16, row: u16) -> bool { + let divider = self + .resize_dividers + .borrow() + .iter() + .find(|divider| rect_contains(divider.hitbox, col, row)) + .copied(); + self.resize_drag = divider; + divider.is_some() + } + + /// Applies one mouse position to the active divider. Returns `true` when + /// a persisted layout value changed. + pub fn resize_from_pointer(&mut self, col: u16, row: u16) -> bool { + let Some(drag) = self.resize_drag else { + return false; + }; + let amount = match drag.axis { + DividerAxis::Vertical => split_percent( + col.saturating_sub(drag.bounds.x), + drag.bounds.width, + drag.min_before, + drag.min_after, + ), + DividerAxis::Horizontal => split_percent( + row.saturating_sub(drag.bounds.y), + drag.bounds.height, + drag.min_before, + drag.min_after, + ), + }; + + match drag.target { + DividerTarget::OverviewSidebar => self.config.dashboard_sizes.overview_sidebar = amount, + DividerTarget::OverviewWorkflow => { + self.config.dashboard_sizes.overview_workflow = amount + } + DividerTarget::CodingSplit => self.config.dashboard_sizes.coding_split = amount, + DividerTarget::AuditSplit => self.config.dashboard_sizes.audit_split = amount, + DividerTarget::CustomSidebar => self.config.dashboard_sizes.custom_sidebar = amount, + DividerTarget::CustomStack(before, after) => { + let before_size = self + .custom_layout + .panes + .iter() + .find(|pane| pane.kind == before) + .map(|pane| { + self.config + .dashboard_sizes + .custom_pane_height(before, pane.size) + }); + let after_size = self + .custom_layout + .panes + .iter() + .find(|pane| pane.kind == after) + .map(|pane| { + self.config + .dashboard_sizes + .custom_pane_height(after, pane.size) + }); + if let (Some(before_size), Some(after_size)) = (before_size, after_size) { + let total = before_size as u32 + after_size as u32; + let next_before = + ((total * amount as u32) / 10_000).clamp(1, total.saturating_sub(1)) as u16; + self.config + .dashboard_sizes + .set_custom_pane_height(before, next_before); + self.config + .dashboard_sizes + .set_custom_pane_height(after, total as u16 - next_before); + } + } + } + true + } + + pub fn finish_resize(&mut self) -> bool { + self.resize_drag.take().is_some() + } + pub fn selected_feature(&self) -> Option<&spectatui_core::speckit::Feature> { self.project.features.get(self.feature_index) } diff --git a/crates/spectatui/src/config.rs b/crates/spectatui/src/config.rs index e17ba96..c82307d 100644 --- a/crates/spectatui/src/config.rs +++ b/crates/spectatui/src/config.rs @@ -4,7 +4,7 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use crate::theme::{Accent, ThemeMode}; -use spectatui_core::layout::CustomLayout; +use spectatui_core::layout::{CustomLayout, DashboardSizes}; #[derive(Debug, Serialize, Deserialize)] pub struct AppConfig { @@ -26,6 +26,8 @@ pub struct AppConfig { pub config_location: String, #[serde(default)] pub custom_layout: Option, + #[serde(default)] + pub dashboard_sizes: DashboardSizes, } fn default_theme() -> String { @@ -66,6 +68,7 @@ impl Default for AppConfig { tmux_prefix: default_tmux_prefix(), config_location: default_config_location(), custom_layout: None, + dashboard_sizes: DashboardSizes::default(), } } } diff --git a/crates/spectatui/src/main.rs b/crates/spectatui/src/main.rs index b57639b..b6e86f3 100644 --- a/crates/spectatui/src/main.rs +++ b/crates/spectatui/src/main.rs @@ -1231,6 +1231,9 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, cli_client: &SpecifyCliClient) // Route the wheel through the keyboard nav so it scrolls whatever Up/Down // currently affects (active popup, focused pane, or document view). MouseEventKind::ScrollDown => { + if app.resize_drag.is_some() { + return; + } handle_key( app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), @@ -1238,6 +1241,9 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, cli_client: &SpecifyCliClient) ); } MouseEventKind::ScrollUp => { + if app.resize_drag.is_some() { + return; + } handle_key( app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), @@ -1245,10 +1251,23 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, cli_client: &SpecifyCliClient) ); } MouseEventKind::Down(MouseButton::Left) => { + if app.active_popup.is_none() + && app.palette.is_none() + && !app.layout_editor_active + && app.begin_resize(mouse.column, mouse.row) + { + return; + } if let Some(action) = app.hit_test(mouse.column, mouse.row) { execute_click_action(app, action); } } + MouseEventKind::Drag(MouseButton::Left) => { + app.resize_from_pointer(mouse.column, mouse.row); + } + MouseEventKind::Up(MouseButton::Left) if app.finish_resize() => { + let _ = config::save_config(&app.config); + } _ => {} } } diff --git a/crates/spectatui/src/ui/layout_geometry.rs b/crates/spectatui/src/ui/layout_geometry.rs new file mode 100644 index 0000000..0cc226c --- /dev/null +++ b/crates/spectatui/src/ui/layout_geometry.rs @@ -0,0 +1,229 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use spectatui_core::layout::{CustomLayout, DashboardSizes, PaneConfig, PaneKind}; + +use crate::app::{App, DividerAxis, DividerTarget, ResizeDivider}; + +pub(super) fn custom_layout_geometry( + custom_layout: &CustomLayout, + sidebar_size: u16, + runtime_sizes: &DashboardSizes, + area: Rect, +) -> (Vec<(PaneKind, Rect)>, Vec) { + let visible = custom_layout.visible_panes(); + if visible.is_empty() { + return (Vec::new(), Vec::new()); + } + if visible.len() == 1 { + return (vec![(visible[0].kind, area)], Vec::new()); + } + + let first = visible[0].kind; + let rest = &visible[1..]; + let min_rest_width = rest + .iter() + .map(|pane| min_width(pane.kind)) + .max() + .unwrap_or(1); + let cols = split_horizontal( + area, + stored_or_length(sidebar_size, 38, area.width), + min_width(first), + min_rest_width, + ); + let mut out = vec![(visible[0].kind, cols[0])]; + let mut dividers = vec![vertical_divider( + DividerTarget::CustomSidebar, + area, + cols[0], + min_width(first), + min_rest_width, + )]; + let right_rows = weighted_rows(rest, runtime_sizes, cols[1]); + for (i, (pane, rect)) in rest.iter().zip(right_rows.iter()).enumerate() { + out.push((pane.kind, *rect)); + if let Some(next) = rest.get(i + 1) { + let next_rect = right_rows[i + 1]; + let bounds = Rect::new( + cols[1].x, + rect.y, + cols[1].width, + rect.height.saturating_add(next_rect.height), + ); + dividers.push(horizontal_divider( + DividerTarget::CustomStack(pane.kind, next.kind), + bounds, + *rect, + pane.kind.min_height(), + next.kind.min_height(), + )); + } + } + (out, dividers) +} + +pub(super) fn min_width(kind: PaneKind) -> u16 { + match kind { + PaneKind::FeatureList => 24, + PaneKind::SpecBrowser | PaneKind::Constitution => 28, + PaneKind::ExtensionsPresets => 26, + _ => 20, + } +} + +pub(super) fn stored_or_default(stored: u16, default: u16) -> u16 { + if stored == 0 { + default + } else { + stored + } +} + +pub(super) fn stored_or_length(stored: u16, default_length: u16, total: u16) -> u16 { + if stored == 0 || total == 0 { + (default_length as u32 * 10_000 / total.max(1) as u32).min(10_000) as u16 + } else { + stored + } +} + +pub(super) fn split_horizontal( + area: Rect, + percent: u16, + min_left: u16, + min_right: u16, +) -> [Rect; 2] { + let left_width = split_length(area.width, percent, min_left, min_right); + [ + Rect::new(area.x, area.y, left_width, area.height), + Rect::new( + area.x.saturating_add(left_width), + area.y, + area.width.saturating_sub(left_width), + area.height, + ), + ] +} + +pub(super) fn split_vertical(area: Rect, percent: u16, min_top: u16, min_bottom: u16) -> [Rect; 2] { + let top_height = split_length(area.height, percent, min_top, min_bottom); + [ + Rect::new(area.x, area.y, area.width, top_height), + Rect::new( + area.x, + area.y.saturating_add(top_height), + area.width, + area.height.saturating_sub(top_height), + ), + ] +} + +fn split_length(total: u16, percent: u16, min_before: u16, min_after: u16) -> u16 { + let upper = total.saturating_sub(min_after); + let lower = min_before.min(upper); + (((total as u32 * percent.min(10_000) as u32) / 10_000) as u16).clamp(lower, upper) +} + +fn weighted_rows(panes: &[&PaneConfig], runtime_sizes: &DashboardSizes, area: Rect) -> Vec { + let total_weight: u32 = panes + .iter() + .map(|pane| runtime_sizes.custom_pane_height(pane.kind, pane.size) as u32) + .sum::() + .max(1); + let constraints: Vec = panes + .iter() + .map(|pane| { + Constraint::Ratio( + runtime_sizes.custom_pane_height(pane.kind, pane.size) as u32, + total_weight, + ) + }) + .collect(); + Layout::vertical(constraints).split(area).to_vec() +} + +pub(super) fn register_vertical_divider( + app: &App, + target: DividerTarget, + bounds: Rect, + before: Rect, + min_before: u16, + min_after: u16, +) { + app.register_resize_divider(vertical_divider( + target, bounds, before, min_before, min_after, + )); +} + +fn vertical_divider( + target: DividerTarget, + bounds: Rect, + before: Rect, + min_before: u16, + min_after: u16, +) -> ResizeDivider { + ResizeDivider { + target, + axis: DividerAxis::Vertical, + bounds, + min_before, + min_after, + hitbox: Rect::new( + before.x.saturating_add(before.width.saturating_sub(1)), + bounds.y, + 2, + bounds.height, + ), + } +} + +pub(super) fn register_horizontal_divider( + app: &App, + target: DividerTarget, + bounds: Rect, + before: Rect, + min_before: u16, + min_after: u16, +) { + app.register_resize_divider(horizontal_divider( + target, bounds, before, min_before, min_after, + )); +} + +fn horizontal_divider( + target: DividerTarget, + bounds: Rect, + before: Rect, + min_before: u16, + min_after: u16, +) -> ResizeDivider { + ResizeDivider { + target, + axis: DividerAxis::Horizontal, + bounds, + min_before, + min_after, + hitbox: Rect::new( + bounds.x, + before.y.saturating_add(before.height.saturating_sub(1)), + bounds.width, + 2, + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_length_honors_both_minimums() { + assert_eq!(split_length(100, 500, 30, 40), 30); + assert_eq!(split_length(100, 9_500, 30, 40), 60); + } + + #[test] + fn stored_length_uses_the_legacy_default_until_dragged() { + assert_eq!(stored_or_length(0, 38, 100), 3_800); + assert_eq!(stored_or_length(6_200, 38, 100), 6_200); + } +} diff --git a/crates/spectatui/src/ui/mod.rs b/crates/spectatui/src/ui/mod.rs index 38776c9..0895f90 100644 --- a/crates/spectatui/src/ui/mod.rs +++ b/crates/spectatui/src/ui/mod.rs @@ -6,6 +6,7 @@ mod feature_list; mod header; mod integrations; mod layout_editor; +mod layout_geometry; mod palette; mod popup; mod presets; @@ -21,9 +22,14 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; +use spectatui_core::layout::PaneKind; -use crate::app::{App, DashboardLayout, Screen}; +use crate::app::{App, DashboardLayout, DividerTarget, Screen}; use crate::theme::Theme; +use layout_geometry::{ + custom_layout_geometry, min_width, register_horizontal_divider, register_vertical_divider, + split_horizontal, split_vertical, stored_or_default, stored_or_length, +}; /// Consistent inner padding for every bordered panel/popup so content does not /// hug the frame borders. @@ -186,13 +192,40 @@ fn draw_dashboard(frame: &mut Frame, app: &App, area: Rect) { use crate::app::{ClickAction, Pane}; match app.layout { DashboardLayout::Overview => { - let cols = Layout::horizontal([Constraint::Length(38), Constraint::Min(0)]).split(area); + let sidebar = + stored_or_length(app.config.dashboard_sizes.overview_sidebar, 38, area.width); + let cols = split_horizontal(area, sidebar, min_width(PaneKind::FeatureList), 24); + register_vertical_divider( + app, + DividerTarget::OverviewSidebar, + area, + cols[0], + min_width(PaneKind::FeatureList), + 24, + ); app.register_click(cols[0], ClickAction::FocusPane(Pane::FeatureList)); feature_list::draw(frame, app, cols[0]); - let right = - Layout::vertical([Constraint::Length(13), Constraint::Min(0)]).split(cols[1]); + let workflow = stored_or_length( + app.config.dashboard_sizes.overview_workflow, + 13, + cols[1].height, + ); + let right = split_vertical( + cols[1], + workflow, + PaneKind::WorkflowTimeline.min_height(), + PaneKind::AgentOutput.min_height(), + ); + register_horizontal_divider( + app, + DividerTarget::OverviewWorkflow, + cols[1], + right[0], + PaneKind::WorkflowTimeline.min_height(), + PaneKind::AgentOutput.min_height(), + ); app.register_click(right[0], ClickAction::FocusPane(Pane::Workflow)); app.register_click(right[1], ClickAction::FocusPane(Pane::AgentOutput)); @@ -200,8 +233,20 @@ fn draw_dashboard(frame: &mut Frame, app: &App, area: Rect) { agent_output::draw(frame, app, right[1]); } DashboardLayout::Coding => { - let cols = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); + let cols = split_horizontal( + area, + stored_or_default(app.config.dashboard_sizes.coding_split, 5_000), + min_width(PaneKind::SpecBrowser), + min_width(PaneKind::AgentOutput), + ); + register_vertical_divider( + app, + DividerTarget::CodingSplit, + area, + cols[0], + min_width(PaneKind::SpecBrowser), + min_width(PaneKind::AgentOutput), + ); app.register_click(cols[0], ClickAction::FocusPane(Pane::SpecBrowser)); app.register_click(cols[1], ClickAction::FocusPane(Pane::AgentOutput)); @@ -209,8 +254,20 @@ fn draw_dashboard(frame: &mut Frame, app: &App, area: Rect) { agent_output::draw(frame, app, cols[1]); } DashboardLayout::Audit => { - let cols = - Layout::horizontal([Constraint::Percentage(54), Constraint::Min(0)]).split(area); + let cols = split_horizontal( + area, + stored_or_default(app.config.dashboard_sizes.audit_split, 5_400), + min_width(PaneKind::ExtensionsPresets), + min_width(PaneKind::Constitution), + ); + register_vertical_divider( + app, + DividerTarget::AuditSplit, + area, + cols[0], + min_width(PaneKind::ExtensionsPresets), + min_width(PaneKind::Constitution), + ); app.register_click(cols[0], ClickAction::FocusPane(Pane::ExtensionsPresets)); app.register_click(cols[1], ClickAction::FocusPane(Pane::Constitution)); @@ -224,7 +281,12 @@ fn draw_dashboard(frame: &mut Frame, app: &App, area: Rect) { } fn draw_custom_layout(frame: &mut Frame, app: &App, area: Rect) { - let rects = custom_pane_rects(&app.custom_layout, area); + let (rects, dividers) = custom_layout_geometry( + &app.custom_layout, + app.config.dashboard_sizes.custom_sidebar, + &app.config.dashboard_sizes, + area, + ); if rects.is_empty() { let theme = &app.theme; let msg = ratatui::widgets::Paragraph::new(ratatui::text::Line::from( @@ -237,6 +299,9 @@ fn draw_custom_layout(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(msg, area); return; } + for divider in dividers { + app.register_resize_divider(divider); + } for (kind, rect) in rects { draw_pane_by_kind(frame, app, kind, rect); } @@ -259,25 +324,21 @@ pub(crate) fn custom_pane_rects( let cols = Layout::horizontal([Constraint::Length(38), Constraint::Min(0)]).split(area); let mut out = vec![(visible[0].kind, cols[0])]; - let rest = &visible[1..]; - let total_size: u8 = rest.iter().map(|p| p.size).sum(); + let total_size: u8 = rest.iter().map(|pane| pane.size).sum(); let constraints: Vec = rest .iter() - .map(|p| { + .map(|pane| { if total_size > 0 { - Constraint::Ratio(p.size as u32, total_size as u32) + Constraint::Ratio(pane.size as u32, total_size as u32) } else { Constraint::Min(0) } }) .collect(); - let right_rows = Layout::vertical(constraints).split(cols[1]); - for (i, pane) in rest.iter().enumerate() { - if i < right_rows.len() { - out.push((pane.kind, right_rows[i])); - } + for (pane, rect) in rest.iter().zip(right_rows.iter()) { + out.push((pane.kind, *rect)); } out } diff --git a/specs/001-spectatui-dashboard-mvp/plan.md b/specs/001-spectatui-dashboard-mvp/plan.md index 260e645..fdfe723 100644 --- a/specs/001-spectatui-dashboard-mvp/plan.md +++ b/specs/001-spectatui-dashboard-mvp/plan.md @@ -6,6 +6,14 @@ **Propagated**: 2026-07-14 โ€” Updated from spec.md refinement: catalog browsing of not-yet-installed items (previously out of scope) is delivered via the existing inline `/` filter; the direct-catalog-JSON-fetch design choice (already reflected in Technical Context's `reqwest` line above) is now also documented as an intentional exception in `design/core/spectatui-archi-design.md` ยง1.5, not just an implementation detail. +**Propagated**: 2026-09-13 โ€” Updated from spec.md refinement (FR-024a, draggable +dashboard-divider resizing): documented `DashboardSizes`/`ResizeDivider` as delivered in +Project Structure below. No Constitution Check, Technical Context, or design-doc change +was needed โ€” the feature is mouse-gated UI state layered on the existing +`crossterm`/mouse-event plumbing already covered by Constitution Principle IV, and +`design/core/spectatui-archi-design.md` needs no update since it documents architecture +principles, not per-pane interaction details. + **Input**: Feature specification from `/specs/001-spectatui-dashboard-mvp/spec.md` **Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow. @@ -107,7 +115,9 @@ crates/ spectatui-core/ (lib crate โ€” async engine, UI-agnostic) src/ lib.rs - layout.rs # PaneKind / PaneConfig / CustomLayout + layout.rs # PaneKind / PaneConfig / CustomLayout / + # DashboardSizes / CustomPaneHeight (persisted + # divider-drag proportions) speckit/ mod.rs # Project / Feature discovery (specs/ โ€” read-only) workflow.rs # WorkflowStage inference + tasks progress (read-only) @@ -120,7 +130,9 @@ crates/ spectatui/ (bin crate โ€” UI + event loop, depends on spectatui-core) src/ main.rs - app.rs # App state: Screen / DashboardLayout / Pane / PopupKind / SettingsRow + app.rs # App state: Screen / DashboardLayout / Pane / PopupKind / + # SettingsRow / ResizeDivider / DividerTarget / DividerAxis + # (begin_resize/resize_from_pointer/finish_resize) event.rs # Key / Tick / TmuxChanged / FsChanged config.rs # AppConfig load/save theme.rs # ThemeMode / Accent / Theme @@ -128,7 +140,9 @@ crates/ mod.rs, header.rs, statusbar.rs, feature_list.rs, spec_browser.rs, extensions_presets.rs, integrations.rs, workflows.rs, workflow.rs, agent_output.rs, session_attach.rs, popup.rs, palette.rs, - layout_editor.rs, settings.rs + layout_editor.rs, settings.rs, layout_geometry.rs # divider geometry/ + # hit-box computation + # + per-pane minimums # Tests are colocated `mod tests { ... }` blocks inside the files above # (Constitution Principle II), not a separate tests/ directory. diff --git a/specs/001-spectatui-dashboard-mvp/spec.md b/specs/001-spectatui-dashboard-mvp/spec.md index 22aab2d..4157786 100644 --- a/specs/001-spectatui-dashboard-mvp/spec.md +++ b/specs/001-spectatui-dashboard-mvp/spec.md @@ -10,6 +10,8 @@ **Refined**: 2026-07-14 โ€” Catalog search/browse of not-yet-installed extensions/presets/workflows (previously out of scope) is delivered via the existing inline `/` filter over each manager's "available" rows; documented the deliberate design choice to discover catalog items via direct catalog-JSON fetch rather than CLI `search`/`list --available` parsing. +**Refined**: 2026-09-13 โ€” Documented draggable dashboard-divider resizing: with mouse support enabled, dragging the border between any two adjacent panes in the Overview, Coding, Audit, or Custom layout continuously resizes them (not only via the Custom Layout Editor's keyboard controls), respecting each pane's minimum size, autosaving as a relative proportion on mouse release, and falling back to today's existing default split until first dragged. + **Input**: User description: "feature - initial version. from @design/core/spectatui-archi-design.md and current implementation, write me the necessary spec files" ## Clarifications @@ -85,7 +87,7 @@ A developer wants to arrange which panes are visible and how they're sized, pick **Why this priority**: Personalization increases day-to-day comfort and efficiency but is not required to get value from the dashboard's core monitoring and browsing capability. -**Independent Test**: Switch between the built-in layouts with a single keypress each; enter the layout editor, hide a pane, reorder two panes, and resize one; toggle theme and accent; restart spectatui and confirm every choice persisted. +**Independent Test**: Switch between the built-in layouts with a single keypress each; enter the layout editor, hide a pane, reorder two panes, and resize one; with mouse support enabled, drag a border between two adjacent panes in a built-in layout and confirm the split changes continuously and persists after restart; toggle theme and accent; restart spectatui and confirm every choice persisted. **Acceptance Scenarios**: @@ -94,6 +96,9 @@ A developer wants to arrange which panes are visible and how they're sized, pick 3. **Given** the user has toggled the theme or cycled the accent color, **When** any screen is rendered afterward, **Then** the new theme/accent is applied consistently across every screen and popup. 4. **Given** the user has made layout, theme, or accent changes, **When** they quit and relaunch spectatui, **Then** all of those choices are restored exactly as left. 5. **Given** a project provides its own local settings file, **When** spectatui starts inside that project, **Then** the project-local settings take precedence over the user's general settings for this session. +6. **Given** mouse support is enabled and the dashboard shows two or more adjacent panes (in any of the Overview, Coding, Audit, or Custom layouts), **When** the user drags the border between them, **Then** the split resizes continuously in real time without letting either pane shrink below its usable minimum size. +7. **Given** the user has dragged a divider to a new position, **When** they release the mouse button, **Then** the new split is saved automatically without any separate save step, and is restored on the next launch. +8. **Given** a divider has never been dragged, **When** the dashboard renders that layout, **Then** the split uses today's existing default proportions unchanged. --- @@ -128,6 +133,7 @@ A developer working over SSH or inside tmux (where a mouse may not be usable) wa - What happens when two destructive actions are queued in quick succession? Each mutating action must be confirmed and completed (or fail) independently โ€” there is no batch/silent auto-confirm path. - What happens when the coding-agent tmux session for a feature ends while the user is attached to it? Control must return cleanly to the dashboard rather than leaving the terminal in an inconsistent state. - What happens when a feature's artifacts were produced by an incompatible or unrecognized Spec-Kit template version? The lifecycle stage must show as explicitly "unknown" rather than silently guessing or crashing. +- What happens when the terminal is resized after the user has drag-resized a divider? The persisted split must be stored as a relative proportion (not a fixed cell count) so it re-applies sensibly at the new terminal size rather than clipping or leaving a pane off-screen. ## Requirements *(mandatory)* @@ -169,7 +175,8 @@ A developer working over SSH or inside tmux (where a mouse may not be usable) wa **Layout, theming, and settings** - **FR-023**: System MUST offer a small set of built-in dashboard layout arrangements, each reachable with a single keypress. -- **FR-024**: System MUST allow the user to build a custom layout by showing/hiding, reordering, and resizing the dashboard's panes. +- **FR-024**: System MUST allow the user to build a custom layout by showing/hiding, reordering, and resizing the dashboard's panes via the layout editor's keyboard controls. +- **FR-024a**: When mouse support is enabled, system MUST allow the user to continuously resize adjacent panes in any built-in dashboard layout (Overview, Coding, Audit, Custom) by dragging the border between them, keeping each pane at or above its minimum usable size, persisting the resulting split automatically on mouse release, and defaulting to the layout's existing proportions until first dragged. - **FR-025**: System MUST offer at least two color themes and at least three accent color choices, each togglable/cyclable with a dedicated keypress, applied consistently across every screen and popup. - **FR-026**: System MUST persist the user's layout, theme, accent, and other preference choices between application restarts. - **FR-027**: System MUST allow a project to override the user's general preferences with a project-local settings file when running inside that project. @@ -194,7 +201,7 @@ A developer working over SSH or inside tmux (where a mouse may not be usable) wa - **Integration**: An installable coding-agent tool binding; has an identifier, display name, installed state, default flag, and whether it requires a separate CLI tool. - **Automation Workflow**: An installable, runnable pipeline that automates the lifecycle end-to-end; has an identifier, install state, and run-history summary, distinct from the fixed Lifecycle Stage sequence. - **Coding-Agent Session**: The live terminal session associated with a feature's coding agent; has a running/idle status and recent output used for the live tail and full-attach handoff. Spectatui can create this session itself (running the project's default coding-agent integration) when a selected feature has none, or attach to one already running. -- **User Preferences**: The persisted set of choices (theme, accent, dashboard layout, custom pane arrangement, mouse support, confirmation behavior) that shape the dashboard's appearance and behavior across restarts. +- **User Preferences**: The persisted set of choices (theme, accent, dashboard layout, custom pane arrangement, per-divider pane split proportions, mouse support, confirmation behavior) that shape the dashboard's appearance and behavior across restarts. ## Success Criteria *(mandatory)* diff --git a/specs/001-spectatui-dashboard-mvp/tasks.md b/specs/001-spectatui-dashboard-mvp/tasks.md index 612ecd7..01253a8 100644 --- a/specs/001-spectatui-dashboard-mvp/tasks.md +++ b/specs/001-spectatui-dashboard-mvp/tasks.md @@ -11,6 +11,9 @@ description: "Task list template for feature implementation" not-yet-installed items delivered); added T026 to Phase 5 documenting the delivery and the fix for the stale catalog-availability cache found while implementing it +**Propagated**: 2026-09-13 โ€” Updated from spec.md refinement (FR-024a, draggable +dashboard-divider resizing delivered); added T027 to Phase 6 documenting the delivery + **Input**: Design documents from `/specs/001-spectatui-dashboard-mvp/` **Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md @@ -288,6 +291,32 @@ custom layout editing, theme/accent, and restart persistence. **Checkpoint**: User Stories 1โ€“4 all fully satisfy spec.md. +- [X] T027 [US4] Deliver the spec's 2026-09-13 refinement (FR-024a): draggable + dashboard-divider resizing. **Result**: added `DashboardSizes`/`CustomPaneHeight` + (`crates/spectatui-core/src/layout.rs`) to persist per-divider split proportions + (basis points, not fixed cell counts, so they scale across terminal resizes) alongside + the existing `CustomLayout` editor defaults, which remain untouched; added + `ResizeDivider`/`DividerTarget`/`DividerAxis` plus `App::begin_resize`/ + `resize_from_pointer`/`finish_resize` (`crates/spectatui/src/app.rs`) to hit-test and + drag any divider registered during the last render pass; added + `crates/spectatui/src/ui/layout_geometry.rs` (new file) to compute divider bounds/ + hit-boxes and each `PaneKind`'s minimum width/height, wired into every layout's render + in `crates/spectatui/src/ui/mod.rs` (Overview, Coding, Audit, and Custom's sidebar + + stacked-pane dividers); wired mouse `Down`/`Drag`/`Up` handling in + `crates/spectatui/src/main.rs`, gated off when a popup, the command palette, or the + layout editor is active, and suppressing wheel-scroll while a drag is in progress; + autosaves via `config::save_config` on mouse-up. `AppConfig.dashboard_sizes` + (`crates/spectatui/src/config.rs`) persists the new field with `#[serde(default)]` so + existing config files load unchanged and every split defaults to today's existing + proportion until first dragged. Colocated `mod tests` added in `layout.rs` + (`DashboardSizes`/`CustomPaneHeight` round-trip) and `layout_geometry.rs` + (`split_length` minimum-clamping, `stored_or_length` default-fallback). + `README.md`'s feature list and keybindings table were updated in the same commit + (Constitution Principle III). No `.rs` test coverage was added for the mouse + event-handling glue in `main.rs` itself (consistent with that file's existing + no-automated-tests state for input handling โ€” see T007/T025's citations); flagged here + per Constitution Principle II for a future task if regression coverage is wanted. + --- ## Phase 7: User Story 5 - Navigate and act entirely from the keyboard, with optional mouse support (Priority: P5) From 728b07f6a6af13d97498031ac2a7bfd9c1b23d36 Mon Sep 17 00:00:00 2001 From: Tine Kondo Date: Sun, 13 Sep 2026 07:47:32 +0000 Subject: [PATCH 2/2] feat(layout): add divider hover resize cue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a directional โ†”/โ†• glyph on the dashboard divider under the pointer so users can see continuous resizing is available before dragging. Tracks pointer position through the whole drag and uses a style distinct from the focused-pane border highlight --- README.md | 2 +- crates/spectatui/src/app.rs | 127 +++++++++++++++++++++ crates/spectatui/src/main.rs | 7 ++ crates/spectatui/src/theme.rs | 8 ++ crates/spectatui/src/ui/mod.rs | 33 +++++- specs/001-spectatui-dashboard-mvp/plan.md | 15 ++- specs/001-spectatui-dashboard-mvp/spec.md | 13 ++- specs/001-spectatui-dashboard-mvp/tasks.md | 29 +++++ 8 files changed, 227 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 90e3096..f6723b2 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Options: | `C` | Open Constitution viewer | | `a` | Go to Session attach | -With **Mouse support** enabled, drag any border between adjacent dashboard panels to resize them. Sizes are saved when the mouse button is released and are restored as relative proportions when the terminal is resized. Existing Custom-layout keyboard controls remain available in the layout editor. +With **Mouse support** enabled, drag any border between adjacent dashboard panels to resize them; hovering a border shows a โ†” or โ†• cue confirming it's draggable. Sizes are saved when the mouse button is released and are restored as relative proportions when the terminal is resized. Existing Custom-layout keyboard controls remain available in the layout editor. **Spec browser**: diff --git a/crates/spectatui/src/app.rs b/crates/spectatui/src/app.rs index 3803622..be6b37b 100644 --- a/crates/spectatui/src/app.rs +++ b/crates/spectatui/src/app.rs @@ -78,6 +78,15 @@ pub struct ResizeDivider { pub hitbox: Rect, } +/// The pointer position to draw a directional resize cue (โ†”/โ†•) at, tracked +/// while hovering a divider's hitbox or dragging one (FR-024b). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DividerHover { + pub axis: DividerAxis, + pub col: u16, + pub row: u16, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Pane { FeatureList, @@ -327,6 +336,9 @@ pub struct App { /// regions so mouse hit testing always uses the exact rendered geometry. pub resize_dividers: RefCell>, pub resize_drag: Option, + /// Current pointer position to draw the divider hover cue at, if any + /// (tracked while hovering a divider or dragging one). + pub resize_hover: Option, // Agent output pub agent_lines: Vec, @@ -449,6 +461,7 @@ impl App { layout_editor_active: false, resize_dividers: RefCell::new(Vec::new()), resize_drag: None, + resize_hover: None, agent_lines: Vec::new(), @@ -535,15 +548,52 @@ impl App { .find(|divider| rect_contains(divider.hitbox, col, row)) .copied(); self.resize_drag = divider; + if let Some(divider) = divider { + self.resize_hover = Some(DividerHover { + axis: divider.axis, + col, + row, + }); + } divider.is_some() } + /// Updates which divider (if any) the pointer is hovering, for the + /// resize hover cue (FR-024b). Returns `true` when the hover state + /// changed, so callers can decide whether a redraw is warranted. + pub fn update_divider_hover(&mut self, col: u16, row: u16) -> bool { + let hover = self + .resize_dividers + .borrow() + .iter() + .find(|divider| rect_contains(divider.hitbox, col, row)) + .map(|divider| DividerHover { + axis: divider.axis, + col, + row, + }); + let changed = hover != self.resize_hover; + self.resize_hover = hover; + changed + } + + /// Clears the resize hover cue, e.g. when a popup/palette/layout editor + /// becomes active. Returns `true` when a cue was actually showing. + pub fn clear_divider_hover(&mut self) -> bool { + self.resize_hover.take().is_some() + } + /// Applies one mouse position to the active divider. Returns `true` when /// a persisted layout value changed. pub fn resize_from_pointer(&mut self, col: u16, row: u16) -> bool { let Some(drag) = self.resize_drag else { return false; }; + self.resize_hover = Some(DividerHover { + axis: drag.axis, + col, + row, + }); let amount = match drag.axis { DividerAxis::Vertical => split_percent( col.saturating_sub(drag.bounds.x), @@ -2290,4 +2340,81 @@ mod tests { app.cat_tab = CatalogTarget::Workflow; assert_eq!(app.current_catalog_list().len(), 2); } + + fn test_divider(axis: DividerAxis) -> ResizeDivider { + ResizeDivider { + target: DividerTarget::CodingSplit, + axis, + bounds: Rect::new(0, 0, 100, 40), + min_before: 10, + min_after: 10, + hitbox: Rect::new(50, 0, 2, 40), + } + } + + #[test] + fn update_divider_hover_detects_enter_move_and_leave() { + let mut app = test_app(); + app.register_resize_divider(test_divider(DividerAxis::Vertical)); + + assert!(app.update_divider_hover(50, 5)); + assert_eq!( + app.resize_hover, + Some(DividerHover { + axis: DividerAxis::Vertical, + col: 50, + row: 5 + }) + ); + + // Still inside the hitbox but at a different cell: position tracks the pointer. + assert!(app.update_divider_hover(51, 6)); + + // Leaving the hitbox clears the cue and reports the change. + assert!(app.update_divider_hover(0, 0)); + assert_eq!(app.resize_hover, None); + + // No further change while remaining outside every divider's hitbox. + assert!(!app.update_divider_hover(1, 1)); + } + + #[test] + fn clear_divider_hover_reports_whether_a_cue_was_showing() { + let mut app = test_app(); + app.register_resize_divider(test_divider(DividerAxis::Horizontal)); + app.update_divider_hover(50, 5); + assert!(app.clear_divider_hover()); + assert!(!app.clear_divider_hover()); + } + + #[test] + fn begin_resize_seeds_the_hover_cue_at_the_grabbed_divider() { + let mut app = test_app(); + app.register_resize_divider(test_divider(DividerAxis::Vertical)); + assert!(app.begin_resize(50, 5)); + assert_eq!( + app.resize_hover, + Some(DividerHover { + axis: DividerAxis::Vertical, + col: 50, + row: 5 + }) + ); + } + + #[test] + fn resize_from_pointer_keeps_the_hover_cue_tracking_the_drag() { + let mut app = test_app(); + app.register_resize_divider(test_divider(DividerAxis::Vertical)); + app.begin_resize(50, 5); + app.resize_from_pointer(70, 20); + assert_eq!( + app.resize_hover, + Some(DividerHover { + axis: DividerAxis::Vertical, + col: 70, + row: 20 + }) + ); + } } diff --git a/crates/spectatui/src/main.rs b/crates/spectatui/src/main.rs index b6e86f3..cd8868e 100644 --- a/crates/spectatui/src/main.rs +++ b/crates/spectatui/src/main.rs @@ -1268,6 +1268,13 @@ fn handle_mouse(app: &mut App, mouse: MouseEvent, cli_client: &SpecifyCliClient) MouseEventKind::Up(MouseButton::Left) if app.finish_resize() => { let _ = config::save_config(&app.config); } + MouseEventKind::Moved => { + if app.active_popup.is_none() && app.palette.is_none() && !app.layout_editor_active { + app.update_divider_hover(mouse.column, mouse.row); + } else { + app.clear_divider_hover(); + } + } _ => {} } } diff --git a/crates/spectatui/src/theme.rs b/crates/spectatui/src/theme.rs index 440e479..495ebe4 100644 --- a/crates/spectatui/src/theme.rs +++ b/crates/spectatui/src/theme.rs @@ -71,6 +71,10 @@ pub struct Theme { pub accent_style: Style, pub accent_bold: Style, pub statusbar_style: Style, + /// Divider hover/drag resize cue (โ†”/โ†• glyph). Deliberately independent of + /// `accent` so it's never confused with `border_focused`'s pane-focus + /// highlight. + pub divider_hover: Style, } impl Theme { @@ -164,6 +168,10 @@ impl Theme { .fg(accent_color) .add_modifier(Modifier::BOLD), statusbar_style: Style::default().fg(dim).bg(panel), + divider_hover: Style::default() + .fg(sel_fg) + .bg(sel) + .add_modifier(Modifier::BOLD), } } diff --git a/crates/spectatui/src/ui/mod.rs b/crates/spectatui/src/ui/mod.rs index 0895f90..fdd2aca 100644 --- a/crates/spectatui/src/ui/mod.rs +++ b/crates/spectatui/src/ui/mod.rs @@ -24,7 +24,7 @@ use ratatui::widgets::Paragraph; use ratatui::Frame; use spectatui_core::layout::PaneKind; -use crate::app::{App, DashboardLayout, DividerTarget, Screen}; +use crate::app::{App, DashboardLayout, DividerAxis, DividerTarget, Screen}; use crate::theme::Theme; use layout_geometry::{ custom_layout_geometry, min_width, register_horizontal_divider, register_vertical_divider, @@ -177,6 +177,8 @@ pub fn draw(frame: &mut Frame, app: &App) { statusbar::draw_hints(frame, app, outer[3]); statusbar::draw_statusbar(frame, app, outer[5]); + draw_divider_hover_cue(frame, app); + // Popups render on top if app.active_popup.is_some() { popup::draw(frame, app); @@ -188,6 +190,35 @@ pub fn draw(frame: &mut Frame, app: &App) { } } +/// Renders the FR-024b divider hover/drag cue (โ†”/โ†•) on the exact border cell +/// the pointer is over. Gated the same way `App::begin_resize` already is, so +/// a stale cue never lingers once a popup, the palette, or the layout editor +/// takes over the dashboard. +fn draw_divider_hover_cue(frame: &mut Frame, app: &App) { + if app.screen != Screen::Dashboard + || app.layout_editor_active + || app.active_popup.is_some() + || app.palette.is_some() + { + return; + } + let Some(hover) = app.resize_hover else { + return; + }; + let area = frame.area(); + if hover.col >= area.width || hover.row >= area.height { + return; + } + let glyph = match hover.axis { + DividerAxis::Vertical => 'โ†”', + DividerAxis::Horizontal => 'โ†•', + }; + let style = app.theme.divider_hover; + let cell = &mut frame.buffer_mut()[(hover.col, hover.row)]; + cell.set_char(glyph); + cell.set_style(style); +} + fn draw_dashboard(frame: &mut Frame, app: &App, area: Rect) { use crate::app::{ClickAction, Pane}; match app.layout { diff --git a/specs/001-spectatui-dashboard-mvp/plan.md b/specs/001-spectatui-dashboard-mvp/plan.md index fdfe723..9ef02d4 100644 --- a/specs/001-spectatui-dashboard-mvp/plan.md +++ b/specs/001-spectatui-dashboard-mvp/plan.md @@ -6,6 +6,13 @@ **Propagated**: 2026-07-14 โ€” Updated from spec.md refinement: catalog browsing of not-yet-installed items (previously out of scope) is delivered via the existing inline `/` filter; the direct-catalog-JSON-fetch design choice (already reflected in Technical Context's `reqwest` line above) is now also documented as an intentional exception in `design/core/spectatui-archi-design.md` ยง1.5, not just an implementation detail. +**Propagated**: 2026-09-13 โ€” Updated from spec.md refinement (FR-024b, divider hover +cue), now delivered โ€” see tasks.md T028. No Constitution Check, Technical Context, or +new-dependency change was needed: `crossterm`'s `MouseEventKind::Moved` variant already +existed in the version already in use, it was just unhandled (`main.rs`'s `handle_mouse` +match fell through to `_ => {}`); the new hover-position state and theme style are +additive to `App`/`Theme` below, no new crate. + **Propagated**: 2026-09-13 โ€” Updated from spec.md refinement (FR-024a, draggable dashboard-divider resizing): documented `DashboardSizes`/`ResizeDivider` as delivered in Project Structure below. No Constitution Check, Technical Context, or design-doc change @@ -131,11 +138,13 @@ crates/ src/ main.rs app.rs # App state: Screen / DashboardLayout / Pane / PopupKind / - # SettingsRow / ResizeDivider / DividerTarget / DividerAxis - # (begin_resize/resize_from_pointer/finish_resize) + # SettingsRow / ResizeDivider / DividerTarget / DividerAxis / + # DividerHover (begin_resize/resize_from_pointer/finish_resize/ + # update_divider_hover/clear_divider_hover โ€” FR-024b) event.rs # Key / Tick / TmuxChanged / FsChanged config.rs # AppConfig load/save - theme.rs # ThemeMode / Accent / Theme + theme.rs # ThemeMode / Accent / Theme; `divider_hover` style, distinct + # from `border_focused`'s accent color (FR-024b) ui/ mod.rs, header.rs, statusbar.rs, feature_list.rs, spec_browser.rs, extensions_presets.rs, integrations.rs, workflows.rs, workflow.rs, diff --git a/specs/001-spectatui-dashboard-mvp/spec.md b/specs/001-spectatui-dashboard-mvp/spec.md index 4157786..0cb91bc 100644 --- a/specs/001-spectatui-dashboard-mvp/spec.md +++ b/specs/001-spectatui-dashboard-mvp/spec.md @@ -1,6 +1,6 @@ # Feature Specification: Spectatui Dashboard โ€” Initial Version -**Feature Branch**: `001-spectatui-dashboard-mvp` +**Feature Branch**: `001-spectatui-dashboard-mvp`โ†• **Created**: 2026-07-04 @@ -12,6 +12,8 @@ **Refined**: 2026-09-13 โ€” Documented draggable dashboard-divider resizing: with mouse support enabled, dragging the border between any two adjacent panes in the Overview, Coding, Audit, or Custom layout continuously resizes them (not only via the Custom Layout Editor's keyboard controls), respecting each pane's minimum size, autosaving as a relative proportion on mouse release, and falling back to today's existing default split until first dragged. +**Refined**: 2026-09-13 โ€” Documented a directional hover cue for the dashboard dividers: while hovering (or dragging) a divider with mouse support enabled, a โ†” or โ†• glyph is drawn on the border cell under the pointer, tracking pointer position, to signal that continuous resizing is available there. + **Input**: User description: "feature - initial version. from @design/core/spectatui-archi-design.md and current implementation, write me the necessary spec files" ## Clarifications @@ -87,7 +89,7 @@ A developer wants to arrange which panes are visible and how they're sized, pick **Why this priority**: Personalization increases day-to-day comfort and efficiency but is not required to get value from the dashboard's core monitoring and browsing capability. -**Independent Test**: Switch between the built-in layouts with a single keypress each; enter the layout editor, hide a pane, reorder two panes, and resize one; with mouse support enabled, drag a border between two adjacent panes in a built-in layout and confirm the split changes continuously and persists after restart; toggle theme and accent; restart spectatui and confirm every choice persisted. +**Independent Test**: Switch between the built-in layouts with a single keypress each; enter the layout editor, hide a pane, reorder two panes, and resize one; with mouse support enabled, hover a border between two adjacent panes in a built-in layout and confirm a directional (โ†”/โ†•) cue appears and tracks the pointer, then drag it and confirm the split changes continuously, the cue persists throughout the drag, and the result persists after restart; toggle theme and accent; restart spectatui and confirm every choice persisted. **Acceptance Scenarios**: @@ -99,6 +101,10 @@ A developer wants to arrange which panes are visible and how they're sized, pick 6. **Given** mouse support is enabled and the dashboard shows two or more adjacent panes (in any of the Overview, Coding, Audit, or Custom layouts), **When** the user drags the border between them, **Then** the split resizes continuously in real time without letting either pane shrink below its usable minimum size. 7. **Given** the user has dragged a divider to a new position, **When** they release the mouse button, **Then** the new split is saved automatically without any separate save step, and is restored on the next launch. 8. **Given** a divider has never been dragged, **When** the dashboard renders that layout, **Then** the split uses today's existing default proportions unchanged. +9. **Given** mouse support is enabled and no popup, command palette, or layout editor is active, **When** the pointer moves over a divider's hit-region, **Then** a directional cue appears on the border cell under the pointer โ€” โ†” for a vertical (left-right) divider, โ†• for a horizontal (top-bottom) divider โ€” styled distinctly from the focused-pane border highlight. +10. **Given** the hover cue is showing, **When** the pointer moves within the divider's hit-region, **Then** the cue's position updates to track it. +11. **Given** the user begins dragging a divider, **When** the drag continues, **Then** the cue remains visible for the entire gesture until the mouse button is released. +12. **Given** the hover cue is showing, **When** the pointer leaves the divider's hit-region, or a popup, command palette, or the layout editor becomes active, **Then** the cue no longer appears. --- @@ -133,6 +139,7 @@ A developer working over SSH or inside tmux (where a mouse may not be usable) wa - What happens when two destructive actions are queued in quick succession? Each mutating action must be confirmed and completed (or fail) independently โ€” there is no batch/silent auto-confirm path. - What happens when the coding-agent tmux session for a feature ends while the user is attached to it? Control must return cleanly to the dashboard rather than leaving the terminal in an inconsistent state. - What happens when a feature's artifacts were produced by an incompatible or unrecognized Spec-Kit template version? The lifecycle stage must show as explicitly "unknown" rather than silently guessing or crashing. +- What happens when the pointer hovers a divider while a popup, command palette, or the layout editor is active? No hover cue must appear, consistent with those same states already blocking resize-drag initiation. - What happens when the terminal is resized after the user has drag-resized a divider? The persisted split must be stored as a relative proportion (not a fixed cell count) so it re-applies sensibly at the new terminal size rather than clipping or leaving a pane off-screen. ## Requirements *(mandatory)* @@ -177,6 +184,7 @@ A developer working over SSH or inside tmux (where a mouse may not be usable) wa - **FR-023**: System MUST offer a small set of built-in dashboard layout arrangements, each reachable with a single keypress. - **FR-024**: System MUST allow the user to build a custom layout by showing/hiding, reordering, and resizing the dashboard's panes via the layout editor's keyboard controls. - **FR-024a**: When mouse support is enabled, system MUST allow the user to continuously resize adjacent panes in any built-in dashboard layout (Overview, Coding, Audit, Custom) by dragging the border between them, keeping each pane at or above its minimum usable size, persisting the resulting split automatically on mouse release, and defaulting to the layout's existing proportions until first dragged. +- **FR-024b**: When mouse support is enabled, system MUST render a directional hover cue on a dashboard divider's border cell wherever the pointer is positioned within that divider's hit-region โ€” โ†” for a vertical (left-right) divider, โ†• for a horizontal (top-bottom) divider โ€” under the same conditions FR-024a's drag can begin (no popup, command palette, or layout editor active). The cue MUST track the pointer's exact position along the divider, MUST use a style visually distinct from the existing focused-pane border highlight, MUST remain visible for the duration of an active drag, and MUST disappear when the pointer leaves the divider's hit-region or any of the gating conditions above becomes true. This applies only to FR-024a's dashboard dividers, not the Custom Layout Editor's own keyboard-driven resize controls. - **FR-025**: System MUST offer at least two color themes and at least three accent color choices, each togglable/cyclable with a dedicated keypress, applied consistently across every screen and popup. - **FR-026**: System MUST persist the user's layout, theme, accent, and other preference choices between application restarts. - **FR-027**: System MUST allow a project to override the user's general preferences with a project-local settings file when running inside that project. @@ -228,3 +236,4 @@ A developer working over SSH or inside tmux (where a mouse may not be usable) wa - A supporting terminal-multiplexer session host and the Spec-Kit command-line tool are both expected to be present in the user's environment; their absence is handled as a degraded/error state (see Edge Cases) rather than a supported offline mode. - This initial version targets the current Spec-Kit template conventions rather than maintaining compatibility with multiple historical template versions; artifacts that don't match any recognized pattern degrade to an "unknown stage" indicator instead of being actively parsed by a version-detection system. - Typical usage is a single project with up to roughly 100 total features, extensions, presets, integrations, and workflows combined; the scrolling list views are not required to remain responsive at significantly larger scale in this initial version. +- Controlling the real OS mouse pointer's shape (e.g., swapping it to a resize icon) is out of scope, since no standard, portable mechanism for it exists across terminal emulators; the divider hover cue (FR-024b) is instead drawn by spectatui itself as an in-band glyph on the divider's own border cell. diff --git a/specs/001-spectatui-dashboard-mvp/tasks.md b/specs/001-spectatui-dashboard-mvp/tasks.md index 01253a8..5def401 100644 --- a/specs/001-spectatui-dashboard-mvp/tasks.md +++ b/specs/001-spectatui-dashboard-mvp/tasks.md @@ -11,6 +11,9 @@ description: "Task list template for feature implementation" not-yet-installed items delivered); added T026 to Phase 5 documenting the delivery and the fix for the stale catalog-availability cache found while implementing it +**Propagated**: 2026-09-13 โ€” Updated from spec.md refinement (FR-024b, divider hover +cue); added T028 to Phase 6 as upcoming work (not yet implemented) + **Propagated**: 2026-09-13 โ€” Updated from spec.md refinement (FR-024a, draggable dashboard-divider resizing delivered); added T027 to Phase 6 documenting the delivery @@ -317,6 +320,32 @@ custom layout editing, theme/accent, and restart persistence. no-automated-tests state for input handling โ€” see T007/T025's citations); flagged here per Constitution Principle II for a future task if regression coverage is wanted. +- [X] T028 [US4] Implement the spec's second 2026-09-13 refinement (FR-024b): divider + hover cue. **Result**: added `DividerHover` and `App::resize_hover` + (`crates/spectatui/src/app.rs`), plus `update_divider_hover`/`clear_divider_hover` + (hit-tests the same `resize_dividers` geometry `begin_resize` already uses) and wired + hover-position tracking into `begin_resize`/`resize_from_pointer` so the cue seeds on + mouse-down and follows the pointer for the whole drag. Added + `MouseEventKind::Moved` handling to `handle_mouse` + (`crates/spectatui/src/main.rs`, previously unhandled โ€” fell through to `_ => {}`), + gated identically to `begin_resize` (no popup, no palette, layout editor inactive) and + clearing the cue otherwise. Added a dedicated `Theme::divider_hover` style + (`crates/spectatui/src/theme.rs`, `sel`/`sel_fg` + bold โ€” deliberately not `accent`, so + it can never read as `border_focused`'s pane-focus highlight). Rendering + (`draw_divider_hover_cue` in `crates/spectatui/src/ui/mod.rs`) overlays a single โ†” or โ†• + character directly on the buffer cell at the tracked position โ€” no recoloring of the + rest of the divider โ€” gated the same way at render time as a second line of defense. + Added 4 colocated `mod tests` in `app.rs` covering hover enter/move/leave, the + clear-reports-a-change contract, and hover seeding/tracking through + `begin_resize`/`resize_from_pointer`. Updated `README.md`'s mouse-support paragraph + (Constitution Principle III). `pnpm nx run-many -t build,test,lint` for both crates: + all green (74 tests total, up from 70; zero clippy warnings; `cargo fmt --all -- + --check` clean). **Not covered**: same sandbox limitation as T008/T025 โ€” no real + TTY/tmux available here, so the glyph's actual on-screen appearance (character, + color, position tracking as the mouse moves) was verified by code trace and unit test + only, not visually confirmed in a live terminal; recommend a manual pass before + release. + --- ## Phase 7: User Story 5 - Navigate and act entirely from the keyboard, with optional mouse support (Priority: P5)