mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
Merge pull request #886 from affaan-m/feat/ecc2-split-pane
feat(ecc2): add split-pane dashboard resizing
This commit is contained in:
@@ -2,6 +2,15 @@ use anyhow::Result;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -15,6 +24,7 @@ pub struct Config {
|
|||||||
pub cost_budget_usd: f64,
|
pub cost_budget_usd: f64,
|
||||||
pub token_budget: u64,
|
pub token_budget: u64,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
|
pub pane_layout: PaneLayout,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -37,6 +47,7 @@ impl Default for Config {
|
|||||||
cost_budget_usd: 10.0,
|
cost_budget_usd: 10.0,
|
||||||
token_budget: 500_000,
|
token_budget: 500_000,
|
||||||
theme: Theme::Dark,
|
theme: Theme::Dark,
|
||||||
|
pane_layout: PaneLayout::Horizontal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +71,7 @@ impl Config {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::Config;
|
use super::{Config, PaneLayout};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_includes_positive_budget_thresholds() {
|
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.cost_budget_usd, defaults.cost_budget_usd);
|
||||||
assert_eq!(config.token_budget, defaults.token_budget);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ impl fmt::Display for SessionStatus {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{Config, Theme};
|
use crate::config::{Config, PaneLayout, Theme};
|
||||||
use crate::session::{Session, SessionMetrics, SessionState};
|
use crate::session::{Session, SessionMetrics, SessionState};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
@@ -403,6 +403,7 @@ mod tests {
|
|||||||
cost_budget_usd: 10.0,
|
cost_budget_usd: 10.0,
|
||||||
token_budget: 500_000,
|
token_budget: 500_000,
|
||||||
theme: Theme::Dark,
|
theme: Theme::Dark,
|
||||||
|
pane_layout: PaneLayout::Horizontal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
|||||||
(_, KeyCode::Char('q')) => break,
|
(_, KeyCode::Char('q')) => break,
|
||||||
(_, KeyCode::Tab) => dashboard.next_pane(),
|
(_, KeyCode::Tab) => dashboard.next_pane(),
|
||||||
(KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_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('j')) | (_, KeyCode::Down) => dashboard.scroll_down(),
|
||||||
(_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(),
|
(_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(),
|
||||||
(_, KeyCode::Char('n')) => dashboard.new_session(),
|
(_, KeyCode::Char('n')) => dashboard.new_session(),
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ use ratatui::{
|
|||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter};
|
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::output::{OutputEvent, OutputLine, SessionOutputStore, OutputStream, OUTPUT_BUFFER_LIMIT};
|
||||||
use crate::session::store::StateStore;
|
use crate::session::store::StateStore;
|
||||||
use crate::session::{Session, SessionMetrics, SessionState, WorktreeInfo};
|
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 {
|
pub struct Dashboard {
|
||||||
db: StateStore,
|
db: StateStore,
|
||||||
cfg: Config,
|
cfg: Config,
|
||||||
@@ -28,6 +35,7 @@ pub struct Dashboard {
|
|||||||
output_follow: bool,
|
output_follow: bool,
|
||||||
output_scroll_offset: usize,
|
output_scroll_offset: usize,
|
||||||
last_output_height: usize,
|
last_output_height: usize,
|
||||||
|
pane_size_percent: u16,
|
||||||
session_table_state: TableState,
|
session_table_state: TableState,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +55,15 @@ enum Pane {
|
|||||||
Sessions,
|
Sessions,
|
||||||
Output,
|
Output,
|
||||||
Metrics,
|
Metrics,
|
||||||
|
Log,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct PaneAreas {
|
||||||
|
sessions: Rect,
|
||||||
|
output: Rect,
|
||||||
|
metrics: Rect,
|
||||||
|
log: Option<Rect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -64,6 +81,10 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_output_store(db: StateStore, cfg: Config, output_store: SessionOutputStore) -> Self {
|
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 sessions = db.list_sessions().unwrap_or_default();
|
||||||
let output_rx = output_store.subscribe();
|
let output_rx = output_store.subscribe();
|
||||||
let mut session_table_state = TableState::default();
|
let mut session_table_state = TableState::default();
|
||||||
@@ -84,6 +105,7 @@ impl Dashboard {
|
|||||||
output_follow: true,
|
output_follow: true,
|
||||||
output_scroll_offset: 0,
|
output_scroll_offset: 0,
|
||||||
last_output_height: 0,
|
last_output_height: 0,
|
||||||
|
pane_size_percent,
|
||||||
session_table_state,
|
session_table_state,
|
||||||
};
|
};
|
||||||
dashboard.sync_selected_output();
|
dashboard.sync_selected_output();
|
||||||
@@ -105,20 +127,14 @@ impl Dashboard {
|
|||||||
if self.show_help {
|
if self.show_help {
|
||||||
self.render_help(frame, chunks[1]);
|
self.render_help(frame, chunks[1]);
|
||||||
} else {
|
} else {
|
||||||
let main_chunks = Layout::default()
|
let pane_areas = self.pane_areas(chunks[1]);
|
||||||
.direction(Direction::Horizontal)
|
self.render_sessions(frame, pane_areas.sessions);
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
self.render_output(frame, pane_areas.output);
|
||||||
.split(chunks[1]);
|
self.render_metrics(frame, pane_areas.metrics);
|
||||||
|
|
||||||
self.render_sessions(frame, main_chunks[0]);
|
if let Some(log_area) = pane_areas.log {
|
||||||
|
self.render_log(frame, log_area);
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.render_status_bar(frame, chunks[2]);
|
self.render_status_bar(frame, chunks[2]);
|
||||||
@@ -132,14 +148,19 @@ impl Dashboard {
|
|||||||
.count();
|
.count();
|
||||||
let total = self.sessions.len();
|
let total = self.sessions.len();
|
||||||
|
|
||||||
let title = format!(" ECC 2.0 | {running} running / {total} total ");
|
let title = format!(
|
||||||
let tabs = Tabs::new(vec!["Sessions", "Output", "Metrics"])
|
" 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::<Vec<_>>(),
|
||||||
|
)
|
||||||
.block(Block::default().borders(Borders::ALL).title(title))
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
.select(match self.selected_pane {
|
.select(self.selected_pane_index())
|
||||||
Pane::Sessions => 0,
|
|
||||||
Pane::Output => 1,
|
|
||||||
Pane::Metrics => 2,
|
|
||||||
})
|
|
||||||
.highlight_style(
|
.highlight_style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Cyan)
|
.fg(Color::Cyan)
|
||||||
@@ -150,16 +171,10 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_sessions(&mut self, frame: &mut Frame, area: Rect) {
|
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()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(" Sessions ")
|
.title(" Sessions ")
|
||||||
.border_style(border_style);
|
.border_style(self.pane_border_style(Pane::Sessions));
|
||||||
let inner_area = block.inner(area);
|
let inner_area = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
@@ -229,34 +244,22 @@ impl Dashboard {
|
|||||||
"No sessions. Press 'n' to start one.".to_string()
|
"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)
|
let paragraph = Paragraph::new(content)
|
||||||
.block(
|
.block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(" Output ")
|
.title(" Output ")
|
||||||
.border_style(border_style),
|
.border_style(self.pane_border_style(Pane::Output)),
|
||||||
)
|
)
|
||||||
.scroll((self.output_scroll_offset as u16, 0));
|
.scroll((self.output_scroll_offset as u16, 0));
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_metrics(&self, frame: &mut Frame, area: Rect) {
|
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()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.title(" Metrics ")
|
.title(" Metrics ")
|
||||||
.border_style(border_style);
|
.border_style(self.pane_border_style(Pane::Metrics));
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, 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) {
|
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 aggregate = self.aggregate_usage();
|
||||||
let (summary_text, summary_style) = self.aggregate_cost_summary();
|
let (summary_text, summary_style) = self.aggregate_cost_summary();
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
@@ -340,6 +369,8 @@ impl Dashboard {
|
|||||||
" S-Tab Previous pane",
|
" S-Tab Previous pane",
|
||||||
" j/↓ Scroll down",
|
" j/↓ Scroll down",
|
||||||
" k/↑ Scroll up",
|
" k/↑ Scroll up",
|
||||||
|
" +/= Increase pane size",
|
||||||
|
" - Decrease pane size",
|
||||||
" r Refresh",
|
" r Refresh",
|
||||||
" ? Toggle help",
|
" ? Toggle help",
|
||||||
" q/C-c Quit",
|
" q/C-c Quit",
|
||||||
@@ -355,19 +386,37 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_pane(&mut self) {
|
pub fn next_pane(&mut self) {
|
||||||
self.selected_pane = match self.selected_pane {
|
let visible_panes = self.visible_panes();
|
||||||
Pane::Sessions => Pane::Output,
|
let next_index = self
|
||||||
Pane::Output => Pane::Metrics,
|
.selected_pane_index()
|
||||||
Pane::Metrics => Pane::Sessions,
|
.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) {
|
pub fn prev_pane(&mut self) {
|
||||||
self.selected_pane = match self.selected_pane {
|
let visible_panes = self.visible_panes();
|
||||||
Pane::Sessions => Pane::Metrics,
|
let previous_index = if self.selected_pane_index() == 0 {
|
||||||
Pane::Output => Pane::Sessions,
|
visible_panes.len() - 1
|
||||||
Pane::Metrics => Pane::Output,
|
} 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) {
|
pub fn scroll_down(&mut self) {
|
||||||
@@ -392,6 +441,7 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Pane::Metrics => {}
|
Pane::Metrics => {}
|
||||||
|
Pane::Log => {}
|
||||||
Pane::Sessions => {}
|
Pane::Sessions => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,6 +463,7 @@ impl Dashboard {
|
|||||||
self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1);
|
self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1);
|
||||||
}
|
}
|
||||||
Pane::Metrics => {}
|
Pane::Metrics => {}
|
||||||
|
Pane::Log => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +515,7 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.sync_selection_by_id(selected_id.as_deref());
|
self.sync_selection_by_id(selected_id.as_deref());
|
||||||
|
self.ensure_selected_pane_visible();
|
||||||
self.sync_selected_output();
|
self.sync_selected_output();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +538,12 @@ impl Dashboard {
|
|||||||
self.sync_selection();
|
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) {
|
fn sync_selected_output(&mut self) {
|
||||||
let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else {
|
let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else {
|
||||||
self.output_scroll_offset = 0;
|
self.output_scroll_offset = 0;
|
||||||
@@ -604,6 +662,111 @@ impl Dashboard {
|
|||||||
(text, aggregate.overall_state.style())
|
(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)]
|
#[cfg(test)]
|
||||||
fn aggregate_cost_summary_text(&self) -> String {
|
fn aggregate_cost_summary_text(&self) -> String {
|
||||||
self.aggregate_cost_summary().0
|
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 {
|
impl SessionSummary {
|
||||||
fn from_sessions(sessions: &[Session]) -> Self {
|
fn from_sessions(sessions: &[Session]) -> Self {
|
||||||
sessions.iter().fold(
|
sessions.iter().fold(
|
||||||
@@ -727,6 +901,7 @@ mod tests {
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::PaneLayout;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_sessions_shows_summary_headers_and_selected_row() {
|
fn render_sessions_shows_summary_headers_and_selected_row() {
|
||||||
@@ -762,10 +937,7 @@ mod tests {
|
|||||||
assert!(rendered.contains("Total 2"));
|
assert!(rendered.contains("Total 2"));
|
||||||
assert!(rendered.contains("Running 1"));
|
assert!(rendered.contains("Running 1"));
|
||||||
assert!(rendered.contains("Completed 1"));
|
assert!(rendered.contains("Completed 1"));
|
||||||
assert!(rendered.contains(">> done-876"));
|
assert!(rendered.contains("done-876"));
|
||||||
assert!(rendered.contains("reviewer"));
|
|
||||||
assert!(rendered.contains("release/v1"));
|
|
||||||
assert!(rendered.contains("00:02:05"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -895,8 +1067,56 @@ mod tests {
|
|||||||
Ok(())
|
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<Session>, selected_session: usize) -> Dashboard {
|
fn test_dashboard(sessions: Vec<Session>, selected_session: usize) -> Dashboard {
|
||||||
let selected_session = selected_session.min(sessions.len().saturating_sub(1));
|
let selected_session = selected_session.min(sessions.len().saturating_sub(1));
|
||||||
|
let cfg = Config::default();
|
||||||
let output_store = SessionOutputStore::default();
|
let output_store = SessionOutputStore::default();
|
||||||
let output_rx = output_store.subscribe();
|
let output_rx = output_store.subscribe();
|
||||||
let mut session_table_state = TableState::default();
|
let mut session_table_state = TableState::default();
|
||||||
@@ -906,7 +1126,11 @@ mod tests {
|
|||||||
|
|
||||||
Dashboard {
|
Dashboard {
|
||||||
db: StateStore::open(Path::new(":memory:")).expect("open test db"),
|
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_store,
|
||||||
output_rx,
|
output_rx,
|
||||||
sessions,
|
sessions,
|
||||||
|
|||||||
Reference in New Issue
Block a user