Skip to main content

batty_cli/team/
layout.rs

1//! tmux layout builder — creates zones and panes from team hierarchy.
2//!
3//! Zones are vertical columns in the tmux window. Within each zone, members
4//! are stacked vertically; engineer-heavy zones may first be partitioned into
5//! manager-aligned subcolumns to preserve the reporting hierarchy.
6
7use std::collections::HashMap;
8use std::fs::OpenOptions;
9use std::path::Path;
10
11use anyhow::{Result, bail};
12use tracing::{debug, info};
13
14use super::config::{LayoutConfig, OrchestratorPosition, RoleType, WorkflowMode};
15use super::hierarchy::MemberInstance;
16use crate::tmux;
17
18const ORCHESTRATOR_PANE_WIDTH_PCT: u32 = 20;
19const ORCHESTRATOR_ROLE: &str = "orchestrator";
20
21#[derive(Debug, Clone)]
22struct ZonePlan<'a> {
23    width_pct: u32,
24    members: Vec<&'a MemberInstance>,
25    horizontal_columns: usize,
26}
27
28/// Build the tmux layout for a team session.
29///
30/// Creates the session with the first member's pane, then splits to create
31/// all remaining panes. Returns a mapping of member name → tmux pane target.
32pub fn build_layout(
33    session: &str,
34    members: &[MemberInstance],
35    layout: &Option<LayoutConfig>,
36    project_root: &Path,
37    workflow_mode: WorkflowMode,
38    orchestrator_pane: bool,
39    orchestrator_position: OrchestratorPosition,
40) -> Result<HashMap<String, String>> {
41    let pane_members: Vec<_> = members
42        .iter()
43        .filter(|m| m.role_type != RoleType::User)
44        .collect();
45
46    if pane_members.is_empty() {
47        bail!("no pane members to create layout for");
48    }
49
50    let work_dir = project_root.to_string_lossy().to_string();
51
52    // Create session with the first member
53    tmux::create_session(session, "bash", &[], &work_dir)?;
54    tmux::rename_window(&format!("{session}:0"), "team")?;
55
56    // Enable pane borders with role labels using @batty_role (agent-proof)
57    let _ = std::process::Command::new("tmux")
58        .args(["set-option", "-t", session, "pane-border-status", "top"])
59        .output();
60    let _ = std::process::Command::new("tmux")
61        .args([
62            "set-option",
63            "-t",
64            session,
65            "pane-border-format",
66            " #[fg=green,bold]#{@batty_role}#[default] #{@batty_status} ",
67        ])
68        .output();
69
70    let mut pane_map: HashMap<String, String> = HashMap::new();
71    let orchestrator_enabled = should_launch_orchestrator_pane(workflow_mode, orchestrator_pane);
72    let initial_pane = tmux::pane_id(session)?;
73    let agent_root_pane = if orchestrator_enabled {
74        launch_orchestrator_pane(session, &initial_pane, project_root, orchestrator_position)?
75    } else {
76        initial_pane
77    };
78
79    if pane_members.len() == 1 {
80        // Single pane — just use the initial pane
81        set_pane_title(session, &agent_root_pane, &pane_members[0].name)?;
82        pane_map.insert(pane_members[0].name.clone(), agent_root_pane);
83        return Ok(pane_map);
84    }
85
86    // Group members by zone for layout
87    let zones = if let Some(layout_config) = layout {
88        build_zones_from_config(layout_config, &pane_members)
89    } else {
90        build_zones_auto(&pane_members)
91    };
92
93    // Create remaining zone columns by splitting the previous zone's pane.
94    // Left-to-right: each split carves the next zone off the right side.
95    //
96    // tmux `split -h -p N` gives the NEW pane N% of the source pane.
97    // Before each split, the source pane represents zones [i-1..N]. We want
98    // zone i-1 to keep its share and the new pane to get the rest.
99    //
100    // Example: zones [20%, 20%, 60%]:
101    //   Split 1: source = zones [0,1,2] = 100%. New pane gets (20+60)/100 = 80%.
102    //   Split 2: source = zones [1,2] = 80%.  New pane gets 60/80 = 75%.
103    let mut zone_panes: Vec<String> = vec![agent_root_pane.clone()];
104    let mut remaining_pct: u32 = zones.iter().map(|zone| zone.width_pct).sum();
105    for (i, _zone) in zones.iter().enumerate().skip(1) {
106        let right_side: u32 = zones[i..].iter().map(|zone| zone.width_pct).sum();
107        let split_pct = ((right_side as f64 / remaining_pct as f64) * 100.0).round() as u32;
108        let split_pct = split_pct.clamp(10, 90);
109        let split_from = zone_panes.last().unwrap();
110        let pane_id = tmux::split_window_horizontal(split_from, split_pct)?;
111        zone_panes.push(pane_id);
112        remaining_pct = right_side;
113        debug!(zone = i, split_pct, "created zone column");
114    }
115
116    // Within each zone, split vertically for members. Engineer zones with
117    // multiple managers are partitioned into per-manager subcolumns first.
118    for (zone_idx, zone) in zones.iter().enumerate() {
119        let zone_pane = &zone_panes[zone_idx];
120        let zone_members = &zone.members;
121
122        if zone_members.is_empty() {
123            continue;
124        }
125
126        let subgroups = split_zone_subgroups(zone_members);
127        if subgroups.len() == 1 {
128            let columns = split_members_into_columns(zone_members, zone.horizontal_columns);
129            if columns.len() == 1 {
130                stack_members_in_pane(session, zone_pane, &columns[0], &mut pane_map)?;
131            } else {
132                let column_panes = split_subgroup_columns(zone_pane, &columns)?;
133                for (column_pane, column_members) in column_panes.iter().zip(columns.iter()) {
134                    stack_members_in_pane(session, column_pane, column_members, &mut pane_map)?;
135                }
136            }
137            continue;
138        }
139
140        let subgroup_panes = split_subgroup_columns(zone_pane, &subgroups)?;
141        for (subgroup_pane, subgroup_members) in subgroup_panes.iter().zip(subgroups.iter()) {
142            stack_members_in_pane(session, subgroup_pane, subgroup_members, &mut pane_map)?;
143        }
144    }
145
146    info!(session, panes = pane_map.len(), "team layout created");
147
148    Ok(pane_map)
149}
150
151fn should_launch_orchestrator_pane(workflow_mode: WorkflowMode, orchestrator_pane: bool) -> bool {
152    workflow_mode.enables_runtime_surface() && orchestrator_pane
153}
154
155fn launch_orchestrator_pane(
156    session: &str,
157    initial_pane: &str,
158    project_root: &Path,
159    position: OrchestratorPosition,
160) -> Result<String> {
161    let plain_log_path = super::orchestrator_log_path(project_root);
162    let ansi_log_path = super::orchestrator_ansi_log_path(project_root);
163    ensure_orchestrator_log(&plain_log_path)?;
164    ensure_orchestrator_log(&ansi_log_path)?;
165
166    let (orchestrator_target, agent_root_pane) = match position {
167        OrchestratorPosition::Left => {
168            // Split horizontally: new pane (right) gets the remaining width for agents.
169            // The original pane (left) becomes the orchestrator column.
170            let agent_pane =
171                tmux::split_window_horizontal(initial_pane, 100 - ORCHESTRATOR_PANE_WIDTH_PCT)?;
172            (initial_pane.to_string(), agent_pane)
173        }
174        OrchestratorPosition::Bottom => {
175            // Split vertically: new pane (bottom) becomes the orchestrator.
176            // The original pane (top) remains the agent root.
177            let orch_pane = tmux::split_window_vertical_in_pane(
178                session,
179                initial_pane,
180                ORCHESTRATOR_PANE_WIDTH_PCT,
181            )?;
182            (orch_pane, initial_pane.to_string())
183        }
184    };
185
186    let tail_command = format!(
187        "bash -lc 'touch {path}; exec tail -n 200 -F {path}'",
188        path = shell_single_quote(ansi_log_path.to_string_lossy().as_ref())
189    );
190    tmux::respawn_pane(&orchestrator_target, &tail_command)?;
191    set_pane_title(session, &orchestrator_target, ORCHESTRATOR_ROLE)?;
192    let _ = std::process::Command::new("tmux")
193        .args([
194            "set-option",
195            "-p",
196            "-t",
197            orchestrator_target.as_str(),
198            "@batty_status",
199            "workflow stream",
200        ])
201        .output();
202    let _ = std::process::Command::new("tmux")
203        .args(["select-pane", "-t", agent_root_pane.as_str()])
204        .output();
205    Ok(agent_root_pane)
206}
207
208fn ensure_orchestrator_log(path: &Path) -> Result<()> {
209    if let Some(parent) = path.parent() {
210        std::fs::create_dir_all(parent)?;
211    }
212    OpenOptions::new().create(true).append(true).open(path)?;
213    Ok(())
214}
215
216fn shell_single_quote(value: &str) -> String {
217    value.replace('\'', "'\"'\"'")
218}
219
220fn batty_binary() -> String {
221    std::env::current_exe()
222        .ok()
223        .and_then(|path| path.to_str().map(str::to_string))
224        .unwrap_or_else(|| "batty".to_string())
225}
226
227/// Build a shell command that runs the interactive shim pane bridge.
228pub fn display_pane_command(
229    project_root: &Path,
230    member: &str,
231    events_log_path: &Path,
232    pty_log_path: &Path,
233) -> String {
234    let batty = batty_binary();
235    format!(
236        "bash -lc 'touch {events}; touch {pty}; exec {batty} console-pane --project-root {root} --member {member} --events-log-path {events} --pty-log-path {pty}'",
237        batty = shell_single_quote(&batty),
238        root = shell_single_quote(project_root.to_string_lossy().as_ref()),
239        member = shell_single_quote(member),
240        events = shell_single_quote(events_log_path.to_string_lossy().as_ref()),
241        pty = shell_single_quote(pty_log_path.to_string_lossy().as_ref()),
242    )
243}
244
245/// Respawn a tmux pane as an interactive shim console pane.
246pub fn respawn_as_display_pane(
247    pane_target: &str,
248    project_root: &Path,
249    member: &str,
250    events_log_path: &Path,
251    pty_log_path: &Path,
252) -> Result<()> {
253    let cmd = display_pane_command(project_root, member, events_log_path, pty_log_path);
254    crate::tmux::respawn_pane(pane_target, &cmd)?;
255    Ok(())
256}
257
258fn split_off_current_member_pct(total_slots: usize) -> u32 {
259    (((1.0 / total_slots as f64) * 100.0).round() as u32).clamp(10, 90)
260}
261
262fn split_zone_subgroups<'a>(zone_members: &'a [&MemberInstance]) -> Vec<Vec<&'a MemberInstance>> {
263    let engineer_hierarchy = zone_members
264        .iter()
265        .all(|member| member.role_type == RoleType::Engineer && member.reports_to.is_some());
266    if !engineer_hierarchy {
267        return vec![zone_members.to_vec()];
268    }
269
270    let mut groups: Vec<(String, Vec<&MemberInstance>)> = Vec::new();
271    for member in zone_members {
272        let parent = member.reports_to.clone().unwrap_or_default();
273        if let Some((_, grouped)) = groups
274            .iter_mut()
275            .find(|(reports_to, _)| *reports_to == parent)
276        {
277            grouped.push(*member);
278        } else {
279            groups.push((parent, vec![*member]));
280        }
281    }
282
283    groups.into_iter().map(|(_, grouped)| grouped).collect()
284}
285
286fn split_members_into_columns<'a>(
287    members: &[&'a MemberInstance],
288    desired_columns: usize,
289) -> Vec<Vec<&'a MemberInstance>> {
290    let columns = desired_columns.clamp(1, members.len().max(1));
291    if columns == 1 {
292        return vec![members.to_vec()];
293    }
294
295    let mut groups = Vec::with_capacity(columns);
296    let mut start = 0;
297    for column_idx in 0..columns {
298        let remaining_members = members.len() - start;
299        let remaining_columns = columns - column_idx;
300        let take = remaining_members.div_ceil(remaining_columns);
301        groups.push(members[start..start + take].to_vec());
302        start += take;
303    }
304
305    groups
306}
307
308fn split_subgroup_columns(
309    zone_pane: &str,
310    subgroups: &[Vec<&MemberInstance>],
311) -> Result<Vec<String>> {
312    let mut panes = vec![zone_pane.to_string()];
313    let mut remaining_weight: usize = subgroups.iter().map(Vec::len).sum();
314
315    for subgroup_idx in 1..subgroups.len() {
316        let right_weight: usize = subgroups[subgroup_idx..].iter().map(Vec::len).sum();
317        let split_pct = ((right_weight as f64 / remaining_weight as f64) * 100.0).round() as u32;
318        let split_pct = split_pct.clamp(10, 90);
319        let split_from = panes.last().unwrap();
320        let pane_id = tmux::split_window_horizontal(split_from, split_pct)?;
321        panes.push(pane_id);
322        remaining_weight = right_weight;
323    }
324
325    Ok(panes)
326}
327
328fn stack_members_in_pane(
329    session: &str,
330    pane_id: &str,
331    members: &[&MemberInstance],
332    pane_map: &mut HashMap<String, String>,
333) -> Result<()> {
334    let remaining_pane = pane_id.to_string();
335
336    for member_idx in (1..members.len()).rev() {
337        let member = members[member_idx];
338        let pct = split_off_current_member_pct(member_idx + 1);
339        let member_pane = tmux::split_window_vertical_in_pane(session, &remaining_pane, pct)?;
340        set_pane_title(session, &member_pane, &member.name)?;
341        debug!(
342            member = %member.name,
343            pane = %member_pane,
344            split_pct = pct,
345            "created member pane"
346        );
347        pane_map.insert(member.name.clone(), member_pane);
348    }
349
350    tmux::select_layout_even(&remaining_pane)?;
351    set_pane_title(session, &remaining_pane, &members[0].name)?;
352    pane_map.insert(members[0].name.clone(), remaining_pane);
353    Ok(())
354}
355
356/// Set a pane's title and store the role name in a custom tmux option.
357///
358/// We set both `select-pane -T` (standard title) and a custom pane option
359/// `@batty_role` that agents like Claude Code cannot overwrite. The
360/// `pane-border-format` reads `@batty_role` for a stable label.
361fn set_pane_title(_session: &str, pane_id: &str, title: &str) -> Result<()> {
362    // Use select-pane -T to set pane title. Pane IDs (%N) are global in tmux.
363    let output = std::process::Command::new("tmux")
364        .args(["select-pane", "-t", pane_id, "-T", title])
365        .output()?;
366    if !output.status.success() {
367        debug!(
368            pane = pane_id,
369            title, "failed to set pane title (non-critical)"
370        );
371    }
372
373    // Store role name in a custom pane option that agents can't overwrite
374    let _ = std::process::Command::new("tmux")
375        .args(["set-option", "-p", "-t", pane_id, "@batty_role", title])
376        .output();
377
378    Ok(())
379}
380
381/// Group members into zones based on explicit layout config.
382fn build_zones_from_config<'a>(
383    config: &LayoutConfig,
384    members: &'a [&MemberInstance],
385) -> Vec<ZonePlan<'a>> {
386    let mut zones: Vec<ZonePlan<'a>> = config
387        .zones
388        .iter()
389        .map(|z| ZonePlan {
390            width_pct: z.width_pct,
391            members: Vec::new(),
392            horizontal_columns: z
393                .split
394                .as_ref()
395                .map(|split| split.horizontal as usize)
396                .unwrap_or(1)
397                .max(1),
398        })
399        .collect();
400
401    // Map members to zones by role type
402    let mut member_queue: Vec<&MemberInstance> = members.to_vec();
403
404    for (zone_idx, zone_def) in config.zones.iter().enumerate() {
405        let zone_name = zone_def.name.as_str();
406
407        let exact_matches: Vec<&MemberInstance> = member_queue
408            .iter()
409            .copied()
410            .filter(|member| member.name == zone_name || member.role_name == zone_name)
411            .collect();
412        if !exact_matches.is_empty() {
413            let mut selected_names: Vec<&str> = Vec::new();
414            for member in exact_matches {
415                if !selected_names.contains(&member.name.as_str()) {
416                    zones[zone_idx].members.push(member);
417                    selected_names.push(member.name.as_str());
418                }
419                if member.role_type == RoleType::Manager {
420                    for report in member_queue.iter().copied().filter(|candidate| {
421                        candidate.reports_to.as_deref() == Some(member.name.as_str())
422                    }) {
423                        if !selected_names.contains(&report.name.as_str()) {
424                            zones[zone_idx].members.push(report);
425                            selected_names.push(report.name.as_str());
426                        }
427                    }
428                }
429            }
430            member_queue.retain(|member| !selected_names.contains(&member.name.as_str()));
431            continue;
432        }
433
434        // Try to match zone name to role types
435        let target_types = match zone_name {
436            n if n.contains("architect") => vec![RoleType::Architect],
437            n if n.contains("manager") => vec![RoleType::Manager],
438            n if n.contains("engineer") => vec![RoleType::Engineer],
439            _ => continue,
440        };
441
442        let max_members = zone_def
443            .split
444            .as_ref()
445            .map(|s| s.horizontal as usize)
446            .unwrap_or(usize::MAX);
447
448        let mut taken = 0;
449        member_queue.retain(|m| {
450            if taken >= max_members {
451                return true;
452            }
453            if target_types.contains(&m.role_type) {
454                zones[zone_idx].members.push(m);
455                taken += 1;
456                false
457            } else {
458                true
459            }
460        });
461    }
462
463    // Put any unplaced members in the last zone
464    if let Some(last) = zones.last_mut() {
465        last.members.extend(member_queue);
466    }
467
468    // Remove empty zones
469    zones.retain(|zone| !zone.members.is_empty());
470    zones
471}
472
473/// Auto-generate zones from member role types.
474fn build_zones_auto<'a>(members: &'a [&MemberInstance]) -> Vec<ZonePlan<'a>> {
475    let architects: Vec<_> = members
476        .iter()
477        .filter(|m| m.role_type == RoleType::Architect)
478        .copied()
479        .collect();
480    let managers: Vec<_> = members
481        .iter()
482        .filter(|m| m.role_type == RoleType::Manager)
483        .copied()
484        .collect();
485    let engineers: Vec<_> = members
486        .iter()
487        .filter(|m| m.role_type == RoleType::Engineer)
488        .copied()
489        .collect();
490
491    let mut zones = Vec::new();
492    let total = members.len() as u32;
493
494    if !architects.is_empty() {
495        let pct = ((architects.len() as u32 * 100) / total).max(10);
496        zones.push(ZonePlan {
497            width_pct: pct,
498            members: architects,
499            horizontal_columns: 1,
500        });
501    }
502    if !managers.is_empty() {
503        let pct = ((managers.len() as u32 * 100) / total).max(15);
504        zones.push(ZonePlan {
505            width_pct: pct,
506            members: managers,
507            horizontal_columns: 1,
508        });
509    }
510    if !engineers.is_empty() {
511        let pct = ((engineers.len() as u32 * 100) / total).max(20);
512        zones.push(ZonePlan {
513            width_pct: pct,
514            members: engineers,
515            horizontal_columns: 1,
516        });
517    }
518
519    zones
520}
521
522#[cfg(test)]
523mod tests {
524    use super::super::config::TeamConfig;
525    use super::super::hierarchy;
526    use super::*;
527    use serial_test::serial;
528    use std::process::Command;
529
530    fn make_members(yaml: &str) -> Vec<MemberInstance> {
531        let config: TeamConfig = serde_yaml::from_str(yaml).unwrap();
532        hierarchy::resolve_hierarchy(&config).unwrap()
533    }
534
535    #[test]
536    fn auto_zones_group_by_role() {
537        let members = make_members(
538            r#"
539name: test
540roles:
541  - name: architect
542    role_type: architect
543    agent: claude
544    instances: 1
545  - name: manager
546    role_type: manager
547    agent: claude
548    instances: 2
549  - name: engineer
550    role_type: engineer
551    agent: codex
552    instances: 3
553"#,
554        );
555        let pane_members: Vec<_> = members
556            .iter()
557            .filter(|m| m.role_type != RoleType::User)
558            .collect();
559        let zones = build_zones_auto(&pane_members);
560        assert_eq!(zones.len(), 3);
561        assert_eq!(zones[0].members.len(), 1); // architect
562        assert_eq!(zones[1].members.len(), 2); // managers
563        assert_eq!(zones[2].members.len(), 6); // engineers (2 managers × 3 each)
564    }
565
566    #[test]
567    fn config_zones_assign_members() {
568        let members = make_members(
569            r#"
570name: test
571roles:
572  - name: architect
573    role_type: architect
574    agent: claude
575    instances: 1
576  - name: manager
577    role_type: manager
578    agent: claude
579    instances: 1
580  - name: engineer
581    role_type: engineer
582    agent: codex
583    instances: 3
584"#,
585        );
586        let layout = LayoutConfig {
587            zones: vec![
588                super::super::config::ZoneDef {
589                    name: "architect".to_string(),
590                    width_pct: 20,
591                    split: None,
592                },
593                super::super::config::ZoneDef {
594                    name: "managers".to_string(),
595                    width_pct: 30,
596                    split: None,
597                },
598                super::super::config::ZoneDef {
599                    name: "engineers".to_string(),
600                    width_pct: 50,
601                    split: None,
602                },
603            ],
604        };
605        let pane_members: Vec<_> = members
606            .iter()
607            .filter(|m| m.role_type != RoleType::User)
608            .collect();
609        let zones = build_zones_from_config(&layout, &pane_members);
610        assert_eq!(zones.len(), 3);
611        assert_eq!(zones[0].members[0].role_type, RoleType::Architect);
612        assert_eq!(zones[1].members[0].role_type, RoleType::Manager);
613        assert_eq!(zones[2].members.len(), 3);
614    }
615
616    #[test]
617    fn split_percentages_preserve_equal_zone_stack() {
618        let splits: Vec<_> = (2..=6).map(split_off_current_member_pct).collect();
619        assert_eq!(splits, vec![50, 33, 25, 20, 17]);
620    }
621
622    #[test]
623    #[serial]
624    #[cfg_attr(not(feature = "integration"), ignore)]
625    fn build_layout_supports_architect_two_managers_and_six_engineers() {
626        let session = "batty-test-team-layout-nine";
627        let _ = crate::tmux::kill_session(session);
628
629        let members = make_members(
630            r#"
631name: test
632roles:
633  - name: architect
634    role_type: architect
635    agent: codex
636  - name: manager
637    role_type: manager
638    agent: codex
639    instances: 2
640  - name: engineer
641    role_type: engineer
642    agent: codex
643    instances: 3
644    talks_to: [manager]
645"#,
646        );
647
648        let layout = Some(LayoutConfig {
649            zones: vec![
650                super::super::config::ZoneDef {
651                    name: "architect".to_string(),
652                    width_pct: 15,
653                    split: None,
654                },
655                super::super::config::ZoneDef {
656                    name: "managers".to_string(),
657                    width_pct: 25,
658                    split: Some(super::super::config::SplitDef { horizontal: 2 }),
659                },
660                super::super::config::ZoneDef {
661                    name: "engineers".to_string(),
662                    width_pct: 60,
663                    split: Some(super::super::config::SplitDef { horizontal: 6 }),
664                },
665            ],
666        });
667
668        let pane_map = build_layout(
669            session,
670            &members,
671            &layout,
672            Path::new("/tmp"),
673            WorkflowMode::Legacy,
674            true,
675            OrchestratorPosition::Bottom,
676        )
677        .unwrap();
678        assert_eq!(pane_map.len(), 9);
679
680        let pane_count_output = Command::new("tmux")
681            .args(["list-panes", "-t", session, "-F", "#{pane_id}"])
682            .output()
683            .unwrap();
684        assert!(pane_count_output.status.success());
685        let pane_count = String::from_utf8_lossy(&pane_count_output.stdout)
686            .lines()
687            .count();
688        assert_eq!(pane_count, 9);
689
690        let engineer_geometry_output = Command::new("tmux")
691            .args([
692                "list-panes",
693                "-t",
694                session,
695                "-F",
696                "#{pane_title} #{pane_left} #{pane_height}",
697            ])
698            .output()
699            .unwrap();
700        assert!(engineer_geometry_output.status.success());
701        let mut engineer_columns: HashMap<u32, Vec<u32>> = HashMap::new();
702        for line in String::from_utf8_lossy(&engineer_geometry_output.stdout).lines() {
703            let parts: Vec<_> = line.split_whitespace().collect();
704            if parts.len() != 3 || !parts[0].starts_with("eng-") {
705                continue;
706            }
707            let left: u32 = parts[1].parse().unwrap();
708            let height: u32 = parts[2].parse().unwrap();
709            engineer_columns.entry(left).or_default().push(height);
710        }
711        assert_eq!(engineer_columns.len(), 2);
712        assert!(engineer_columns.values().all(|heights| heights.len() == 3));
713        for heights in engineer_columns.values() {
714            assert!(heights.iter().all(|height| *height >= 4));
715            let min_height = heights.iter().min().copied().unwrap();
716            let max_height = heights.iter().max().copied().unwrap();
717            // tmux rounds pane sizes slightly differently across platforms
718            // once pane borders/status lines are enabled. We only need to
719            // ensure the engineer stacks stay materially balanced.
720            assert!(max_height - min_height <= 2);
721        }
722
723        crate::tmux::kill_session(session).unwrap();
724    }
725
726    #[test]
727    fn auto_zones_single_role_type_produces_one_zone() {
728        let members = make_members(
729            r#"
730name: test
731roles:
732  - name: engineer
733    role_type: engineer
734    agent: codex
735    instances: 4
736"#,
737        );
738        let pane_members: Vec<_> = members
739            .iter()
740            .filter(|m| m.role_type != RoleType::User)
741            .collect();
742        let zones = build_zones_auto(&pane_members);
743        assert_eq!(zones.len(), 1);
744        assert_eq!(zones[0].members.len(), 4);
745    }
746
747    #[test]
748    fn split_zone_subgroups_groups_engineers_by_manager() {
749        let members = make_members(
750            r#"
751name: test
752roles:
753  - name: manager
754    role_type: manager
755    agent: claude
756    instances: 2
757  - name: engineer
758    role_type: engineer
759    agent: codex
760    instances: 2
761    talks_to: [manager]
762"#,
763        );
764        let engineers: Vec<_> = members
765            .iter()
766            .filter(|m| m.role_type == RoleType::Engineer)
767            .collect();
768        let subgroups = split_zone_subgroups(&engineers);
769        assert_eq!(subgroups.len(), 2);
770        assert_eq!(subgroups[0].len(), 2);
771        assert_eq!(subgroups[1].len(), 2);
772        // Each subgroup should share the same reports_to
773        for group in &subgroups {
774            let parent = group[0].reports_to.as_ref().unwrap();
775            assert!(
776                group
777                    .iter()
778                    .all(|m| m.reports_to.as_ref().unwrap() == parent)
779            );
780        }
781    }
782
783    #[test]
784    fn config_zones_unplaced_members_go_to_last_zone() {
785        let members = make_members(
786            r#"
787name: test
788roles:
789  - name: architect
790    role_type: architect
791    agent: claude
792  - name: manager
793    role_type: manager
794    agent: claude
795  - name: engineer
796    role_type: engineer
797    agent: codex
798    instances: 2
799"#,
800        );
801        // Only define one zone — everything else should end up there
802        let layout = LayoutConfig {
803            zones: vec![super::super::config::ZoneDef {
804                name: "architect".to_string(),
805                width_pct: 100,
806                split: None,
807            }],
808        };
809        let pane_members: Vec<_> = members
810            .iter()
811            .filter(|m| m.role_type != RoleType::User)
812            .collect();
813        let zones = build_zones_from_config(&layout, &pane_members);
814        // Architect zone gets architect + leftover manager/engineers
815        assert_eq!(zones.len(), 1);
816        assert_eq!(zones[0].members.len(), 4); // 1 arch + 1 mgr + 2 eng
817    }
818
819    #[test]
820    fn config_zones_exact_manager_role_collects_direct_reports() {
821        let members = make_members(
822            r#"
823name: test
824roles:
825  - name: architect
826    role_type: architect
827    agent: claude
828  - name: scientist
829    role_type: architect
830    agent: claude
831  - name: black-lead
832    role_type: manager
833    agent: claude
834    talks_to: [architect, black-eng]
835  - name: red-lead
836    role_type: manager
837    agent: claude
838    talks_to: [architect, red-eng]
839  - name: black-eng
840    role_type: engineer
841    agent: codex
842    instances: 2
843    talks_to: [black-lead]
844  - name: red-eng
845    role_type: engineer
846    agent: codex
847    instances: 2
848    talks_to: [red-lead]
849"#,
850        );
851        let layout = LayoutConfig {
852            zones: vec![
853                super::super::config::ZoneDef {
854                    name: "scientist".to_string(),
855                    width_pct: 10,
856                    split: None,
857                },
858                super::super::config::ZoneDef {
859                    name: "architect".to_string(),
860                    width_pct: 10,
861                    split: None,
862                },
863                super::super::config::ZoneDef {
864                    name: "black-lead".to_string(),
865                    width_pct: 40,
866                    split: None,
867                },
868                super::super::config::ZoneDef {
869                    name: "red-lead".to_string(),
870                    width_pct: 40,
871                    split: None,
872                },
873            ],
874        };
875        let pane_members: Vec<_> = members
876            .iter()
877            .filter(|m| m.role_type != RoleType::User)
878            .collect();
879        let zones = build_zones_from_config(&layout, &pane_members);
880        assert_eq!(zones.len(), 4);
881        assert_eq!(
882            zones[0]
883                .members
884                .iter()
885                .map(|m| m.name.as_str())
886                .collect::<Vec<_>>(),
887            vec!["scientist"]
888        );
889        assert_eq!(
890            zones[1]
891                .members
892                .iter()
893                .map(|m| m.name.as_str())
894                .collect::<Vec<_>>(),
895            vec!["architect"]
896        );
897        assert_eq!(
898            zones[2]
899                .members
900                .iter()
901                .map(|m| m.name.as_str())
902                .collect::<Vec<_>>(),
903            vec!["black-lead", "black-eng-1-1", "black-eng-1-2"]
904        );
905        assert_eq!(
906            zones[3]
907                .members
908                .iter()
909                .map(|m| m.name.as_str())
910                .collect::<Vec<_>>(),
911            vec!["red-lead", "red-eng-1-1", "red-eng-1-2"]
912        );
913    }
914
915    #[test]
916    fn workflow_mode_controls_orchestrator_pane_launch() {
917        assert!(!should_launch_orchestrator_pane(WorkflowMode::Legacy, true));
918        assert!(should_launch_orchestrator_pane(WorkflowMode::Hybrid, true));
919        assert!(should_launch_orchestrator_pane(
920            WorkflowMode::WorkflowFirst,
921            true,
922        ));
923        assert!(should_launch_orchestrator_pane(
924            WorkflowMode::BoardFirst,
925            true,
926        ));
927        assert!(!should_launch_orchestrator_pane(
928            WorkflowMode::Hybrid,
929            false
930        ));
931    }
932
933    #[test]
934    #[serial]
935    #[cfg_attr(not(feature = "integration"), ignore)]
936    fn build_layout_adds_orchestrator_pane_when_enabled() {
937        let session = "batty-test-team-layout-orchestrator";
938        let _ = crate::tmux::kill_session(session);
939        let tmp = tempfile::tempdir().unwrap();
940
941        let members = make_members(
942            r#"
943name: test
944roles:
945  - name: architect
946    role_type: architect
947    agent: claude
948  - name: engineer
949    role_type: engineer
950    agent: codex
951"#,
952        );
953
954        let pane_map = build_layout(
955            session,
956            &members,
957            &None,
958            tmp.path(),
959            WorkflowMode::Hybrid,
960            true,
961            OrchestratorPosition::Bottom,
962        )
963        .unwrap();
964        assert_eq!(pane_map.len(), 2);
965
966        let panes_output = Command::new("tmux")
967            .args([
968                "list-panes",
969                "-t",
970                session,
971                "-F",
972                "#{pane_id} #{@batty_role}",
973            ])
974            .output()
975            .unwrap();
976        assert!(panes_output.status.success());
977        let pane_roles = String::from_utf8_lossy(&panes_output.stdout);
978        assert!(
979            pane_roles
980                .lines()
981                .any(|line| line.ends_with(" orchestrator"))
982        );
983        assert_eq!(pane_roles.lines().count(), 3);
984        assert!(tmp.path().join(".batty").join("orchestrator.log").exists());
985
986        crate::tmux::kill_session(session).unwrap();
987    }
988
989    #[test]
990    #[serial]
991    #[cfg_attr(not(feature = "integration"), ignore)]
992    fn build_layout_skips_orchestrator_pane_when_disabled() {
993        let session = "batty-test-team-layout-no-orchestrator";
994        let _ = crate::tmux::kill_session(session);
995        let tmp = tempfile::tempdir().unwrap();
996
997        let members = make_members(
998            r#"
999name: test
1000roles:
1001  - name: architect
1002    role_type: architect
1003    agent: claude
1004  - name: engineer
1005    role_type: engineer
1006    agent: codex
1007"#,
1008        );
1009
1010        let pane_map = build_layout(
1011            session,
1012            &members,
1013            &None,
1014            tmp.path(),
1015            WorkflowMode::Hybrid,
1016            false,
1017            OrchestratorPosition::Bottom,
1018        )
1019        .unwrap();
1020        assert_eq!(pane_map.len(), 2);
1021
1022        let pane_count_output = Command::new("tmux")
1023            .args(["list-panes", "-t", session, "-F", "#{pane_id}"])
1024            .output()
1025            .unwrap();
1026        assert!(pane_count_output.status.success());
1027        let pane_count = String::from_utf8_lossy(&pane_count_output.stdout)
1028            .lines()
1029            .count();
1030        assert_eq!(pane_count, 2);
1031
1032        crate::tmux::kill_session(session).unwrap();
1033    }
1034
1035    #[test]
1036    fn split_members_into_columns_balances_contiguous_groups() {
1037        let members = make_members(
1038            r#"
1039name: test
1040roles:
1041  - name: architect
1042    role_type: architect
1043    agent: claude
1044    instances: 5
1045"#,
1046        );
1047        let pane_members: Vec<_> = members.iter().collect();
1048        let columns = split_members_into_columns(&pane_members, 2);
1049        assert_eq!(columns.len(), 2);
1050        assert_eq!(columns[0].len(), 3);
1051        assert_eq!(columns[1].len(), 2);
1052        assert_eq!(columns[0][0].name, pane_members[0].name);
1053        assert_eq!(columns[1][0].name, pane_members[3].name);
1054    }
1055
1056    #[test]
1057    #[serial]
1058    #[cfg_attr(not(feature = "integration"), ignore)]
1059    fn build_layout_honors_horizontal_split_for_architect_zone() {
1060        let session = "batty-test-team-layout-architect-pair";
1061        let _ = crate::tmux::kill_session(session);
1062
1063        let members = make_members(
1064            r#"
1065name: test
1066roles:
1067  - name: architect
1068    role_type: architect
1069    agent: claude
1070  - name: scientist
1071    role_type: architect
1072    agent: claude
1073"#,
1074        );
1075
1076        let layout = Some(LayoutConfig {
1077            zones: vec![super::super::config::ZoneDef {
1078                name: "architect".to_string(),
1079                width_pct: 100,
1080                split: Some(super::super::config::SplitDef { horizontal: 2 }),
1081            }],
1082        });
1083
1084        let pane_map = build_layout(
1085            session,
1086            &members,
1087            &layout,
1088            Path::new("/tmp"),
1089            WorkflowMode::Legacy,
1090            true,
1091            OrchestratorPosition::Bottom,
1092        )
1093        .unwrap();
1094        assert_eq!(pane_map.len(), 2);
1095
1096        let geometry_output = Command::new("tmux")
1097            .args([
1098                "list-panes",
1099                "-t",
1100                session,
1101                "-F",
1102                "#{pane_title}\t#{pane_left}\t#{pane_top}\t#{pane_width}\t#{pane_height}",
1103            ])
1104            .output()
1105            .unwrap();
1106        assert!(geometry_output.status.success());
1107
1108        let mut panes = HashMap::new();
1109        for line in String::from_utf8_lossy(&geometry_output.stdout).lines() {
1110            let parts: Vec<_> = line.split('\t').collect();
1111            if parts.len() != 5 {
1112                continue;
1113            }
1114            panes.insert(
1115                parts[0].to_string(),
1116                (
1117                    parts[1].parse::<u32>().unwrap(),
1118                    parts[2].parse::<u32>().unwrap(),
1119                    parts[3].parse::<u32>().unwrap(),
1120                    parts[4].parse::<u32>().unwrap(),
1121                ),
1122            );
1123        }
1124
1125        let architect = panes.get("architect").unwrap();
1126        let scientist = panes.get("scientist").unwrap();
1127        assert_ne!(architect.0, scientist.0, "expected side-by-side panes");
1128        assert_eq!(architect.1, scientist.1, "expected aligned top edges");
1129        assert!(architect.3 > 0 && scientist.3 > 0);
1130
1131        crate::tmux::kill_session(session).unwrap();
1132    }
1133
1134    // --- New tests for #255 ---
1135
1136    #[test]
1137    fn split_off_current_member_pct_single_member() {
1138        // 1 member = 100%, clamped to 90
1139        assert_eq!(split_off_current_member_pct(1), 90);
1140    }
1141
1142    #[test]
1143    fn split_off_current_member_pct_large_count() {
1144        // 20 members = 5%, clamped to 10
1145        assert_eq!(split_off_current_member_pct(20), 10);
1146    }
1147
1148    #[test]
1149    fn split_off_current_member_pct_boundary_values() {
1150        assert_eq!(split_off_current_member_pct(10), 10);
1151        assert_eq!(split_off_current_member_pct(3), 33);
1152        assert_eq!(split_off_current_member_pct(4), 25);
1153    }
1154
1155    #[test]
1156    fn shell_single_quote_no_quotes() {
1157        assert_eq!(shell_single_quote("hello world"), "hello world");
1158    }
1159
1160    #[test]
1161    fn shell_single_quote_with_single_quotes() {
1162        assert_eq!(shell_single_quote("it's"), "it'\"'\"'s");
1163    }
1164
1165    #[test]
1166    fn shell_single_quote_multiple_quotes() {
1167        assert_eq!(shell_single_quote("a'b'c"), "a'\"'\"'b'\"'\"'c");
1168    }
1169
1170    #[test]
1171    fn auto_zones_only_architects() {
1172        let members = make_members(
1173            r#"
1174name: test
1175roles:
1176  - name: architect
1177    role_type: architect
1178    agent: claude
1179    instances: 3
1180"#,
1181        );
1182        let pane_members: Vec<_> = members
1183            .iter()
1184            .filter(|m| m.role_type != RoleType::User)
1185            .collect();
1186        let zones = build_zones_auto(&pane_members);
1187        assert_eq!(zones.len(), 1);
1188        assert_eq!(zones[0].members.len(), 3);
1189        assert!(
1190            zones[0]
1191                .members
1192                .iter()
1193                .all(|m| m.role_type == RoleType::Architect)
1194        );
1195    }
1196
1197    #[test]
1198    fn auto_zones_only_managers() {
1199        let members = make_members(
1200            r#"
1201name: test
1202roles:
1203  - name: manager
1204    role_type: manager
1205    agent: claude
1206    instances: 2
1207"#,
1208        );
1209        let pane_members: Vec<_> = members
1210            .iter()
1211            .filter(|m| m.role_type != RoleType::User)
1212            .collect();
1213        let zones = build_zones_auto(&pane_members);
1214        assert_eq!(zones.len(), 1);
1215        assert_eq!(zones[0].members.len(), 2);
1216    }
1217
1218    #[test]
1219    fn auto_zones_architect_and_engineers_no_managers() {
1220        let members = make_members(
1221            r#"
1222name: test
1223roles:
1224  - name: architect
1225    role_type: architect
1226    agent: claude
1227  - name: engineer
1228    role_type: engineer
1229    agent: codex
1230    instances: 4
1231"#,
1232        );
1233        let pane_members: Vec<_> = members
1234            .iter()
1235            .filter(|m| m.role_type != RoleType::User)
1236            .collect();
1237        let zones = build_zones_auto(&pane_members);
1238        assert_eq!(zones.len(), 2);
1239        assert_eq!(zones[0].members[0].role_type, RoleType::Architect);
1240        assert_eq!(zones[1].members.len(), 4);
1241        assert!(
1242            zones[1]
1243                .members
1244                .iter()
1245                .all(|m| m.role_type == RoleType::Engineer)
1246        );
1247    }
1248
1249    #[test]
1250    fn auto_zones_width_pct_minimum_enforcement() {
1251        // With 1 arch, 1 mgr, 10 eng = 12 total
1252        // Architect raw = 1/12*100 = 8% → clamped to 10%
1253        // Manager raw = 1/12*100 = 8% → clamped to 15%
1254        // Engineer raw = 10/12*100 = 83% → stays at 83% (>20%)
1255        let members = make_members(
1256            r#"
1257name: test
1258roles:
1259  - name: architect
1260    role_type: architect
1261    agent: claude
1262  - name: manager
1263    role_type: manager
1264    agent: claude
1265  - name: engineer
1266    role_type: engineer
1267    agent: codex
1268    instances: 10
1269"#,
1270        );
1271        let pane_members: Vec<_> = members
1272            .iter()
1273            .filter(|m| m.role_type != RoleType::User)
1274            .collect();
1275        let zones = build_zones_auto(&pane_members);
1276        assert_eq!(zones.len(), 3);
1277        assert!(zones[0].width_pct >= 10, "architect zone min 10%");
1278        assert!(zones[1].width_pct >= 15, "manager zone min 15%");
1279        assert!(zones[2].width_pct >= 20, "engineer zone min 20%");
1280    }
1281
1282    #[test]
1283    fn split_zone_subgroups_mixed_roles_returns_single_group() {
1284        let members = make_members(
1285            r#"
1286name: test
1287roles:
1288  - name: architect
1289    role_type: architect
1290    agent: claude
1291  - name: manager
1292    role_type: manager
1293    agent: claude
1294"#,
1295        );
1296        let pane_members: Vec<_> = members
1297            .iter()
1298            .filter(|m| m.role_type != RoleType::User)
1299            .collect();
1300        let subgroups = split_zone_subgroups(&pane_members);
1301        assert_eq!(subgroups.len(), 1);
1302        assert_eq!(subgroups[0].len(), 2);
1303    }
1304
1305    #[test]
1306    fn split_zone_subgroups_single_manager_all_engineers_one_group() {
1307        let members = make_members(
1308            r#"
1309name: test
1310roles:
1311  - name: manager
1312    role_type: manager
1313    agent: claude
1314  - name: engineer
1315    role_type: engineer
1316    agent: codex
1317    instances: 3
1318    talks_to: [manager]
1319"#,
1320        );
1321        let engineers: Vec<_> = members
1322            .iter()
1323            .filter(|m| m.role_type == RoleType::Engineer)
1324            .collect();
1325        let subgroups = split_zone_subgroups(&engineers);
1326        // All engineers report to same manager → single subgroup
1327        assert_eq!(subgroups.len(), 1);
1328        assert_eq!(subgroups[0].len(), 3);
1329    }
1330
1331    #[test]
1332    fn split_members_into_columns_single_column() {
1333        let members = make_members(
1334            r#"
1335name: test
1336roles:
1337  - name: engineer
1338    role_type: engineer
1339    agent: codex
1340    instances: 4
1341"#,
1342        );
1343        let pane_members: Vec<_> = members.iter().collect();
1344        let columns = split_members_into_columns(&pane_members, 1);
1345        assert_eq!(columns.len(), 1);
1346        assert_eq!(columns[0].len(), 4);
1347    }
1348
1349    #[test]
1350    fn split_members_into_columns_more_columns_than_members() {
1351        let members = make_members(
1352            r#"
1353name: test
1354roles:
1355  - name: engineer
1356    role_type: engineer
1357    agent: codex
1358    instances: 2
1359"#,
1360        );
1361        let pane_members: Vec<_> = members.iter().collect();
1362        // Request 5 columns but only 2 members → clamped to 2
1363        let columns = split_members_into_columns(&pane_members, 5);
1364        assert_eq!(columns.len(), 2);
1365        assert_eq!(columns[0].len(), 1);
1366        assert_eq!(columns[1].len(), 1);
1367    }
1368
1369    #[test]
1370    fn split_members_into_columns_exact_division() {
1371        let members = make_members(
1372            r#"
1373name: test
1374roles:
1375  - name: engineer
1376    role_type: engineer
1377    agent: codex
1378    instances: 6
1379"#,
1380        );
1381        let pane_members: Vec<_> = members.iter().collect();
1382        let columns = split_members_into_columns(&pane_members, 3);
1383        assert_eq!(columns.len(), 3);
1384        assert_eq!(columns[0].len(), 2);
1385        assert_eq!(columns[1].len(), 2);
1386        assert_eq!(columns[2].len(), 2);
1387    }
1388
1389    #[test]
1390    fn split_members_into_columns_zero_desired_clamps_to_one() {
1391        let members = make_members(
1392            r#"
1393name: test
1394roles:
1395  - name: engineer
1396    role_type: engineer
1397    agent: codex
1398    instances: 3
1399"#,
1400        );
1401        let pane_members: Vec<_> = members.iter().collect();
1402        let columns = split_members_into_columns(&pane_members, 0);
1403        assert_eq!(columns.len(), 1);
1404        assert_eq!(columns[0].len(), 3);
1405    }
1406
1407    #[test]
1408    fn config_zones_role_type_keyword_matching() {
1409        let members = make_members(
1410            r#"
1411name: test
1412roles:
1413  - name: architect
1414    role_type: architect
1415    agent: claude
1416  - name: manager
1417    role_type: manager
1418    agent: claude
1419  - name: engineer
1420    role_type: engineer
1421    agent: codex
1422    instances: 2
1423"#,
1424        );
1425        // Zone names contain role-type keywords
1426        let layout = LayoutConfig {
1427            zones: vec![
1428                super::super::config::ZoneDef {
1429                    name: "my-architect-zone".to_string(),
1430                    width_pct: 20,
1431                    split: None,
1432                },
1433                super::super::config::ZoneDef {
1434                    name: "the-manager-area".to_string(),
1435                    width_pct: 30,
1436                    split: None,
1437                },
1438                super::super::config::ZoneDef {
1439                    name: "engineer-pool".to_string(),
1440                    width_pct: 50,
1441                    split: None,
1442                },
1443            ],
1444        };
1445        let pane_members: Vec<_> = members
1446            .iter()
1447            .filter(|m| m.role_type != RoleType::User)
1448            .collect();
1449        let zones = build_zones_from_config(&layout, &pane_members);
1450        assert_eq!(zones.len(), 3);
1451        assert_eq!(zones[0].members[0].role_type, RoleType::Architect);
1452        assert_eq!(zones[1].members[0].role_type, RoleType::Manager);
1453        assert_eq!(zones[2].members.len(), 2);
1454        assert!(
1455            zones[2]
1456                .members
1457                .iter()
1458                .all(|m| m.role_type == RoleType::Engineer)
1459        );
1460    }
1461
1462    #[test]
1463    fn config_zones_no_matching_zones_all_go_to_last() {
1464        let members = make_members(
1465            r#"
1466name: test
1467roles:
1468  - name: architect
1469    role_type: architect
1470    agent: claude
1471  - name: engineer
1472    role_type: engineer
1473    agent: codex
1474    instances: 2
1475"#,
1476        );
1477        let layout = LayoutConfig {
1478            zones: vec![super::super::config::ZoneDef {
1479                name: "unrelated-zone".to_string(),
1480                width_pct: 100,
1481                split: None,
1482            }],
1483        };
1484        let pane_members: Vec<_> = members
1485            .iter()
1486            .filter(|m| m.role_type != RoleType::User)
1487            .collect();
1488        let zones = build_zones_from_config(&layout, &pane_members);
1489        assert_eq!(zones.len(), 1);
1490        // All members land in last (only) zone
1491        assert_eq!(zones[0].members.len(), 3);
1492    }
1493
1494    #[test]
1495    fn config_zones_empty_zones_are_removed() {
1496        let members = make_members(
1497            r#"
1498name: test
1499roles:
1500  - name: architect
1501    role_type: architect
1502    agent: claude
1503"#,
1504        );
1505        let layout = LayoutConfig {
1506            zones: vec![
1507                super::super::config::ZoneDef {
1508                    name: "architect".to_string(),
1509                    width_pct: 30,
1510                    split: None,
1511                },
1512                super::super::config::ZoneDef {
1513                    name: "engineers".to_string(),
1514                    width_pct: 70,
1515                    split: None,
1516                },
1517            ],
1518        };
1519        let pane_members: Vec<_> = members
1520            .iter()
1521            .filter(|m| m.role_type != RoleType::User)
1522            .collect();
1523        let zones = build_zones_from_config(&layout, &pane_members);
1524        // Engineer zone is empty and should be removed
1525        assert_eq!(zones.len(), 1);
1526        assert_eq!(zones[0].members[0].name, "architect");
1527    }
1528
1529    #[test]
1530    fn config_zones_horizontal_columns_stored_from_split() {
1531        let layout = LayoutConfig {
1532            zones: vec![super::super::config::ZoneDef {
1533                name: "engineers".to_string(),
1534                width_pct: 100,
1535                split: Some(super::super::config::SplitDef { horizontal: 4 }),
1536            }],
1537        };
1538        let members = make_members(
1539            r#"
1540name: test
1541roles:
1542  - name: engineer
1543    role_type: engineer
1544    agent: codex
1545    instances: 8
1546"#,
1547        );
1548        let pane_members: Vec<_> = members
1549            .iter()
1550            .filter(|m| m.role_type != RoleType::User)
1551            .collect();
1552        let zones = build_zones_from_config(&layout, &pane_members);
1553        assert_eq!(zones.len(), 1);
1554        assert_eq!(zones[0].horizontal_columns, 4);
1555        assert_eq!(zones[0].members.len(), 8);
1556    }
1557
1558    #[test]
1559    fn auto_zones_user_role_excluded() {
1560        let members = make_members(
1561            r#"
1562name: test
1563roles:
1564  - name: human
1565    role_type: user
1566    talks_to: [architect]
1567  - name: architect
1568    role_type: architect
1569    agent: claude
1570  - name: engineer
1571    role_type: engineer
1572    agent: codex
1573"#,
1574        );
1575        let pane_members: Vec<_> = members
1576            .iter()
1577            .filter(|m| m.role_type != RoleType::User)
1578            .collect();
1579        let zones = build_zones_auto(&pane_members);
1580        // Only architect and engineer zones — no user zone
1581        assert_eq!(zones.len(), 2);
1582        assert!(
1583            zones
1584                .iter()
1585                .all(|z| z.members.iter().all(|m| m.role_type != RoleType::User))
1586        );
1587    }
1588
1589    #[test]
1590    fn workflow_mode_legacy_never_enables_orchestrator() {
1591        assert!(!should_launch_orchestrator_pane(WorkflowMode::Legacy, true));
1592        assert!(!should_launch_orchestrator_pane(
1593            WorkflowMode::Legacy,
1594            false
1595        ));
1596    }
1597
1598    #[test]
1599    fn config_zones_max_members_via_split_horizontal() {
1600        let members = make_members(
1601            r#"
1602name: test
1603roles:
1604  - name: engineer
1605    role_type: engineer
1606    agent: codex
1607    instances: 6
1608"#,
1609        );
1610        // Limit engineer zone to only 3 via split.horizontal
1611        let layout = LayoutConfig {
1612            zones: vec![
1613                super::super::config::ZoneDef {
1614                    name: "engineers".to_string(),
1615                    width_pct: 50,
1616                    split: Some(super::super::config::SplitDef { horizontal: 3 }),
1617                },
1618                super::super::config::ZoneDef {
1619                    name: "overflow".to_string(),
1620                    width_pct: 50,
1621                    split: None,
1622                },
1623            ],
1624        };
1625        let pane_members: Vec<_> = members
1626            .iter()
1627            .filter(|m| m.role_type != RoleType::User)
1628            .collect();
1629        let zones = build_zones_from_config(&layout, &pane_members);
1630        // First zone gets 3 engineers (capped by split.horizontal)
1631        assert_eq!(zones[0].members.len(), 3);
1632        // Remaining 3 go to last zone
1633        assert_eq!(zones[1].members.len(), 3);
1634    }
1635
1636    #[test]
1637    fn display_pane_command_simple_path() {
1638        let root = std::path::PathBuf::from("/tmp/repo");
1639        let events = std::path::PathBuf::from("/tmp/shim-logs/eng-1.events.log");
1640        let pty = std::path::PathBuf::from("/tmp/shim-logs/eng-1.pty.log");
1641        let cmd = display_pane_command(&root, "eng-1", &events, &pty);
1642        assert!(cmd.contains("console-pane"));
1643        assert!(cmd.contains("/tmp/shim-logs/eng-1.events.log"));
1644        assert!(cmd.contains("/tmp/shim-logs/eng-1.pty.log"));
1645        assert!(cmd.starts_with("bash -lc"));
1646    }
1647
1648    #[test]
1649    fn display_pane_command_escapes_single_quotes() {
1650        let root = std::path::PathBuf::from("/tmp/repo");
1651        let events = std::path::PathBuf::from("/tmp/it's events.log");
1652        let pty = std::path::PathBuf::from("/tmp/it's a log.pty.log");
1653        let cmd = display_pane_command(&root, "eng-1", &events, &pty);
1654        // Single quote should be escaped for shell safety
1655        assert!(!cmd.contains("it's"));
1656        assert!(cmd.contains("console-pane"));
1657    }
1658}