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/// Pure grid-geometry function of agent count, named per the add/remove
77/// design (D1). The v0.5.0 layout builder ([`supervisor_layout`]) is already a
78/// pure function of `agent_count`; `layout_for` is the canonical name the
79/// `add-branch` / `remove-branch` specs use to make explicit that the same
80/// geometry is recomputed for `N → N+1` (add) and `N → N−1` (remove)
81/// re-tiling, not just the initial start-time layout.
82///
83/// Returns [`PawError::ConfigError`] when `agent_count > SUPERVISOR_MAX_AGENTS`
84/// — the same "split into multiple sessions" error `git paw start` surfaces.
85pub fn layout_for(agent_count: usize) -> Result<SupervisorLayout, PawError> {
86    supervisor_layout(agent_count)
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn assert_layout(
94        agent_count: usize,
95        expected_rows: usize,
96        expected_top: u8,
97        expected_agent: f32,
98    ) {
99        let layout = supervisor_layout(agent_count).expect("layout should compute");
100        assert_eq!(
101            layout.agent_rows, expected_rows,
102            "agent_rows for {agent_count}"
103        );
104        assert_eq!(
105            layout.total_rows,
106            expected_rows + 1,
107            "total_rows for {agent_count}"
108        );
109        assert_eq!(
110            layout.top_row_pct, expected_top,
111            "top_row_pct for {agent_count}"
112        );
113        assert!(
114            (layout.agent_row_pct - expected_agent).abs() < 0.01,
115            "agent_row_pct for {agent_count}: expected {expected_agent}, got {}",
116            layout.agent_row_pct
117        );
118    }
119
120    #[test]
121    fn layout_for_1_agent() {
122        assert_layout(1, 1, 60, 40.0);
123    }
124
125    #[test]
126    fn layout_for_5_agents() {
127        assert_layout(5, 1, 60, 40.0);
128    }
129
130    #[test]
131    fn layout_for_6_agents() {
132        assert_layout(6, 2, 40, 30.0);
133    }
134
135    #[test]
136    fn layout_for_10_agents() {
137        assert_layout(10, 2, 40, 30.0);
138    }
139
140    #[test]
141    fn layout_for_11_agents() {
142        assert_layout(11, 3, 28, 24.0);
143    }
144
145    #[test]
146    fn layout_for_15_agents() {
147        assert_layout(15, 3, 28, 24.0);
148    }
149
150    #[test]
151    fn layout_for_16_agents() {
152        assert_layout(16, 4, 28, 18.0);
153    }
154
155    #[test]
156    fn layout_for_20_agents() {
157        assert_layout(20, 4, 28, 18.0);
158    }
159
160    #[test]
161    fn layout_for_21_agents() {
162        assert_layout(21, 5, 28, 14.4);
163    }
164
165    #[test]
166    fn layout_for_25_agents() {
167        assert_layout(25, 5, 28, 14.4);
168    }
169
170    #[test]
171    fn layout_rejects_26_agents() {
172        let err = supervisor_layout(26).expect_err("26 agents should be rejected");
173        let msg = err.to_string();
174        assert!(
175            msg.contains("26 agents requested"),
176            "error mentions count: {msg}"
177        );
178        assert!(msg.contains("maximum is 25"), "error mentions max: {msg}");
179        assert!(
180            msg.contains("--branches"),
181            "error suggests --branches workaround: {msg}"
182        );
183    }
184
185    #[test]
186    fn layout_rejects_far_above_cap() {
187        let err = supervisor_layout(100).expect_err("100 agents should be rejected");
188        assert!(err.to_string().contains("100 agents requested"));
189    }
190
191    #[test]
192    fn layout_for_matches_supervisor_layout_across_the_range() {
193        // layout_for is the D1-named alias; it must be identical to
194        // supervisor_layout for every valid count and reject the same way.
195        for n in 1..=SUPERVISOR_MAX_AGENTS {
196            assert_eq!(
197                layout_for(n).expect("layout_for should compute"),
198                supervisor_layout(n).expect("supervisor_layout should compute"),
199                "layout_for({n}) should match supervisor_layout({n})"
200            );
201        }
202        assert!(
203            layout_for(SUPERVISOR_MAX_AGENTS + 1).is_err(),
204            "layout_for should reject above the cap like supervisor_layout"
205        );
206    }
207
208    #[test]
209    fn constants_have_expected_values() {
210        assert_eq!(SUPERVISOR_MAX_AGENTS, 25);
211        assert_eq!(SUPERVISOR_AGENTS_PER_ROW, 5);
212        assert_eq!(SUPERVISOR_PANE_OFFSET, 2);
213    }
214}