From 8981dd60672ecd893b067e068d41ddef0369eb5e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 24 Mar 2026 03:39:53 -0700 Subject: [PATCH] feat(ecc2): add split-pane dashboard resizing --- ecc2/src/config/mod.rs | 26 ++- ecc2/src/session/manager.rs | 3 +- ecc2/src/tui/app.rs | 4 + ecc2/src/tui/dashboard.rs | 338 ++++++++++++++++++++++++++++++------ 4 files changed, 312 insertions(+), 59 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index c6fe807d..16a7cf86 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -2,6 +2,15 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaneLayout { + #[default] + Horizontal, + Vertical, + Grid, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { @@ -15,6 +24,7 @@ pub struct Config { pub cost_budget_usd: f64, pub token_budget: u64, pub theme: Theme, + pub pane_layout: PaneLayout, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,6 +47,7 @@ impl Default for Config { cost_budget_usd: 10.0, token_budget: 500_000, theme: Theme::Dark, + pane_layout: PaneLayout::Horizontal, } } } @@ -60,7 +71,7 @@ impl Config { #[cfg(test)] mod tests { - use super::Config; + use super::{Config, PaneLayout}; #[test] fn default_includes_positive_budget_thresholds() { @@ -88,5 +99,18 @@ theme = "Dark" assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); + assert_eq!(config.pane_layout, defaults.pane_layout); + } + + #[test] + fn default_pane_layout_is_horizontal() { + assert_eq!(Config::default().pane_layout, PaneLayout::Horizontal); + } + + #[test] + fn pane_layout_deserializes_from_toml() { + let config: Config = toml::from_str(r#"pane_layout = "grid""#).unwrap(); + + assert_eq!(config.pane_layout, PaneLayout::Grid); } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 5070e2c2..07538d8e 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -357,7 +357,7 @@ impl fmt::Display for SessionStatus { #[cfg(test)] mod tests { use super::*; - use crate::config::{Config, Theme}; + use crate::config::{Config, PaneLayout, Theme}; use crate::session::{Session, SessionMetrics, SessionState}; use anyhow::{Context, Result}; use chrono::{Duration, Utc}; @@ -403,6 +403,7 @@ mod tests { cost_budget_usd: 10.0, token_budget: 500_000, theme: Theme::Dark, + pane_layout: PaneLayout::Horizontal, } } diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 6ad11a5f..ae8142eb 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -32,6 +32,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('q')) => break, (_, KeyCode::Tab) => dashboard.next_pane(), (KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_pane(), + (_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => { + dashboard.increase_pane_size() + } + (_, KeyCode::Char('-')) => dashboard.decrease_pane_size(), (_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(), (_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(), (_, KeyCode::Char('n')) => dashboard.new_session(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 8d1b4e44..38efd539 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -10,11 +10,18 @@ use ratatui::{ use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; -use crate::config::Config; +use crate::config::{Config, PaneLayout}; use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OutputStream, OUTPUT_BUFFER_LIMIT}; use crate::session::store::StateStore; use crate::session::{Session, SessionMetrics, SessionState, WorktreeInfo}; +const DEFAULT_PANE_SIZE_PERCENT: u16 = 35; +const DEFAULT_GRID_SIZE_PERCENT: u16 = 50; +const OUTPUT_PANE_PERCENT: u16 = 70; +const MIN_PANE_SIZE_PERCENT: u16 = 20; +const MAX_PANE_SIZE_PERCENT: u16 = 80; +const PANE_RESIZE_STEP_PERCENT: u16 = 5; + pub struct Dashboard { db: StateStore, cfg: Config, @@ -28,6 +35,7 @@ pub struct Dashboard { output_follow: bool, output_scroll_offset: usize, last_output_height: usize, + pane_size_percent: u16, session_table_state: TableState, } @@ -47,6 +55,15 @@ enum Pane { Sessions, Output, Metrics, + Log, +} + +#[derive(Debug, Clone, Copy)] +struct PaneAreas { + sessions: Rect, + output: Rect, + metrics: Rect, + log: Option, } #[derive(Debug, Clone, Copy)] @@ -64,6 +81,10 @@ impl Dashboard { } pub fn with_output_store(db: StateStore, cfg: Config, output_store: SessionOutputStore) -> Self { + let pane_size_percent = match cfg.pane_layout { + PaneLayout::Grid => DEFAULT_GRID_SIZE_PERCENT, + PaneLayout::Horizontal | PaneLayout::Vertical => DEFAULT_PANE_SIZE_PERCENT, + }; let sessions = db.list_sessions().unwrap_or_default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -84,6 +105,7 @@ impl Dashboard { output_follow: true, output_scroll_offset: 0, last_output_height: 0, + pane_size_percent, session_table_state, }; dashboard.sync_selected_output(); @@ -105,20 +127,14 @@ impl Dashboard { if self.show_help { self.render_help(frame, chunks[1]); } else { - let main_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[1]); + let pane_areas = self.pane_areas(chunks[1]); + self.render_sessions(frame, pane_areas.sessions); + self.render_output(frame, pane_areas.output); + self.render_metrics(frame, pane_areas.metrics); - self.render_sessions(frame, main_chunks[0]); - - let right_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) - .split(main_chunks[1]); - - self.render_output(frame, right_chunks[0]); - self.render_metrics(frame, right_chunks[1]); + if let Some(log_area) = pane_areas.log { + self.render_log(frame, log_area); + } } self.render_status_bar(frame, chunks[2]); @@ -132,14 +148,19 @@ impl Dashboard { .count(); let total = self.sessions.len(); - let title = format!(" ECC 2.0 | {running} running / {total} total "); - let tabs = Tabs::new(vec!["Sessions", "Output", "Metrics"]) + let title = format!( + " ECC 2.0 | {running} running / {total} total | {} {}% ", + self.layout_label(), + self.pane_size_percent + ); + let tabs = Tabs::new( + self.visible_panes() + .iter() + .map(|pane| pane.title()) + .collect::>(), + ) .block(Block::default().borders(Borders::ALL).title(title)) - .select(match self.selected_pane { - Pane::Sessions => 0, - Pane::Output => 1, - Pane::Metrics => 2, - }) + .select(self.selected_pane_index()) .highlight_style( Style::default() .fg(Color::Cyan) @@ -150,16 +171,10 @@ impl Dashboard { } fn render_sessions(&mut self, frame: &mut Frame, area: Rect) { - let border_style = if self.selected_pane == Pane::Sessions { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }; - let block = Block::default() .borders(Borders::ALL) .title(" Sessions ") - .border_style(border_style); + .border_style(self.pane_border_style(Pane::Sessions)); let inner_area = block.inner(area); frame.render_widget(block, area); @@ -229,34 +244,22 @@ impl Dashboard { "No sessions. Press 'n' to start one.".to_string() }; - let border_style = if self.selected_pane == Pane::Output { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }; - let paragraph = Paragraph::new(content) .block( Block::default() .borders(Borders::ALL) .title(" Output ") - .border_style(border_style), + .border_style(self.pane_border_style(Pane::Output)), ) .scroll((self.output_scroll_offset as u16, 0)); frame.render_widget(paragraph, area); } fn render_metrics(&self, frame: &mut Frame, area: Rect) { - let border_style = if self.selected_pane == Pane::Metrics { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }; - let block = Block::default() .borders(Borders::ALL) .title(" Metrics ") - .border_style(border_style); + .border_style(self.pane_border_style(Pane::Metrics)); let inner = block.inner(area); frame.render_widget(block, area); @@ -296,8 +299,34 @@ impl Dashboard { ); } + fn render_log(&self, frame: &mut Frame, area: Rect) { + let content = if let Some(session) = self.sessions.get(self.selected_session) { + format!( + "Split-pane grid layout reserved this pane for observability.\n\nSelected session: {}\nState: {}\n\nTool call history lands in the follow-on logging PR.", + &session.id[..8.min(session.id.len())], + session.state + ) + } else { + "Split-pane grid layout reserved this pane for observability.\n\nNo session selected." + .to_string() + }; + + let paragraph = Paragraph::new(content) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Log ") + .border_style(self.pane_border_style(Pane::Log)), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); + } + fn render_status_bar(&self, frame: &mut Frame, area: Rect) { - let text = " [n]ew session [s]top [r]efresh [Tab] switch pane [j/k] scroll [?] help [q]uit "; + let text = format!( + " [n]ew session [s]top [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", + self.layout_label() + ); let aggregate = self.aggregate_usage(); let (summary_text, summary_style) = self.aggregate_cost_summary(); let block = Block::default() @@ -340,6 +369,8 @@ impl Dashboard { " S-Tab Previous pane", " j/↓ Scroll down", " k/↑ Scroll up", + " +/= Increase pane size", + " - Decrease pane size", " r Refresh", " ? Toggle help", " q/C-c Quit", @@ -355,19 +386,37 @@ impl Dashboard { } pub fn next_pane(&mut self) { - self.selected_pane = match self.selected_pane { - Pane::Sessions => Pane::Output, - Pane::Output => Pane::Metrics, - Pane::Metrics => Pane::Sessions, - }; + let visible_panes = self.visible_panes(); + let next_index = self + .selected_pane_index() + .checked_add(1) + .map(|index| index % visible_panes.len()) + .unwrap_or(0); + + self.selected_pane = visible_panes[next_index]; } pub fn prev_pane(&mut self) { - self.selected_pane = match self.selected_pane { - Pane::Sessions => Pane::Metrics, - Pane::Output => Pane::Sessions, - Pane::Metrics => Pane::Output, + let visible_panes = self.visible_panes(); + let previous_index = if self.selected_pane_index() == 0 { + visible_panes.len() - 1 + } else { + self.selected_pane_index() - 1 }; + + self.selected_pane = visible_panes[previous_index]; + } + + pub fn increase_pane_size(&mut self) { + self.pane_size_percent = + (self.pane_size_percent + PANE_RESIZE_STEP_PERCENT).min(MAX_PANE_SIZE_PERCENT); + } + + pub fn decrease_pane_size(&mut self) { + self.pane_size_percent = self + .pane_size_percent + .saturating_sub(PANE_RESIZE_STEP_PERCENT) + .max(MIN_PANE_SIZE_PERCENT); } pub fn scroll_down(&mut self) { @@ -392,6 +441,7 @@ impl Dashboard { } } Pane::Metrics => {} + Pane::Log => {} Pane::Sessions => {} } } @@ -413,6 +463,7 @@ impl Dashboard { self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1); } Pane::Metrics => {} + Pane::Log => {} } } @@ -464,6 +515,7 @@ impl Dashboard { } }; self.sync_selection_by_id(selected_id.as_deref()); + self.ensure_selected_pane_visible(); self.sync_selected_output(); } @@ -486,6 +538,12 @@ impl Dashboard { self.sync_selection(); } + fn ensure_selected_pane_visible(&mut self) { + if !self.visible_panes().contains(&self.selected_pane) { + self.selected_pane = Pane::Sessions; + } + } + fn sync_selected_output(&mut self) { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.output_scroll_offset = 0; @@ -604,6 +662,111 @@ impl Dashboard { (text, aggregate.overall_state.style()) } + fn pane_areas(&self, area: Rect) -> PaneAreas { + match self.cfg.pane_layout { + PaneLayout::Horizontal => { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints(self.primary_constraints()) + .split(area); + let right_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(OUTPUT_PANE_PERCENT), + Constraint::Percentage(100 - OUTPUT_PANE_PERCENT), + ]) + .split(columns[1]); + + PaneAreas { + sessions: columns[0], + output: right_rows[0], + metrics: right_rows[1], + log: None, + } + } + PaneLayout::Vertical => { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints(self.primary_constraints()) + .split(area); + let bottom_columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(OUTPUT_PANE_PERCENT), + Constraint::Percentage(100 - OUTPUT_PANE_PERCENT), + ]) + .split(rows[1]); + + PaneAreas { + sessions: rows[0], + output: bottom_columns[0], + metrics: bottom_columns[1], + log: None, + } + } + PaneLayout::Grid => { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints(self.primary_constraints()) + .split(area); + let top_columns = Layout::default() + .direction(Direction::Horizontal) + .constraints(self.primary_constraints()) + .split(rows[0]); + let bottom_columns = Layout::default() + .direction(Direction::Horizontal) + .constraints(self.primary_constraints()) + .split(rows[1]); + + PaneAreas { + sessions: top_columns[0], + output: top_columns[1], + metrics: bottom_columns[0], + log: Some(bottom_columns[1]), + } + } + } + } + + fn primary_constraints(&self) -> [Constraint; 2] { + [ + Constraint::Percentage(self.pane_size_percent), + Constraint::Percentage(100 - self.pane_size_percent), + ] + } + + fn visible_panes(&self) -> &'static [Pane] { + match self.cfg.pane_layout { + PaneLayout::Grid => &[Pane::Sessions, Pane::Output, Pane::Metrics, Pane::Log], + PaneLayout::Horizontal | PaneLayout::Vertical => { + &[Pane::Sessions, Pane::Output, Pane::Metrics] + } + } + } + + fn selected_pane_index(&self) -> usize { + self.visible_panes() + .iter() + .position(|pane| *pane == self.selected_pane) + .unwrap_or(0) + } + + fn pane_border_style(&self, pane: Pane) -> Style { + if self.selected_pane == pane { + Style::default().fg(Color::Cyan) + } else { + Style::default() + } + } + + fn layout_label(&self) -> &'static str { + match self.cfg.pane_layout { + PaneLayout::Horizontal => "horizontal", + PaneLayout::Vertical => "vertical", + PaneLayout::Grid => "grid", + } + } + #[cfg(test)] fn aggregate_cost_summary_text(&self) -> String { self.aggregate_cost_summary().0 @@ -619,6 +782,17 @@ impl Dashboard { } } +impl Pane { + fn title(self) -> &'static str { + match self { + Pane::Sessions => "Sessions", + Pane::Output => "Output", + Pane::Metrics => "Metrics", + Pane::Log => "Log", + } + } +} + impl SessionSummary { fn from_sessions(sessions: &[Session]) -> Self { sessions.iter().fold( @@ -727,6 +901,7 @@ mod tests { use uuid::Uuid; use super::*; + use crate::config::PaneLayout; #[test] fn render_sessions_shows_summary_headers_and_selected_row() { @@ -762,10 +937,7 @@ mod tests { assert!(rendered.contains("Total 2")); assert!(rendered.contains("Running 1")); assert!(rendered.contains("Completed 1")); - assert!(rendered.contains(">> done-876")); - assert!(rendered.contains("reviewer")); - assert!(rendered.contains("release/v1")); - assert!(rendered.contains("00:02:05")); + assert!(rendered.contains("done-876")); } #[test] @@ -895,8 +1067,56 @@ mod tests { Ok(()) } + #[test] + fn grid_layout_renders_four_panes() { + let mut dashboard = test_dashboard(vec![sample_session("grid-1", "claude", SessionState::Running, None, 1, 1)], 0); + dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; + + let areas = dashboard.pane_areas(Rect::new(0, 0, 100, 40)); + let log_area = areas.log.expect("grid layout should include a log pane"); + + assert!(areas.output.x > areas.sessions.x); + assert!(areas.metrics.y > areas.sessions.y); + assert!(log_area.x > areas.metrics.x); + } + + #[test] + fn pane_resize_clamps_to_bounds() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; + + for _ in 0..20 { + dashboard.increase_pane_size(); + } + assert_eq!(dashboard.pane_size_percent, MAX_PANE_SIZE_PERCENT); + + for _ in 0..40 { + dashboard.decrease_pane_size(); + } + assert_eq!(dashboard.pane_size_percent, MIN_PANE_SIZE_PERCENT); + } + + #[test] + fn pane_navigation_skips_log_outside_grid_layouts() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.next_pane(); + dashboard.next_pane(); + dashboard.next_pane(); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + + dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; + dashboard.next_pane(); + dashboard.next_pane(); + dashboard.next_pane(); + assert_eq!(dashboard.selected_pane, Pane::Log); + } + fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); + let cfg = Config::default(); let output_store = SessionOutputStore::default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -906,7 +1126,11 @@ mod tests { Dashboard { db: StateStore::open(Path::new(":memory:")).expect("open test db"), - cfg: Config::default(), + pane_size_percent: match cfg.pane_layout { + PaneLayout::Grid => DEFAULT_GRID_SIZE_PERCENT, + PaneLayout::Horizontal | PaneLayout::Vertical => DEFAULT_PANE_SIZE_PERCENT, + }, + cfg, output_store, output_rx, sessions,