Skip to main content

git_paw/supervisor/
layout.rs

1//! Pane-layout calculation for supervisor-mode tmux sessions.
2//!
3//! v0.5.0 supervisor mode arranges panes as:
4//!
5//! - Pane 0: supervisor agent (50% width of top row)
6//! - Pane 1: dashboard (50% width of top row)
7//! - Panes 2..N+1: coding agents, row-major, up to [`SUPERVISOR_AGENTS_PER_ROW`]
8//!   columns per row
9//!
10//! Vertical proportions vary with the total number of rows. See
11//! `openspec/changes/supervisor-as-pane/specs/tmux-orchestration/spec.md`.
12
13use crate::error::PawError;
14
15/// Maximum agents per supervisor session for v0.5.0. Above this, the launch
16/// is rejected with an actionable "split into multiple sessions" error.
17/// Configurable extension deferred to v1.0.0 (issue #17).
18pub const SUPERVISOR_MAX_AGENTS: usize = 25;
19
20/// Agents per agent-grid row for v0.5.0. Hard-coded; configurable in v1.0.0.
21pub const SUPERVISOR_AGENTS_PER_ROW: usize = 5;
22
23/// Offset applied to agent-pane indices in supervisor mode: supervisor at 0,
24/// dashboard at 1, so the first coding agent lands at pane 2.
25pub const SUPERVISOR_PANE_OFFSET: usize = 2;
26
27/// Computed layout parameters for a supervisor-mode tmux session.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct SupervisorLayout {
30    /// Number of horizontal rows holding coding agents (excludes the top row).
31    pub agent_rows: usize,
32    /// Total tmux rows = `agent_rows + 1` (1 top row + agent rows).
33    pub total_rows: usize,
34    /// Height percentage allocated to the top row (supervisor + dashboard).
35    pub top_row_pct: u8,
36    /// Height percentage allocated to each agent row. `f32` because the
37    /// 21–25-agent bucket lands on 14.4%.
38    pub agent_row_pct: f32,
39}
40
41/// Compute the layout for a supervisor session with `agent_count` coding agents.
42///
43/// Returns [`PawError::ConfigError`] when `agent_count > SUPERVISOR_MAX_AGENTS`.
44pub fn supervisor_layout(agent_count: usize) -> Result<SupervisorLayout, PawError> {
45    if agent_count > SUPERVISOR_MAX_AGENTS {
46        return Err(PawError::ConfigError(format!(
47            "{agent_count} agents requested; maximum is {SUPERVISOR_MAX_AGENTS} per session.\n\
48             \n\
49             Split into multiple sessions:\n  \
50             git paw start --branches <subset>\n\
51             \n\
52             (Configurable max_agents is planned for v1.0.0 — see milestone.)"
53        )));
54    }
55
56    let agent_rows = agent_count.div_ceil(SUPERVISOR_AGENTS_PER_ROW).max(1);
57    let total_rows = agent_rows + 1;
58
59    let (top_row_pct, agent_row_pct) = match total_rows {
60        2 => (60u8, 40.0_f32),
61        3 => (40u8, 30.0_f32),
62        4 => (28u8, 24.0_f32),
63        5 => (28u8, 18.0_f32),
64        6 => (28u8, 14.4_f32),
65        _ => unreachable!("agent_count > SUPERVISOR_MAX_AGENTS is rejected above"),
66    };
67
68    Ok(SupervisorLayout {
69        agent_rows,
70        total_rows,
71        top_row_pct,
72        agent_row_pct,
73    })
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn assert_layout(
81        agent_count: usize,
82        expected_rows: usize,
83        expected_top: u8,
84        expected_agent: f32,
85    ) {
86        let layout = supervisor_layout(agent_count).expect("layout should compute");
87        assert_eq!(
88            layout.agent_rows, expected_rows,
89            "agent_rows for {agent_count}"
90        );
91        assert_eq!(
92            layout.total_rows,
93            expected_rows + 1,
94            "total_rows for {agent_count}"
95        );
96        assert_eq!(
97            layout.top_row_pct, expected_top,
98            "top_row_pct for {agent_count}"
99        );
100        assert!(
101            (layout.agent_row_pct - expected_agent).abs() < 0.01,
102            "agent_row_pct for {agent_count}: expected {expected_agent}, got {}",
103            layout.agent_row_pct
104        );
105    }
106
107    #[test]
108    fn layout_for_1_agent() {
109        assert_layout(1, 1, 60, 40.0);
110    }
111
112    #[test]
113    fn layout_for_5_agents() {
114        assert_layout(5, 1, 60, 40.0);
115    }
116
117    #[test]
118    fn layout_for_6_agents() {
119        assert_layout(6, 2, 40, 30.0);
120    }
121
122    #[test]
123    fn layout_for_10_agents() {
124        assert_layout(10, 2, 40, 30.0);
125    }
126
127    #[test]
128    fn layout_for_11_agents() {
129        assert_layout(11, 3, 28, 24.0);
130    }
131
132    #[test]
133    fn layout_for_15_agents() {
134        assert_layout(15, 3, 28, 24.0);
135    }
136
137    #[test]
138    fn layout_for_16_agents() {
139        assert_layout(16, 4, 28, 18.0);
140    }
141
142    #[test]
143    fn layout_for_20_agents() {
144        assert_layout(20, 4, 28, 18.0);
145    }
146
147    #[test]
148    fn layout_for_21_agents() {
149        assert_layout(21, 5, 28, 14.4);
150    }
151
152    #[test]
153    fn layout_for_25_agents() {
154        assert_layout(25, 5, 28, 14.4);
155    }
156
157    #[test]
158    fn layout_rejects_26_agents() {
159        let err = supervisor_layout(26).expect_err("26 agents should be rejected");
160        let msg = err.to_string();
161        assert!(
162            msg.contains("26 agents requested"),
163            "error mentions count: {msg}"
164        );
165        assert!(msg.contains("maximum is 25"), "error mentions max: {msg}");
166        assert!(
167            msg.contains("--branches"),
168            "error suggests --branches workaround: {msg}"
169        );
170    }
171
172    #[test]
173    fn layout_rejects_far_above_cap() {
174        let err = supervisor_layout(100).expect_err("100 agents should be rejected");
175        assert!(err.to_string().contains("100 agents requested"));
176    }
177
178    #[test]
179    fn constants_have_expected_values() {
180        assert_eq!(SUPERVISOR_MAX_AGENTS, 25);
181        assert_eq!(SUPERVISOR_AGENTS_PER_ROW, 5);
182        assert_eq!(SUPERVISOR_PANE_OFFSET, 2);
183    }
184}