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