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::Hybrid,
925            false
926        ));
927    }
928
929    #[test]
930    #[serial]
931    #[cfg_attr(not(feature = "integration"), ignore)]
932    fn build_layout_adds_orchestrator_pane_when_enabled() {
933        let session = "batty-test-team-layout-orchestrator";
934        let _ = crate::tmux::kill_session(session);
935        let tmp = tempfile::tempdir().unwrap();
936
937        let members = make_members(
938            r#"
939name: test
940roles:
941  - name: architect
942    role_type: architect
943    agent: claude
944  - name: engineer
945    role_type: engineer
946    agent: codex
947"#,
948        );
949
950        let pane_map = build_layout(
951            session,
952            &members,
953            &None,
954            tmp.path(),
955            WorkflowMode::Hybrid,
956            true,
957            OrchestratorPosition::Bottom,
958        )
959        .unwrap();
960        assert_eq!(pane_map.len(), 2);
961
962        let panes_output = Command::new("tmux")
963            .args([
964                "list-panes",
965                "-t",
966                session,
967                "-F",
968                "#{pane_id} #{@batty_role}",
969            ])
970            .output()
971            .unwrap();
972        assert!(panes_output.status.success());
973        let pane_roles = String::from_utf8_lossy(&panes_output.stdout);
974        assert!(
975            pane_roles
976                .lines()
977                .any(|line| line.ends_with(" orchestrator"))
978        );
979        assert_eq!(pane_roles.lines().count(), 3);
980        assert!(tmp.path().join(".batty").join("orchestrator.log").exists());
981
982        crate::tmux::kill_session(session).unwrap();
983    }
984
985    #[test]
986    #[serial]
987    #[cfg_attr(not(feature = "integration"), ignore)]
988    fn build_layout_skips_orchestrator_pane_when_disabled() {
989        let session = "batty-test-team-layout-no-orchestrator";
990        let _ = crate::tmux::kill_session(session);
991        let tmp = tempfile::tempdir().unwrap();
992
993        let members = make_members(
994            r#"
995name: test
996roles:
997  - name: architect
998    role_type: architect
999    agent: claude
1000  - name: engineer
1001    role_type: engineer
1002    agent: codex
1003"#,
1004        );
1005
1006        let pane_map = build_layout(
1007            session,
1008            &members,
1009            &None,
1010            tmp.path(),
1011            WorkflowMode::Hybrid,
1012            false,
1013            OrchestratorPosition::Bottom,
1014        )
1015        .unwrap();
1016        assert_eq!(pane_map.len(), 2);
1017
1018        let pane_count_output = Command::new("tmux")
1019            .args(["list-panes", "-t", session, "-F", "#{pane_id}"])
1020            .output()
1021            .unwrap();
1022        assert!(pane_count_output.status.success());
1023        let pane_count = String::from_utf8_lossy(&pane_count_output.stdout)
1024            .lines()
1025            .count();
1026        assert_eq!(pane_count, 2);
1027
1028        crate::tmux::kill_session(session).unwrap();
1029    }
1030
1031    #[test]
1032    fn split_members_into_columns_balances_contiguous_groups() {
1033        let members = make_members(
1034            r#"
1035name: test
1036roles:
1037  - name: architect
1038    role_type: architect
1039    agent: claude
1040    instances: 5
1041"#,
1042        );
1043        let pane_members: Vec<_> = members.iter().collect();
1044        let columns = split_members_into_columns(&pane_members, 2);
1045        assert_eq!(columns.len(), 2);
1046        assert_eq!(columns[0].len(), 3);
1047        assert_eq!(columns[1].len(), 2);
1048        assert_eq!(columns[0][0].name, pane_members[0].name);
1049        assert_eq!(columns[1][0].name, pane_members[3].name);
1050    }
1051
1052    #[test]
1053    #[serial]
1054    #[cfg_attr(not(feature = "integration"), ignore)]
1055    fn build_layout_honors_horizontal_split_for_architect_zone() {
1056        let session = "batty-test-team-layout-architect-pair";
1057        let _ = crate::tmux::kill_session(session);
1058
1059        let members = make_members(
1060            r#"
1061name: test
1062roles:
1063  - name: architect
1064    role_type: architect
1065    agent: claude
1066  - name: scientist
1067    role_type: architect
1068    agent: claude
1069"#,
1070        );
1071
1072        let layout = Some(LayoutConfig {
1073            zones: vec![super::super::config::ZoneDef {
1074                name: "architect".to_string(),
1075                width_pct: 100,
1076                split: Some(super::super::config::SplitDef { horizontal: 2 }),
1077            }],
1078        });
1079
1080        let pane_map = build_layout(
1081            session,
1082            &members,
1083            &layout,
1084            Path::new("/tmp"),
1085            WorkflowMode::Legacy,
1086            true,
1087            OrchestratorPosition::Bottom,
1088        )
1089        .unwrap();
1090        assert_eq!(pane_map.len(), 2);
1091
1092        let geometry_output = Command::new("tmux")
1093            .args([
1094                "list-panes",
1095                "-t",
1096                session,
1097                "-F",
1098                "#{pane_title}\t#{pane_left}\t#{pane_top}\t#{pane_width}\t#{pane_height}",
1099            ])
1100            .output()
1101            .unwrap();
1102        assert!(geometry_output.status.success());
1103
1104        let mut panes = HashMap::new();
1105        for line in String::from_utf8_lossy(&geometry_output.stdout).lines() {
1106            let parts: Vec<_> = line.split('\t').collect();
1107            if parts.len() != 5 {
1108                continue;
1109            }
1110            panes.insert(
1111                parts[0].to_string(),
1112                (
1113                    parts[1].parse::<u32>().unwrap(),
1114                    parts[2].parse::<u32>().unwrap(),
1115                    parts[3].parse::<u32>().unwrap(),
1116                    parts[4].parse::<u32>().unwrap(),
1117                ),
1118            );
1119        }
1120
1121        let architect = panes.get("architect").unwrap();
1122        let scientist = panes.get("scientist").unwrap();
1123        assert_ne!(architect.0, scientist.0, "expected side-by-side panes");
1124        assert_eq!(architect.1, scientist.1, "expected aligned top edges");
1125        assert!(architect.3 > 0 && scientist.3 > 0);
1126
1127        crate::tmux::kill_session(session).unwrap();
1128    }
1129
1130    // --- New tests for #255 ---
1131
1132    #[test]
1133    fn split_off_current_member_pct_single_member() {
1134        // 1 member = 100%, clamped to 90
1135        assert_eq!(split_off_current_member_pct(1), 90);
1136    }
1137
1138    #[test]
1139    fn split_off_current_member_pct_large_count() {
1140        // 20 members = 5%, clamped to 10
1141        assert_eq!(split_off_current_member_pct(20), 10);
1142    }
1143
1144    #[test]
1145    fn split_off_current_member_pct_boundary_values() {
1146        assert_eq!(split_off_current_member_pct(10), 10);
1147        assert_eq!(split_off_current_member_pct(3), 33);
1148        assert_eq!(split_off_current_member_pct(4), 25);
1149    }
1150
1151    #[test]
1152    fn shell_single_quote_no_quotes() {
1153        assert_eq!(shell_single_quote("hello world"), "hello world");
1154    }
1155
1156    #[test]
1157    fn shell_single_quote_with_single_quotes() {
1158        assert_eq!(shell_single_quote("it's"), "it'\"'\"'s");
1159    }
1160
1161    #[test]
1162    fn shell_single_quote_multiple_quotes() {
1163        assert_eq!(shell_single_quote("a'b'c"), "a'\"'\"'b'\"'\"'c");
1164    }
1165
1166    #[test]
1167    fn auto_zones_only_architects() {
1168        let members = make_members(
1169            r#"
1170name: test
1171roles:
1172  - name: architect
1173    role_type: architect
1174    agent: claude
1175    instances: 3
1176"#,
1177        );
1178        let pane_members: Vec<_> = members
1179            .iter()
1180            .filter(|m| m.role_type != RoleType::User)
1181            .collect();
1182        let zones = build_zones_auto(&pane_members);
1183        assert_eq!(zones.len(), 1);
1184        assert_eq!(zones[0].members.len(), 3);
1185        assert!(
1186            zones[0]
1187                .members
1188                .iter()
1189                .all(|m| m.role_type == RoleType::Architect)
1190        );
1191    }
1192
1193    #[test]
1194    fn auto_zones_only_managers() {
1195        let members = make_members(
1196            r#"
1197name: test
1198roles:
1199  - name: manager
1200    role_type: manager
1201    agent: claude
1202    instances: 2
1203"#,
1204        );
1205        let pane_members: Vec<_> = members
1206            .iter()
1207            .filter(|m| m.role_type != RoleType::User)
1208            .collect();
1209        let zones = build_zones_auto(&pane_members);
1210        assert_eq!(zones.len(), 1);
1211        assert_eq!(zones[0].members.len(), 2);
1212    }
1213
1214    #[test]
1215    fn auto_zones_architect_and_engineers_no_managers() {
1216        let members = make_members(
1217            r#"
1218name: test
1219roles:
1220  - name: architect
1221    role_type: architect
1222    agent: claude
1223  - name: engineer
1224    role_type: engineer
1225    agent: codex
1226    instances: 4
1227"#,
1228        );
1229        let pane_members: Vec<_> = members
1230            .iter()
1231            .filter(|m| m.role_type != RoleType::User)
1232            .collect();
1233        let zones = build_zones_auto(&pane_members);
1234        assert_eq!(zones.len(), 2);
1235        assert_eq!(zones[0].members[0].role_type, RoleType::Architect);
1236        assert_eq!(zones[1].members.len(), 4);
1237        assert!(
1238            zones[1]
1239                .members
1240                .iter()
1241                .all(|m| m.role_type == RoleType::Engineer)
1242        );
1243    }
1244
1245    #[test]
1246    fn auto_zones_width_pct_minimum_enforcement() {
1247        // With 1 arch, 1 mgr, 10 eng = 12 total
1248        // Architect raw = 1/12*100 = 8% → clamped to 10%
1249        // Manager raw = 1/12*100 = 8% → clamped to 15%
1250        // Engineer raw = 10/12*100 = 83% → stays at 83% (>20%)
1251        let members = make_members(
1252            r#"
1253name: test
1254roles:
1255  - name: architect
1256    role_type: architect
1257    agent: claude
1258  - name: manager
1259    role_type: manager
1260    agent: claude
1261  - name: engineer
1262    role_type: engineer
1263    agent: codex
1264    instances: 10
1265"#,
1266        );
1267        let pane_members: Vec<_> = members
1268            .iter()
1269            .filter(|m| m.role_type != RoleType::User)
1270            .collect();
1271        let zones = build_zones_auto(&pane_members);
1272        assert_eq!(zones.len(), 3);
1273        assert!(zones[0].width_pct >= 10, "architect zone min 10%");
1274        assert!(zones[1].width_pct >= 15, "manager zone min 15%");
1275        assert!(zones[2].width_pct >= 20, "engineer zone min 20%");
1276    }
1277
1278    #[test]
1279    fn split_zone_subgroups_mixed_roles_returns_single_group() {
1280        let members = make_members(
1281            r#"
1282name: test
1283roles:
1284  - name: architect
1285    role_type: architect
1286    agent: claude
1287  - name: manager
1288    role_type: manager
1289    agent: claude
1290"#,
1291        );
1292        let pane_members: Vec<_> = members
1293            .iter()
1294            .filter(|m| m.role_type != RoleType::User)
1295            .collect();
1296        let subgroups = split_zone_subgroups(&pane_members);
1297        assert_eq!(subgroups.len(), 1);
1298        assert_eq!(subgroups[0].len(), 2);
1299    }
1300
1301    #[test]
1302    fn split_zone_subgroups_single_manager_all_engineers_one_group() {
1303        let members = make_members(
1304            r#"
1305name: test
1306roles:
1307  - name: manager
1308    role_type: manager
1309    agent: claude
1310  - name: engineer
1311    role_type: engineer
1312    agent: codex
1313    instances: 3
1314    talks_to: [manager]
1315"#,
1316        );
1317        let engineers: Vec<_> = members
1318            .iter()
1319            .filter(|m| m.role_type == RoleType::Engineer)
1320            .collect();
1321        let subgroups = split_zone_subgroups(&engineers);
1322        // All engineers report to same manager → single subgroup
1323        assert_eq!(subgroups.len(), 1);
1324        assert_eq!(subgroups[0].len(), 3);
1325    }
1326
1327    #[test]
1328    fn split_members_into_columns_single_column() {
1329        let members = make_members(
1330            r#"
1331name: test
1332roles:
1333  - name: engineer
1334    role_type: engineer
1335    agent: codex
1336    instances: 4
1337"#,
1338        );
1339        let pane_members: Vec<_> = members.iter().collect();
1340        let columns = split_members_into_columns(&pane_members, 1);
1341        assert_eq!(columns.len(), 1);
1342        assert_eq!(columns[0].len(), 4);
1343    }
1344
1345    #[test]
1346    fn split_members_into_columns_more_columns_than_members() {
1347        let members = make_members(
1348            r#"
1349name: test
1350roles:
1351  - name: engineer
1352    role_type: engineer
1353    agent: codex
1354    instances: 2
1355"#,
1356        );
1357        let pane_members: Vec<_> = members.iter().collect();
1358        // Request 5 columns but only 2 members → clamped to 2
1359        let columns = split_members_into_columns(&pane_members, 5);
1360        assert_eq!(columns.len(), 2);
1361        assert_eq!(columns[0].len(), 1);
1362        assert_eq!(columns[1].len(), 1);
1363    }
1364
1365    #[test]
1366    fn split_members_into_columns_exact_division() {
1367        let members = make_members(
1368            r#"
1369name: test
1370roles:
1371  - name: engineer
1372    role_type: engineer
1373    agent: codex
1374    instances: 6
1375"#,
1376        );
1377        let pane_members: Vec<_> = members.iter().collect();
1378        let columns = split_members_into_columns(&pane_members, 3);
1379        assert_eq!(columns.len(), 3);
1380        assert_eq!(columns[0].len(), 2);
1381        assert_eq!(columns[1].len(), 2);
1382        assert_eq!(columns[2].len(), 2);
1383    }
1384
1385    #[test]
1386    fn split_members_into_columns_zero_desired_clamps_to_one() {
1387        let members = make_members(
1388            r#"
1389name: test
1390roles:
1391  - name: engineer
1392    role_type: engineer
1393    agent: codex
1394    instances: 3
1395"#,
1396        );
1397        let pane_members: Vec<_> = members.iter().collect();
1398        let columns = split_members_into_columns(&pane_members, 0);
1399        assert_eq!(columns.len(), 1);
1400        assert_eq!(columns[0].len(), 3);
1401    }
1402
1403    #[test]
1404    fn config_zones_role_type_keyword_matching() {
1405        let members = make_members(
1406            r#"
1407name: test
1408roles:
1409  - name: architect
1410    role_type: architect
1411    agent: claude
1412  - name: manager
1413    role_type: manager
1414    agent: claude
1415  - name: engineer
1416    role_type: engineer
1417    agent: codex
1418    instances: 2
1419"#,
1420        );
1421        // Zone names contain role-type keywords
1422        let layout = LayoutConfig {
1423            zones: vec![
1424                super::super::config::ZoneDef {
1425                    name: "my-architect-zone".to_string(),
1426                    width_pct: 20,
1427                    split: None,
1428                },
1429                super::super::config::ZoneDef {
1430                    name: "the-manager-area".to_string(),
1431                    width_pct: 30,
1432                    split: None,
1433                },
1434                super::super::config::ZoneDef {
1435                    name: "engineer-pool".to_string(),
1436                    width_pct: 50,
1437                    split: None,
1438                },
1439            ],
1440        };
1441        let pane_members: Vec<_> = members
1442            .iter()
1443            .filter(|m| m.role_type != RoleType::User)
1444            .collect();
1445        let zones = build_zones_from_config(&layout, &pane_members);
1446        assert_eq!(zones.len(), 3);
1447        assert_eq!(zones[0].members[0].role_type, RoleType::Architect);
1448        assert_eq!(zones[1].members[0].role_type, RoleType::Manager);
1449        assert_eq!(zones[2].members.len(), 2);
1450        assert!(
1451            zones[2]
1452                .members
1453                .iter()
1454                .all(|m| m.role_type == RoleType::Engineer)
1455        );
1456    }
1457
1458    #[test]
1459    fn config_zones_no_matching_zones_all_go_to_last() {
1460        let members = make_members(
1461            r#"
1462name: test
1463roles:
1464  - name: architect
1465    role_type: architect
1466    agent: claude
1467  - name: engineer
1468    role_type: engineer
1469    agent: codex
1470    instances: 2
1471"#,
1472        );
1473        let layout = LayoutConfig {
1474            zones: vec![super::super::config::ZoneDef {
1475                name: "unrelated-zone".to_string(),
1476                width_pct: 100,
1477                split: None,
1478            }],
1479        };
1480        let pane_members: Vec<_> = members
1481            .iter()
1482            .filter(|m| m.role_type != RoleType::User)
1483            .collect();
1484        let zones = build_zones_from_config(&layout, &pane_members);
1485        assert_eq!(zones.len(), 1);
1486        // All members land in last (only) zone
1487        assert_eq!(zones[0].members.len(), 3);
1488    }
1489
1490    #[test]
1491    fn config_zones_empty_zones_are_removed() {
1492        let members = make_members(
1493            r#"
1494name: test
1495roles:
1496  - name: architect
1497    role_type: architect
1498    agent: claude
1499"#,
1500        );
1501        let layout = LayoutConfig {
1502            zones: vec![
1503                super::super::config::ZoneDef {
1504                    name: "architect".to_string(),
1505                    width_pct: 30,
1506                    split: None,
1507                },
1508                super::super::config::ZoneDef {
1509                    name: "engineers".to_string(),
1510                    width_pct: 70,
1511                    split: None,
1512                },
1513            ],
1514        };
1515        let pane_members: Vec<_> = members
1516            .iter()
1517            .filter(|m| m.role_type != RoleType::User)
1518            .collect();
1519        let zones = build_zones_from_config(&layout, &pane_members);
1520        // Engineer zone is empty and should be removed
1521        assert_eq!(zones.len(), 1);
1522        assert_eq!(zones[0].members[0].name, "architect");
1523    }
1524
1525    #[test]
1526    fn config_zones_horizontal_columns_stored_from_split() {
1527        let layout = LayoutConfig {
1528            zones: vec![super::super::config::ZoneDef {
1529                name: "engineers".to_string(),
1530                width_pct: 100,
1531                split: Some(super::super::config::SplitDef { horizontal: 4 }),
1532            }],
1533        };
1534        let members = make_members(
1535            r#"
1536name: test
1537roles:
1538  - name: engineer
1539    role_type: engineer
1540    agent: codex
1541    instances: 8
1542"#,
1543        );
1544        let pane_members: Vec<_> = members
1545            .iter()
1546            .filter(|m| m.role_type != RoleType::User)
1547            .collect();
1548        let zones = build_zones_from_config(&layout, &pane_members);
1549        assert_eq!(zones.len(), 1);
1550        assert_eq!(zones[0].horizontal_columns, 4);
1551        assert_eq!(zones[0].members.len(), 8);
1552    }
1553
1554    #[test]
1555    fn auto_zones_user_role_excluded() {
1556        let members = make_members(
1557            r#"
1558name: test
1559roles:
1560  - name: human
1561    role_type: user
1562    talks_to: [architect]
1563  - name: architect
1564    role_type: architect
1565    agent: claude
1566  - name: engineer
1567    role_type: engineer
1568    agent: codex
1569"#,
1570        );
1571        let pane_members: Vec<_> = members
1572            .iter()
1573            .filter(|m| m.role_type != RoleType::User)
1574            .collect();
1575        let zones = build_zones_auto(&pane_members);
1576        // Only architect and engineer zones — no user zone
1577        assert_eq!(zones.len(), 2);
1578        assert!(
1579            zones
1580                .iter()
1581                .all(|z| z.members.iter().all(|m| m.role_type != RoleType::User))
1582        );
1583    }
1584
1585    #[test]
1586    fn workflow_mode_legacy_never_enables_orchestrator() {
1587        assert!(!should_launch_orchestrator_pane(WorkflowMode::Legacy, true));
1588        assert!(!should_launch_orchestrator_pane(
1589            WorkflowMode::Legacy,
1590            false
1591        ));
1592    }
1593
1594    #[test]
1595    fn config_zones_max_members_via_split_horizontal() {
1596        let members = make_members(
1597            r#"
1598name: test
1599roles:
1600  - name: engineer
1601    role_type: engineer
1602    agent: codex
1603    instances: 6
1604"#,
1605        );
1606        // Limit engineer zone to only 3 via split.horizontal
1607        let layout = LayoutConfig {
1608            zones: vec![
1609                super::super::config::ZoneDef {
1610                    name: "engineers".to_string(),
1611                    width_pct: 50,
1612                    split: Some(super::super::config::SplitDef { horizontal: 3 }),
1613                },
1614                super::super::config::ZoneDef {
1615                    name: "overflow".to_string(),
1616                    width_pct: 50,
1617                    split: None,
1618                },
1619            ],
1620        };
1621        let pane_members: Vec<_> = members
1622            .iter()
1623            .filter(|m| m.role_type != RoleType::User)
1624            .collect();
1625        let zones = build_zones_from_config(&layout, &pane_members);
1626        // First zone gets 3 engineers (capped by split.horizontal)
1627        assert_eq!(zones[0].members.len(), 3);
1628        // Remaining 3 go to last zone
1629        assert_eq!(zones[1].members.len(), 3);
1630    }
1631
1632    #[test]
1633    fn display_pane_command_simple_path() {
1634        let root = std::path::PathBuf::from("/tmp/repo");
1635        let events = std::path::PathBuf::from("/tmp/shim-logs/eng-1.events.log");
1636        let pty = std::path::PathBuf::from("/tmp/shim-logs/eng-1.pty.log");
1637        let cmd = display_pane_command(&root, "eng-1", &events, &pty);
1638        assert!(cmd.contains("console-pane"));
1639        assert!(cmd.contains("/tmp/shim-logs/eng-1.events.log"));
1640        assert!(cmd.contains("/tmp/shim-logs/eng-1.pty.log"));
1641        assert!(cmd.starts_with("bash -lc"));
1642    }
1643
1644    #[test]
1645    fn display_pane_command_escapes_single_quotes() {
1646        let root = std::path::PathBuf::from("/tmp/repo");
1647        let events = std::path::PathBuf::from("/tmp/it's events.log");
1648        let pty = std::path::PathBuf::from("/tmp/it's a log.pty.log");
1649        let cmd = display_pane_command(&root, "eng-1", &events, &pty);
1650        // Single quote should be escaped for shell safety
1651        assert!(!cmd.contains("it's"));
1652        assert!(cmd.contains("console-pane"));
1653    }
1654}