Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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; 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**:

| Key | Action |
Expand Down
57 changes: 57 additions & 0 deletions crates/spectatui-core/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomPaneHeight>,
}

#[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);
}
}
266 changes: 265 additions & 1 deletion crates/spectatui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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,
Expand All @@ -33,6 +50,43 @@ 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,
}

/// 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,
Expand Down Expand Up @@ -278,6 +332,13 @@ 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<Vec<ResizeDivider>>,
pub resize_drag: Option<ResizeDivider>,
/// Current pointer position to draw the divider hover cue at, if any
/// (tracked while hovering a divider or dragging one).
pub resize_hover: Option<DividerHover>,

// Agent output
pub agent_lines: Vec<String>,
Expand Down Expand Up @@ -398,6 +459,9 @@ impl App {
custom_layout,
layout_editor_index: 0,
layout_editor_active: false,
resize_dividers: RefCell::new(Vec::new()),
resize_drag: None,
resize_hover: None,

agent_lines: Vec::new(),

Expand Down Expand Up @@ -458,6 +522,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,
Expand All @@ -471,6 +536,128 @@ 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;
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),
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)
}
Expand Down Expand Up @@ -2153,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
})
);
}
}
Loading
Loading