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