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
220pub 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
232pub 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
337fn set_pane_title(_session: &str, pane_id: &str, title: &str) -> Result<()> {
343 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 let _ = std::process::Command::new("tmux")
356 .args(["set-option", "-p", "-t", pane_id, "@batty_role", title])
357 .output();
358
359 Ok(())
360}
361
362fn 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 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 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 if let Some(last) = zones.last_mut() {
446 last.members.extend(member_queue);
447 }
448
449 zones.retain(|zone| !zone.members.is_empty());
451 zones
452}
453
454fn 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); assert_eq!(zones[1].members.len(), 2); assert_eq!(zones[2].members.len(), 6); }
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 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 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 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 assert_eq!(zones.len(), 1);
797 assert_eq!(zones[0].members.len(), 4); }
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 #[test]
1114 fn split_off_current_member_pct_single_member() {
1115 assert_eq!(split_off_current_member_pct(1), 90);
1117 }
1118
1119 #[test]
1120 fn split_off_current_member_pct_large_count() {
1121 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 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 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 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 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 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 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 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 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 assert_eq!(zones[0].members.len(), 3);
1609 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 assert!(!cmd.contains("it's"));
1628 assert!(cmd.contains("tail -n 200 -F"));
1629 }
1630}