1use 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
28pub 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 tmux::create_session(session, "bash", &[], &work_dir)?;
54 tmux::rename_window(&format!("{session}:0"), "team")?;
55
56 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 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 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 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 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 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 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
227pub 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
245pub 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
356fn set_pane_title(_session: &str, pane_id: &str, title: &str) -> Result<()> {
362 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 let _ = std::process::Command::new("tmux")
375 .args(["set-option", "-p", "-t", pane_id, "@batty_role", title])
376 .output();
377
378 Ok(())
379}
380
381fn 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 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 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 if let Some(last) = zones.last_mut() {
465 last.members.extend(member_queue);
466 }
467
468 zones.retain(|zone| !zone.members.is_empty());
470 zones
471}
472
473fn 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); assert_eq!(zones[1].members.len(), 2); assert_eq!(zones[2].members.len(), 6); }
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 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 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 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 assert_eq!(zones.len(), 1);
816 assert_eq!(zones[0].members.len(), 4); }
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 #[test]
1133 fn split_off_current_member_pct_single_member() {
1134 assert_eq!(split_off_current_member_pct(1), 90);
1136 }
1137
1138 #[test]
1139 fn split_off_current_member_pct_large_count() {
1140 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 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 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 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 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 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 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 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 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 assert_eq!(zones[0].members.len(), 3);
1628 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 assert!(!cmd.contains("it's"));
1652 assert!(cmd.contains("console-pane"));
1653 }
1654}