1#![allow(dead_code)]
7
8use std::path::Path;
9use std::process::{Command, Output};
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use anyhow::{Context, Result, bail};
13use tracing::{debug, info, warn};
14
15use crate::team::errors::TmuxError;
16
17static PROBE_COUNTER: AtomicU64 = AtomicU64::new(1);
18const SUPERVISOR_CONTROL_OPTION: &str = "@batty_supervisor_control";
19
20pub const SUPERVISOR_PAUSE_HOTKEY: &str = "C-b P";
22pub const SUPERVISOR_RESUME_HOTKEY: &str = "C-b R";
24const SEND_KEYS_SUBMIT_DELAY_MS: u64 = 100;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SplitMode {
29 Lines,
30 Percent,
31 Disabled,
32}
33
34#[derive(Debug, Clone)]
36pub struct TmuxCapabilities {
37 pub version_raw: String,
38 pub version: Option<(u32, u32)>,
39 pub pipe_pane: bool,
40 pub pipe_pane_only_if_missing: bool,
41 pub status_style: bool,
42 pub split_mode: SplitMode,
43}
44
45impl TmuxCapabilities {
46 pub fn known_good(&self) -> bool {
53 matches!(self.version, Some((major, minor)) if major > 3 || (major == 3 && minor >= 2))
54 }
55
56 pub fn remediation_message(&self) -> String {
57 format!(
58 "tmux capability check failed (detected '{}'). Batty requires working `pipe-pane` support. \
59Install or upgrade tmux (recommended >= 3.2) and re-run `batty start`.",
60 self.version_raw
61 )
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct PaneDetails {
68 pub id: String,
69 pub command: String,
70 pub active: bool,
71 pub dead: bool,
72}
73
74fn check_tmux_with_program(program: &str) -> Result<String> {
75 let output = Command::new(program)
76 .arg("-V")
77 .output()
78 .map_err(|error| TmuxError::exec("tmux -V", error))?;
79
80 if !output.status.success() {
81 return Err(TmuxError::command_failed(
82 "tmux -V",
83 None,
84 &String::from_utf8_lossy(&output.stderr),
85 )
86 .into());
87 }
88
89 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
90 debug!(version = %version, "tmux found");
91 Ok(version)
92}
93
94pub fn check_tmux() -> Result<String> {
96 check_tmux_with_program("tmux")
97}
98
99pub fn tmux_available() -> bool {
101 check_tmux().is_ok()
102}
103
104fn parse_tmux_version(version_raw: &str) -> Option<(u32, u32)> {
105 let raw = version_raw.trim();
106 let ver = raw.strip_prefix("tmux ")?;
107 let mut chars = ver.chars().peekable();
108
109 let mut major = String::new();
110 while let Some(c) = chars.peek() {
111 if c.is_ascii_digit() {
112 major.push(*c);
113 chars.next();
114 } else {
115 break;
116 }
117 }
118 if major.is_empty() {
119 return None;
120 }
121
122 if chars.next()? != '.' {
123 return None;
124 }
125
126 let mut minor = String::new();
127 while let Some(c) = chars.peek() {
128 if c.is_ascii_digit() {
129 minor.push(*c);
130 chars.next();
131 } else {
132 break;
133 }
134 }
135 if minor.is_empty() {
136 return None;
137 }
138
139 Some((major.parse().ok()?, minor.parse().ok()?))
140}
141
142fn run_tmux<I, S>(args: I) -> Result<Output>
143where
144 I: IntoIterator<Item = S>,
145 S: AsRef<std::ffi::OsStr>,
146{
147 Command::new("tmux")
148 .args(args)
149 .output()
150 .map_err(|error| TmuxError::exec("tmux", error).into())
151}
152
153pub fn probe_capabilities() -> Result<TmuxCapabilities> {
155 let version_raw = check_tmux()?;
156 let version = parse_tmux_version(&version_raw);
157
158 let probe_id = PROBE_COUNTER.fetch_add(1, Ordering::Relaxed);
159 let session = format!("batty-cap-probe-{}-{probe_id}", std::process::id());
160 let _ = kill_session(&session);
161 create_session(&session, "sleep", &["20".to_string()], "/tmp")
162 .with_context(|| format!("failed to create tmux probe session '{session}'"))?;
163 let pane = pane_id(&session)?;
164
165 let cleanup = || {
166 let _ = kill_session(&session);
167 };
168
169 let pipe_cmd = "cat >/dev/null";
170 let pipe_pane = match run_tmux(["pipe-pane", "-t", pane.as_str(), pipe_cmd]) {
171 Ok(out) => out.status.success(),
172 Err(_) => false,
173 };
174
175 let pipe_pane_only_if_missing =
176 match run_tmux(["pipe-pane", "-o", "-t", pane.as_str(), pipe_cmd]) {
177 Ok(out) => out.status.success(),
178 Err(_) => false,
179 };
180
181 let _ = run_tmux(["pipe-pane", "-t", pane.as_str()]);
183
184 let status_style = match run_tmux([
185 "set",
186 "-t",
187 session.as_str(),
188 "status-style",
189 "bg=colour235,fg=colour136",
190 ]) {
191 Ok(out) => out.status.success(),
192 Err(_) => false,
193 };
194
195 let split_lines = match run_tmux([
196 "split-window",
197 "-v",
198 "-l",
199 "3",
200 "-t",
201 session.as_str(),
202 "sleep",
203 "1",
204 ]) {
205 Ok(out) => out.status.success(),
206 Err(_) => false,
207 };
208 let split_percent = if split_lines {
209 false
210 } else {
211 match run_tmux([
212 "split-window",
213 "-v",
214 "-p",
215 "20",
216 "-t",
217 session.as_str(),
218 "sleep",
219 "1",
220 ]) {
221 Ok(out) => out.status.success(),
222 Err(_) => false,
223 }
224 };
225
226 cleanup();
227
228 let split_mode = if split_lines {
229 SplitMode::Lines
230 } else if split_percent {
231 SplitMode::Percent
232 } else {
233 SplitMode::Disabled
234 };
235
236 Ok(TmuxCapabilities {
237 version_raw,
238 version,
239 pipe_pane,
240 pipe_pane_only_if_missing,
241 status_style,
242 split_mode,
243 })
244}
245
246pub fn session_name(phase: &str) -> String {
248 let sanitized: String = phase
251 .chars()
252 .map(|c| {
253 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
254 c
255 } else {
256 '-'
257 }
258 })
259 .collect();
260 format!("batty-{sanitized}")
261}
262
263pub fn session_exists(session: &str) -> bool {
265 Command::new("tmux")
266 .args(["has-session", "-t", session])
267 .output()
268 .map(|o| o.status.success())
269 .unwrap_or(false)
270}
271
272pub fn pane_exists(target: &str) -> bool {
274 Command::new("tmux")
275 .args(["display-message", "-p", "-t", target, "#{pane_id}"])
276 .output()
277 .map(|o| o.status.success())
278 .unwrap_or(false)
279}
280
281pub fn pane_dead(target: &str) -> Result<bool> {
283 let output = Command::new("tmux")
284 .args(["display-message", "-p", "-t", target, "#{pane_dead}"])
285 .output()
286 .with_context(|| format!("failed to query pane_dead for target '{target}'"))?;
287
288 if !output.status.success() {
289 let stderr = String::from_utf8_lossy(&output.stderr);
290 return Err(TmuxError::command_failed(
291 "display-message #{pane_dead}",
292 Some(target),
293 &stderr,
294 )
295 .into());
296 }
297
298 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
299 Ok(value == "1")
300}
301
302pub fn pane_pipe_enabled(target: &str) -> Result<bool> {
304 let output = Command::new("tmux")
305 .args(["display-message", "-p", "-t", target, "#{pane_pipe}"])
306 .output()
307 .with_context(|| format!("failed to query pane_pipe for target '{target}'"))?;
308
309 if !output.status.success() {
310 let stderr = String::from_utf8_lossy(&output.stderr);
311 bail!("tmux display-message pane_pipe failed: {stderr}");
312 }
313
314 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
315 Ok(value == "1")
316}
317
318pub fn pane_id(target: &str) -> Result<String> {
320 let output = Command::new("tmux")
321 .args(["display-message", "-p", "-t", target, "#{pane_id}"])
322 .output()
323 .with_context(|| format!("failed to resolve pane id for target '{target}'"))?;
324
325 if !output.status.success() {
326 let stderr = String::from_utf8_lossy(&output.stderr);
327 return Err(
328 TmuxError::command_failed("display-message #{pane_id}", Some(target), &stderr).into(),
329 );
330 }
331
332 let pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
333 if pane.is_empty() {
334 return Err(TmuxError::EmptyPaneId {
335 target: target.to_string(),
336 }
337 .into());
338 }
339 Ok(pane)
340}
341
342pub fn pane_dimensions(target: &str) -> Result<(u16, u16)> {
344 let output = Command::new("tmux")
345 .args([
346 "display-message",
347 "-p",
348 "-t",
349 target,
350 "#{pane_width} #{pane_height}",
351 ])
352 .output()
353 .with_context(|| format!("failed to query pane dimensions for target '{target}'"))?;
354
355 if !output.status.success() {
356 let stderr = String::from_utf8_lossy(&output.stderr);
357 return Err(TmuxError::command_failed(
358 "display-message #{pane_width} #{pane_height}",
359 Some(target),
360 &stderr,
361 )
362 .into());
363 }
364
365 let stdout = String::from_utf8_lossy(&output.stdout);
366 let mut parts = stdout.split_whitespace();
367 let width = parts
368 .next()
369 .context("tmux pane width missing")?
370 .parse()
371 .context("invalid tmux pane width")?;
372 let height = parts
373 .next()
374 .context("tmux pane height missing")?
375 .parse()
376 .context("invalid tmux pane height")?;
377 Ok((width, height))
378}
379
380pub fn pane_current_path(target: &str) -> Result<String> {
382 let output = Command::new("tmux")
383 .args([
384 "display-message",
385 "-p",
386 "-t",
387 target,
388 "#{pane_current_path}",
389 ])
390 .output()
391 .with_context(|| format!("failed to resolve pane current path for target '{target}'"))?;
392
393 if !output.status.success() {
394 let stderr = String::from_utf8_lossy(&output.stderr);
395 bail!("tmux display-message pane_current_path failed: {stderr}");
396 }
397
398 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
399 if path.is_empty() {
400 bail!("tmux returned empty pane current path for target '{target}'");
401 }
402 Ok(path)
403}
404
405pub fn session_path(session: &str) -> Result<String> {
407 let output = Command::new("tmux")
408 .args(["display-message", "-p", "-t", session, "#{session_path}"])
409 .output()
410 .with_context(|| format!("failed to resolve session path for '{session}'"))?;
411
412 if !output.status.success() {
413 let stderr = String::from_utf8_lossy(&output.stderr);
414 bail!("tmux display-message session_path failed: {stderr}");
415 }
416
417 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
418 if path.is_empty() {
419 bail!("tmux returned empty session path for '{session}'");
420 }
421 Ok(path)
422}
423
424pub fn create_session(session: &str, program: &str, args: &[String], work_dir: &str) -> Result<()> {
429 if session_exists(session) {
430 return Err(TmuxError::SessionExists {
431 session: session.to_string(),
432 }
433 .into());
434 }
435
436 let mut cmd = Command::new("tmux");
439 cmd.args(["new-session", "-d", "-s", session, "-c", work_dir]);
440 cmd.args(["-x", "220", "-y", "50"]);
442 cmd.args(["env", "-u", "CLAUDECODE"]);
446 cmd.arg(program);
447 for arg in args {
448 cmd.arg(arg);
449 }
450
451 let output = cmd
452 .output()
453 .with_context(|| format!("failed to create tmux session '{session}'"))?;
454
455 if !output.status.success() {
456 let stderr = String::from_utf8_lossy(&output.stderr);
457 return Err(TmuxError::command_failed("new-session", Some(session), &stderr).into());
458 }
459
460 if let Err(e) = set_mouse(session, true) {
461 warn!(
462 session = session,
463 error = %e,
464 "failed to enable tmux mouse mode"
465 );
466 }
467
468 info!(session = session, "tmux session created");
469 Ok(())
470}
471
472pub fn create_window(
474 session: &str,
475 window_name: &str,
476 program: &str,
477 args: &[String],
478 work_dir: &str,
479) -> Result<()> {
480 if !session_exists(session) {
481 bail!("tmux session '{session}' not found");
482 }
483
484 let mut cmd = Command::new("tmux");
485 cmd.args([
486 "new-window",
487 "-d",
488 "-t",
489 session,
490 "-n",
491 window_name,
492 "-c",
493 work_dir,
494 ]);
495 cmd.args(["env", "-u", "CLAUDECODE"]);
498 cmd.arg(program);
499 for arg in args {
500 cmd.arg(arg);
501 }
502
503 let output = cmd
504 .output()
505 .with_context(|| format!("failed to create tmux window '{window_name}'"))?;
506
507 if !output.status.success() {
508 let stderr = String::from_utf8_lossy(&output.stderr);
509 bail!("tmux new-window failed: {stderr}");
510 }
511
512 Ok(())
513}
514
515pub fn rename_window(target: &str, new_name: &str) -> Result<()> {
517 let output = Command::new("tmux")
518 .args(["rename-window", "-t", target, new_name])
519 .output()
520 .with_context(|| format!("failed to rename tmux window target '{target}'"))?;
521
522 if !output.status.success() {
523 let stderr = String::from_utf8_lossy(&output.stderr);
524 bail!("tmux rename-window failed: {stderr}");
525 }
526
527 Ok(())
528}
529
530pub fn select_window(target: &str) -> Result<()> {
532 let output = Command::new("tmux")
533 .args(["select-window", "-t", target])
534 .output()
535 .with_context(|| format!("failed to select tmux window '{target}'"))?;
536
537 if !output.status.success() {
538 let stderr = String::from_utf8_lossy(&output.stderr);
539 bail!("tmux select-window failed: {stderr}");
540 }
541
542 Ok(())
543}
544
545pub fn setup_pipe_pane(target: &str, log_path: &Path) -> Result<()> {
550 if let Some(parent) = log_path.parent() {
552 std::fs::create_dir_all(parent)
553 .with_context(|| format!("failed to create log directory: {}", parent.display()))?;
554 }
555
556 let pipe_cmd = format!("cat >> {}", log_path.display());
557 let output = Command::new("tmux")
558 .args(["pipe-pane", "-t", target, &pipe_cmd])
559 .output()
560 .with_context(|| format!("failed to set up pipe-pane for target '{target}'"))?;
561
562 if !output.status.success() {
563 let stderr = String::from_utf8_lossy(&output.stderr);
564 bail!("tmux pipe-pane failed: {stderr}");
565 }
566
567 info!(target = target, log = %log_path.display(), "pipe-pane configured");
568 Ok(())
569}
570
571pub fn setup_pipe_pane_if_missing(target: &str, log_path: &Path) -> Result<()> {
573 if let Some(parent) = log_path.parent() {
574 std::fs::create_dir_all(parent)
575 .with_context(|| format!("failed to create log directory: {}", parent.display()))?;
576 }
577
578 let pipe_cmd = format!("cat >> {}", log_path.display());
579 let output = Command::new("tmux")
580 .args(["pipe-pane", "-o", "-t", target, &pipe_cmd])
581 .output()
582 .with_context(|| format!("failed to set up pipe-pane (-o) for target '{target}'"))?;
583
584 if !output.status.success() {
585 let stderr = String::from_utf8_lossy(&output.stderr);
586 bail!("tmux pipe-pane -o failed: {stderr}");
587 }
588
589 info!(
590 target = target,
591 log = %log_path.display(),
592 "pipe-pane ensured (only-if-missing)"
593 );
594 Ok(())
595}
596
597pub fn attach(session: &str) -> Result<()> {
601 if !session_exists(session) {
602 bail!(
603 "tmux session '{session}' not found — is batty running? \
604 Start with `batty start` first"
605 );
606 }
607
608 let inside_tmux = std::env::var("TMUX").is_ok();
609
610 let (cmd, args) = if inside_tmux {
611 ("switch-client", vec!["-t", session])
612 } else {
613 ("attach-session", vec!["-t", session])
614 };
615
616 let status = Command::new("tmux")
617 .arg(cmd)
618 .args(&args)
619 .status()
620 .with_context(|| format!("failed to {cmd} to tmux session '{session}'"))?;
621
622 if !status.success() {
623 bail!("tmux {cmd} to '{session}' failed");
624 }
625
626 Ok(())
627}
628
629pub fn send_keys(target: &str, keys: &str, press_enter: bool) -> Result<()> {
634 if !keys.is_empty() {
635 let output = Command::new("tmux")
638 .args(["send-keys", "-t", target, "-l", "--", keys])
639 .output()
640 .with_context(|| format!("failed to send keys to target '{target}'"))?;
641
642 if !output.status.success() {
643 let stderr = String::from_utf8_lossy(&output.stderr);
644 return Err(TmuxError::command_failed("send-keys", Some(target), &stderr).into());
645 }
646 }
647
648 if press_enter {
649 if !keys.is_empty() {
652 std::thread::sleep(std::time::Duration::from_millis(SEND_KEYS_SUBMIT_DELAY_MS));
653 }
654
655 let output = Command::new("tmux")
656 .args(["send-keys", "-t", target, "Enter"])
657 .output()
658 .with_context(|| format!("failed to send Enter to target '{target}'"))?;
659
660 if !output.status.success() {
661 let stderr = String::from_utf8_lossy(&output.stderr);
662 return Err(TmuxError::command_failed("send-keys Enter", Some(target), &stderr).into());
663 }
664 }
665
666 debug!(target = target, keys = keys, "sent keys");
667 Ok(())
668}
669
670pub fn list_sessions_with_prefix(prefix: &str) -> Vec<String> {
672 let output = Command::new("tmux")
673 .args(["list-sessions", "-F", "#{session_name}"])
674 .output();
675
676 match output {
677 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
678 .lines()
679 .filter(|name| name.starts_with(prefix))
680 .map(|s| s.to_string())
681 .collect(),
682 _ => Vec::new(),
683 }
684}
685
686pub struct TestSession {
691 name: String,
692}
693
694impl TestSession {
695 pub fn new(name: impl Into<String>) -> Self {
697 Self { name: name.into() }
698 }
699
700 pub fn name(&self) -> &str {
702 &self.name
703 }
704}
705
706impl Drop for TestSession {
707 fn drop(&mut self) {
708 let _ = kill_session(&self.name);
709 }
710}
711
712pub fn kill_session(session: &str) -> Result<()> {
714 if !session_exists(session) {
715 return Ok(()); }
717
718 let output = Command::new("tmux")
719 .args(["kill-session", "-t", session])
720 .output()
721 .with_context(|| format!("failed to kill tmux session '{session}'"))?;
722
723 if !output.status.success() {
724 let stderr = String::from_utf8_lossy(&output.stderr);
725 bail!("tmux kill-session failed: {stderr}");
726 }
727
728 info!(session = session, "tmux session killed");
729 Ok(())
730}
731
732pub fn capture_pane(target: &str) -> Result<String> {
737 capture_pane_recent(target, 0)
738}
739
740pub fn capture_pane_recent(target: &str, lines: u32) -> Result<String> {
742 let mut args = vec![
743 "capture-pane".to_string(),
744 "-t".to_string(),
745 target.to_string(),
746 "-p".to_string(),
747 ];
748 if lines > 0 {
749 args.push("-S".to_string());
750 args.push(format!("-{lines}"));
751 }
752
753 let output = Command::new("tmux")
754 .args(&args)
755 .output()
756 .with_context(|| format!("failed to capture pane for target '{target}'"))?;
757
758 if !output.status.success() {
759 let stderr = String::from_utf8_lossy(&output.stderr);
760 return Err(TmuxError::command_failed("capture-pane", Some(target), &stderr).into());
761 }
762
763 Ok(String::from_utf8_lossy(&output.stdout).to_string())
764}
765
766pub fn set_mouse(session: &str, enabled: bool) -> Result<()> {
768 let value = if enabled { "on" } else { "off" };
769 tmux_set(session, "mouse", value)
770}
771
772fn bind_supervisor_hotkey(session: &str, key: &str, action: &str) -> Result<()> {
773 let output = Command::new("tmux")
774 .args([
775 "bind-key",
776 "-T",
777 "prefix",
778 key,
779 "set-option",
780 "-t",
781 session,
782 SUPERVISOR_CONTROL_OPTION,
783 action,
784 ])
785 .output()
786 .with_context(|| format!("failed to bind supervisor hotkey '{key}' for '{session}'"))?;
787
788 if !output.status.success() {
789 let stderr = String::from_utf8_lossy(&output.stderr);
790 bail!("tmux bind-key {key} failed: {stderr}");
791 }
792
793 Ok(())
794}
795
796pub fn configure_supervisor_hotkeys(session: &str) -> Result<()> {
800 tmux_set(session, SUPERVISOR_CONTROL_OPTION, "")?;
801 bind_supervisor_hotkey(session, "P", "pause")?;
802 bind_supervisor_hotkey(session, "R", "resume")?;
803 Ok(())
804}
805
806pub fn take_supervisor_hotkey_action(session: &str) -> Result<Option<String>> {
810 let output = Command::new("tmux")
811 .args([
812 "show-options",
813 "-v",
814 "-t",
815 session,
816 SUPERVISOR_CONTROL_OPTION,
817 ])
818 .output()
819 .with_context(|| {
820 format!("failed to read supervisor control option for session '{session}'")
821 })?;
822
823 if !output.status.success() {
824 let stderr = String::from_utf8_lossy(&output.stderr);
825 bail!("tmux show-options supervisor control failed: {stderr}");
826 }
827
828 let action = String::from_utf8_lossy(&output.stdout).trim().to_string();
829 if action.is_empty() {
830 return Ok(None);
831 }
832
833 tmux_set(session, SUPERVISOR_CONTROL_OPTION, "")?;
834 Ok(Some(action))
835}
836
837#[cfg(test)]
841pub fn list_panes(session: &str) -> Result<Vec<String>> {
842 let output = Command::new("tmux")
843 .args(["list-panes", "-t", session, "-F", "#{pane_id}"])
844 .output()
845 .with_context(|| format!("failed to list panes for session '{session}'"))?;
846
847 if !output.status.success() {
848 let stderr = String::from_utf8_lossy(&output.stderr);
849 bail!("tmux list-panes failed: {stderr}");
850 }
851
852 let panes = String::from_utf8_lossy(&output.stdout)
853 .lines()
854 .map(|s| s.to_string())
855 .collect();
856
857 Ok(panes)
858}
859
860#[cfg(test)]
861fn list_window_names(session: &str) -> Result<Vec<String>> {
862 let output = Command::new("tmux")
863 .args(["list-windows", "-t", session, "-F", "#{window_name}"])
864 .output()
865 .with_context(|| format!("failed to list windows for session '{session}'"))?;
866
867 if !output.status.success() {
868 let stderr = String::from_utf8_lossy(&output.stderr);
869 bail!("tmux list-windows failed: {stderr}");
870 }
871
872 Ok(String::from_utf8_lossy(&output.stdout)
873 .lines()
874 .map(|line| line.trim().to_string())
875 .filter(|line| !line.is_empty())
876 .collect())
877}
878
879pub fn list_pane_details(session: &str) -> Result<Vec<PaneDetails>> {
881 let output = Command::new("tmux")
882 .args([
883 "list-panes",
884 "-t",
885 session,
886 "-F",
887 "#{pane_id}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}",
888 ])
889 .output()
890 .with_context(|| format!("failed to list pane details for session '{session}'"))?;
891
892 if !output.status.success() {
893 let stderr = String::from_utf8_lossy(&output.stderr);
894 bail!("tmux list-panes details failed: {stderr}");
895 }
896
897 let mut panes = Vec::new();
898 for line in String::from_utf8_lossy(&output.stdout).lines() {
899 let mut parts = line.split('\t');
900 let Some(id) = parts.next() else { continue };
901 let Some(command) = parts.next() else {
902 continue;
903 };
904 let Some(active) = parts.next() else { continue };
905 let Some(dead) = parts.next() else { continue };
906 panes.push(PaneDetails {
907 id: id.to_string(),
908 command: command.to_string(),
909 active: active == "1",
910 dead: dead == "1",
911 });
912 }
913
914 Ok(panes)
915}
916
917pub fn split_window_horizontal(target_pane: &str, size_pct: u32) -> Result<String> {
922 let size = format!("{size_pct}%");
923 let output = Command::new("tmux")
924 .args([
925 "split-window",
926 "-h",
927 "-t",
928 target_pane,
929 "-l",
930 &size,
931 "-P",
932 "-F",
933 "#{pane_id}",
934 ])
935 .output()
936 .with_context(|| format!("failed to split pane '{target_pane}' horizontally"))?;
937
938 if !output.status.success() {
939 let stderr = String::from_utf8_lossy(&output.stderr);
940 bail!("tmux split-window -h failed: {stderr}");
941 }
942
943 let pane_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
944 debug!(target_pane, pane_id = %pane_id, size_pct, "horizontal split created");
945 Ok(pane_id)
946}
947
948pub fn split_window_vertical_in_pane(
952 _session: &str,
953 pane_id: &str,
954 size_pct: u32,
955) -> Result<String> {
956 let size = format!("{size_pct}%");
958 let output = Command::new("tmux")
959 .args([
960 "split-window",
961 "-v",
962 "-t",
963 pane_id,
964 "-l",
965 &size,
966 "-P",
967 "-F",
968 "#{pane_id}",
969 ])
970 .output()
971 .with_context(|| format!("failed to split pane '{pane_id}' vertically"))?;
972
973 if !output.status.success() {
974 let stderr = String::from_utf8_lossy(&output.stderr);
975 bail!("tmux split-window -v failed for pane '{pane_id}': {stderr}");
976 }
977
978 let new_pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
979 debug!(pane_id = %new_pane, parent = pane_id, size_pct, "vertical split created");
980 Ok(new_pane)
981}
982
983pub fn select_layout_even(target_pane: &str) -> Result<()> {
985 let output = Command::new("tmux")
986 .args(["select-layout", "-E", "-t", target_pane])
987 .output()
988 .with_context(|| format!("failed to even layout for pane '{target_pane}'"))?;
989
990 if !output.status.success() {
991 let stderr = String::from_utf8_lossy(&output.stderr);
992 bail!("tmux select-layout -E failed: {stderr}");
993 }
994
995 Ok(())
996}
997
998const BATTY_BUFFER_NAME: &str = "batty-inject";
1001
1002pub fn load_buffer(content: &str) -> Result<()> {
1007 let tmp = std::env::temp_dir().join(format!("batty-buf-{}", std::process::id()));
1008 std::fs::write(&tmp, content)
1009 .with_context(|| format!("failed to write buffer file {}", tmp.display()))?;
1010
1011 let output = Command::new("tmux")
1012 .args([
1013 "load-buffer",
1014 "-b",
1015 BATTY_BUFFER_NAME,
1016 &tmp.to_string_lossy(),
1017 ])
1018 .output()
1019 .context("failed to run tmux load-buffer")?;
1020
1021 let _ = std::fs::remove_file(&tmp);
1022
1023 if !output.status.success() {
1024 let stderr = String::from_utf8_lossy(&output.stderr);
1025 bail!("tmux load-buffer failed: {stderr}");
1026 }
1027
1028 Ok(())
1029}
1030
1031pub fn paste_buffer(target: &str) -> Result<()> {
1036 let output = Command::new("tmux")
1037 .args(["paste-buffer", "-d", "-b", BATTY_BUFFER_NAME, "-t", target])
1038 .output()
1039 .with_context(|| format!("failed to paste buffer into '{target}'"))?;
1040
1041 if !output.status.success() {
1042 let stderr = String::from_utf8_lossy(&output.stderr);
1043 bail!("tmux paste-buffer failed: {stderr}");
1044 }
1045
1046 Ok(())
1047}
1048
1049pub fn kill_pane(target: &str) -> Result<()> {
1051 let output = Command::new("tmux")
1052 .args(["kill-pane", "-t", target])
1053 .output()
1054 .with_context(|| format!("failed to kill pane '{target}'"))?;
1055
1056 if !output.status.success() {
1057 let stderr = String::from_utf8_lossy(&output.stderr);
1058 if !stderr.contains("not found") {
1060 bail!("tmux kill-pane failed: {stderr}");
1061 }
1062 }
1063
1064 Ok(())
1065}
1066
1067pub fn respawn_pane(target: &str, command: &str) -> Result<()> {
1069 let output = Command::new("tmux")
1070 .args(["respawn-pane", "-t", target, "-k", command])
1071 .output()
1072 .with_context(|| format!("failed to respawn pane '{target}'"))?;
1073
1074 if !output.status.success() {
1075 let stderr = String::from_utf8_lossy(&output.stderr);
1076 bail!("tmux respawn-pane failed: {stderr}");
1077 }
1078
1079 Ok(())
1080}
1081
1082pub fn tmux_set(session: &str, option: &str, value: &str) -> Result<()> {
1084 let output = Command::new("tmux")
1085 .args(["set", "-t", session, option, value])
1086 .output()
1087 .with_context(|| format!("failed to set tmux option '{option}' for session '{session}'"))?;
1088
1089 if !output.status.success() {
1090 let stderr = String::from_utf8_lossy(&output.stderr);
1091 bail!("tmux set {option} failed: {stderr}");
1092 }
1093
1094 Ok(())
1095}
1096
1097#[cfg(test)]
1106mod tests {
1107 use super::*;
1108 use crate::team::test_support::PATH_LOCK;
1109 use serial_test::serial;
1110 use std::cell::RefCell;
1111
1112 thread_local! {
1113 static TMUX_TEST_PATH_GUARD: RefCell<Option<std::sync::MutexGuard<'static, ()>>> = const { RefCell::new(None) };
1114 }
1115
1116 fn require_tmux_integration() -> bool {
1117 TMUX_TEST_PATH_GUARD.with(|slot| {
1118 if slot.borrow().is_none() {
1119 *slot.borrow_mut() =
1120 Some(PATH_LOCK.lock().unwrap_or_else(|error| error.into_inner()));
1121 }
1122 });
1123 if tmux_available() {
1124 return true;
1125 }
1126 eprintln!("skipping tmux integration test: tmux binary unavailable");
1127 false
1128 }
1129
1130 #[test]
1131 fn session_name_convention() {
1132 assert_eq!(session_name("phase-1"), "batty-phase-1");
1133 assert_eq!(session_name("phase-2"), "batty-phase-2");
1134 assert_eq!(session_name("phase-2.5"), "batty-phase-2-5");
1135 assert_eq!(session_name("phase 3"), "batty-phase-3");
1136 }
1137
1138 #[test]
1139 #[serial]
1140 #[cfg_attr(not(feature = "integration"), ignore)]
1141 fn check_tmux_finds_binary() {
1142 let version = check_tmux().unwrap();
1143 assert!(
1144 version.starts_with("tmux"),
1145 "expected tmux version, got: {version}"
1146 );
1147 }
1148
1149 #[test]
1150 fn parse_tmux_version_supports_minor_suffixes() {
1151 assert_eq!(parse_tmux_version("tmux 3.4"), Some((3, 4)));
1152 assert_eq!(parse_tmux_version("tmux 3.3a"), Some((3, 3)));
1153 assert_eq!(parse_tmux_version("tmux 2.9"), Some((2, 9)));
1154 assert_eq!(parse_tmux_version("tmux unknown"), None);
1155 }
1156
1157 #[test]
1158 fn check_tmux_reports_missing_binary() {
1159 assert!(check_tmux_with_program("__batty_missing_tmux__").is_err());
1160 }
1161
1162 #[test]
1163 fn capabilities_known_good_matrix() {
1164 let good = TmuxCapabilities {
1165 version_raw: "tmux 3.2".to_string(),
1166 version: Some((3, 2)),
1167 pipe_pane: true,
1168 pipe_pane_only_if_missing: true,
1169 status_style: true,
1170 split_mode: SplitMode::Lines,
1171 };
1172 assert!(good.known_good());
1173
1174 let fallback = TmuxCapabilities {
1175 version_raw: "tmux 3.1".to_string(),
1176 version: Some((3, 1)),
1177 pipe_pane: true,
1178 pipe_pane_only_if_missing: false,
1179 status_style: true,
1180 split_mode: SplitMode::Percent,
1181 };
1182 assert!(!fallback.known_good());
1183 }
1184
1185 #[test]
1186 #[serial]
1187 #[cfg_attr(not(feature = "integration"), ignore)]
1188 fn capability_probe_reports_pipe_pane() {
1189 let caps = probe_capabilities().unwrap();
1190 assert!(
1191 caps.pipe_pane,
1192 "pipe-pane should be available for batty runtime"
1193 );
1194 }
1195
1196 #[test]
1197 #[serial]
1198 #[cfg_attr(not(feature = "integration"), ignore)]
1199 fn nonexistent_session_does_not_exist() {
1200 assert!(!session_exists("batty-test-nonexistent-12345"));
1201 }
1202
1203 #[test]
1204 #[serial]
1205 fn create_and_kill_session() {
1206 if !require_tmux_integration() {
1207 return;
1208 }
1209 let session = "batty-test-lifecycle";
1210 let _ = kill_session(session);
1212
1213 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1215 assert!(session_exists(session));
1216
1217 kill_session(session).unwrap();
1219 assert!(!session_exists(session));
1220 }
1221
1222 #[test]
1223 #[serial]
1224 #[cfg_attr(not(feature = "integration"), ignore)]
1225 fn session_path_returns_working_directory() {
1226 let session = "batty-test-session-path";
1227 let _ = kill_session(session);
1228
1229 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1230 let path = session_path(session).unwrap();
1231 assert_eq!(path, "/tmp");
1232
1233 kill_session(session).unwrap();
1234 }
1235
1236 #[test]
1237 #[serial]
1238 #[cfg_attr(not(feature = "integration"), ignore)]
1239 fn pane_current_path_returns_working_directory() {
1240 let session = "batty-test-pane-current-path";
1241 let _ = kill_session(session);
1242
1243 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1244 let pane = pane_id(session).unwrap();
1245 let path = pane_current_path(&pane).unwrap();
1246 assert_eq!(
1247 std::fs::canonicalize(&path).unwrap(),
1248 std::fs::canonicalize("/tmp").unwrap()
1249 );
1250
1251 kill_session(session).unwrap();
1252 }
1253
1254 #[test]
1255 #[serial]
1256 #[cfg_attr(not(feature = "integration"), ignore)]
1257 fn duplicate_session_is_error() {
1258 let session = "batty-test-dup";
1259 let _ = kill_session(session);
1260
1261 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1262
1263 let result = create_session(session, "sleep", &["10".to_string()], "/tmp");
1264 assert!(result.is_err());
1265 assert!(result.unwrap_err().to_string().contains("already exists"));
1266
1267 kill_session(session).unwrap();
1268 }
1269
1270 #[test]
1271 #[serial]
1272 #[cfg_attr(not(feature = "integration"), ignore)]
1273 fn create_window_adds_named_window_to_existing_session() {
1274 let session = "batty-test-window-create";
1275 let _ = kill_session(session);
1276
1277 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1278 rename_window(&format!("{session}:0"), "agent-1").unwrap();
1279 create_window(session, "agent-2", "sleep", &["10".to_string()], "/tmp").unwrap();
1280
1281 let names = list_window_names(session).unwrap();
1282 assert!(names.contains(&"agent-1".to_string()));
1283 assert!(names.contains(&"agent-2".to_string()));
1284
1285 select_window(&format!("{session}:agent-1")).unwrap();
1286 kill_session(session).unwrap();
1287 }
1288
1289 #[test]
1290 #[serial]
1291 #[cfg_attr(not(feature = "integration"), ignore)]
1292 fn create_window_unsets_claudecode_from_session_environment() {
1293 let session = "batty-test-window-unset-claudecode";
1294 let _ = kill_session(session);
1295
1296 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1297
1298 let output = Command::new("tmux")
1299 .args(["set-environment", "-t", session, "CLAUDECODE", "1"])
1300 .output()
1301 .unwrap();
1302 assert!(
1303 output.status.success(),
1304 "failed to set CLAUDECODE in tmux session: {}",
1305 String::from_utf8_lossy(&output.stderr)
1306 );
1307
1308 create_window(
1309 session,
1310 "env-check",
1311 "bash",
1312 &[
1313 "-lc".to_string(),
1314 "printf '%s' \"${CLAUDECODE:-unset}\"; sleep 1".to_string(),
1315 ],
1316 "/tmp",
1317 )
1318 .unwrap();
1319
1320 std::thread::sleep(std::time::Duration::from_millis(300));
1321
1322 let content = capture_pane(&format!("{session}:env-check")).unwrap();
1323 assert!(
1324 content.contains("unset"),
1325 "expected CLAUDECODE to be unset in new window, got: {content:?}"
1326 );
1327
1328 kill_session(session).unwrap();
1329 }
1330
1331 #[test]
1332 #[serial]
1333 #[cfg_attr(not(feature = "integration"), ignore)]
1334 fn create_session_enables_mouse_mode() {
1335 let session = "batty-test-mouse";
1336 let _ = kill_session(session);
1337
1338 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1339
1340 let output = Command::new("tmux")
1341 .args(["show-options", "-t", session, "-v", "mouse"])
1342 .output()
1343 .unwrap();
1344 assert!(output.status.success());
1345 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1346 assert_eq!(value, "on", "expected tmux mouse mode to be enabled");
1347
1348 kill_session(session).unwrap();
1349 }
1350
1351 #[test]
1352 #[serial]
1353 #[cfg_attr(not(feature = "integration"), ignore)]
1354 fn send_keys_to_session() {
1355 let session = "batty-test-sendkeys";
1356 let _ = kill_session(session);
1357
1358 create_session(session, "cat", &[], "/tmp").unwrap();
1360
1361 std::thread::sleep(std::time::Duration::from_millis(200));
1363
1364 send_keys(session, "hello", true).unwrap();
1366
1367 kill_session(session).unwrap();
1369 }
1370
1371 #[test]
1372 #[serial]
1373 #[cfg_attr(not(feature = "integration"), ignore)]
1374 fn send_keys_with_enter_submits_line() {
1375 let session = "batty-test-sendkeys-enter";
1376 let _ = kill_session(session);
1377
1378 let tmp = tempfile::tempdir().unwrap();
1379 let log_path = tmp.path().join("sendkeys.log");
1380
1381 create_session(session, "cat", &[], "/tmp").unwrap();
1382 setup_pipe_pane(session, &log_path).unwrap();
1383 std::thread::sleep(std::time::Duration::from_millis(200));
1384
1385 send_keys(session, "supervisor ping", true).unwrap();
1386 std::thread::sleep(std::time::Duration::from_millis(300));
1387
1388 let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1389 assert!(
1390 content.contains("supervisor ping"),
1391 "expected injected text in pane log, got: {content:?}"
1392 );
1393 assert!(
1394 content.contains("supervisor ping\r\n") || content.contains("supervisor ping\n"),
1395 "expected submitted line ending in pane log, got: {content:?}"
1396 );
1397
1398 kill_session(session).unwrap();
1399 }
1400
1401 #[test]
1402 #[serial]
1403 #[cfg_attr(not(feature = "integration"), ignore)]
1404 fn send_keys_enter_only_submits_prompt() {
1405 let session = "batty-test-sendkeys-enter-only";
1406 let _ = kill_session(session);
1407
1408 let tmp = tempfile::tempdir().unwrap();
1409 let log_path = tmp.path().join("sendkeys-enter-only.log");
1410
1411 create_session(session, "cat", &[], "/tmp").unwrap();
1412 let pane = pane_id(session).unwrap();
1413 setup_pipe_pane(&pane, &log_path).unwrap();
1414 std::thread::sleep(std::time::Duration::from_millis(200));
1415
1416 send_keys(&pane, "", true).unwrap();
1417 std::thread::sleep(std::time::Duration::from_millis(300));
1418
1419 let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1420 assert!(
1421 content.contains("\r\n") || content.contains('\n'),
1422 "expected submitted empty line in pane log, got: {content:?}"
1423 );
1424
1425 kill_session(session).unwrap();
1426 }
1427
1428 #[test]
1429 #[serial]
1430 #[cfg_attr(not(feature = "integration"), ignore)]
1431 fn pipe_pane_captures_output() {
1432 let session = "batty-test-pipe";
1433 let _ = kill_session(session);
1434
1435 let tmp = tempfile::tempdir().unwrap();
1436 let log_path = tmp.path().join("pty-output.log");
1437
1438 create_session(session, "bash", &[], "/tmp").unwrap();
1440 let pane = pane_id(session).unwrap();
1441
1442 setup_pipe_pane(&pane, &log_path).unwrap();
1444
1445 std::thread::sleep(std::time::Duration::from_millis(200));
1447
1448 send_keys(&pane, "echo pipe-test-output", true).unwrap();
1450
1451 let mut found = false;
1453 for _ in 0..10 {
1454 std::thread::sleep(std::time::Duration::from_millis(200));
1455 if log_path.exists() {
1456 let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1457 if !content.is_empty() {
1458 found = true;
1459 break;
1460 }
1461 }
1462 }
1463
1464 kill_session(session).unwrap();
1465 assert!(found, "pipe-pane log should have captured output");
1466 }
1467
1468 #[test]
1469 #[serial]
1470 #[cfg_attr(not(feature = "integration"), ignore)]
1471 fn capture_pane_returns_content() {
1472 let session = "batty-test-capture";
1473 let _ = kill_session(session);
1474
1475 create_session(
1476 session,
1477 "bash",
1478 &["-c".to_string(), "echo 'capture-test'; sleep 2".to_string()],
1479 "/tmp",
1480 )
1481 .unwrap();
1482 std::thread::sleep(std::time::Duration::from_millis(500));
1483
1484 let content = capture_pane(session).unwrap();
1485 assert!(
1487 !content.trim().is_empty(),
1488 "capture-pane should return content"
1489 );
1490
1491 kill_session(session).unwrap();
1492 }
1493
1494 #[test]
1495 #[serial]
1496 #[cfg_attr(not(feature = "integration"), ignore)]
1497 fn capture_pane_recent_returns_content() {
1498 let session = "batty-test-capture-recent";
1499 let _ = kill_session(session);
1500
1501 create_session(
1502 session,
1503 "bash",
1504 &[
1505 "-c".to_string(),
1506 "echo 'capture-recent-test'; sleep 2".to_string(),
1507 ],
1508 "/tmp",
1509 )
1510 .unwrap();
1511 std::thread::sleep(std::time::Duration::from_millis(500));
1512
1513 let content = capture_pane_recent(session, 10).unwrap();
1514 assert!(content.contains("capture-recent-test"));
1515
1516 kill_session(session).unwrap();
1517 }
1518
1519 #[test]
1520 #[serial]
1521 #[cfg_attr(not(feature = "integration"), ignore)]
1522 fn list_panes_returns_at_least_one() {
1523 let session = "batty-test-panes";
1524 let _ = kill_session(session);
1525
1526 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1527
1528 let panes = list_panes(session).unwrap();
1529 assert!(!panes.is_empty(), "session should have at least one pane");
1530
1531 kill_session(session).unwrap();
1532 }
1533
1534 #[test]
1535 #[serial]
1536 #[cfg_attr(not(feature = "integration"), ignore)]
1537 fn list_pane_details_includes_active_flag() {
1538 let session = "batty-test-pane-details";
1539 let _ = kill_session(session);
1540
1541 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1542
1543 let panes = list_pane_details(session).unwrap();
1544 assert!(
1545 !panes.is_empty(),
1546 "expected at least one pane detail record"
1547 );
1548 assert!(
1549 panes.iter().any(|p| p.active),
1550 "expected one active pane, got: {panes:?}"
1551 );
1552
1553 kill_session(session).unwrap();
1554 }
1555
1556 #[test]
1557 #[serial]
1558 #[cfg_attr(not(feature = "integration"), ignore)]
1559 fn configure_supervisor_hotkeys_initializes_control_option() {
1560 let session = "batty-test-supervisor-hotkeys";
1561 let _ = kill_session(session);
1562
1563 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1564 configure_supervisor_hotkeys(session).unwrap();
1565
1566 let output = Command::new("tmux")
1567 .args([
1568 "show-options",
1569 "-v",
1570 "-t",
1571 session,
1572 SUPERVISOR_CONTROL_OPTION,
1573 ])
1574 .output()
1575 .unwrap();
1576 assert!(output.status.success());
1577 assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
1578
1579 kill_session(session).unwrap();
1580 }
1581
1582 #[test]
1583 #[serial]
1584 #[cfg_attr(not(feature = "integration"), ignore)]
1585 fn take_supervisor_hotkey_action_reads_and_clears() {
1586 let session = "batty-test-supervisor-action";
1587 let _ = kill_session(session);
1588
1589 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1590 configure_supervisor_hotkeys(session).unwrap();
1591 tmux_set(session, SUPERVISOR_CONTROL_OPTION, "pause").unwrap();
1592
1593 let first = take_supervisor_hotkey_action(session).unwrap();
1594 assert_eq!(first.as_deref(), Some("pause"));
1595
1596 let second = take_supervisor_hotkey_action(session).unwrap();
1597 assert!(second.is_none(), "expected action to be cleared");
1598
1599 kill_session(session).unwrap();
1600 }
1601
1602 #[test]
1603 #[serial]
1604 #[cfg_attr(not(feature = "integration"), ignore)]
1605 fn kill_nonexistent_session_is_ok() {
1606 kill_session("batty-test-nonexistent-kill-99999").unwrap();
1608 }
1609
1610 #[test]
1611 #[serial]
1612 #[cfg_attr(not(feature = "integration"), ignore)]
1613 fn session_with_short_lived_process() {
1614 let session = "batty-test-shortlived";
1615 let _ = kill_session(session);
1616
1617 create_session(session, "echo", &["done".to_string()], "/tmp").unwrap();
1619
1620 std::thread::sleep(std::time::Duration::from_millis(500));
1622
1623 let _ = kill_session(session);
1626 }
1627
1628 #[test]
1629 #[serial]
1630 #[cfg_attr(not(feature = "integration"), ignore)]
1631 fn test_session_guard_cleanup_on_drop() {
1632 let name = "batty-test-guard-drop";
1633 let _ = kill_session(name);
1634
1635 {
1636 let guard = TestSession::new(name);
1637 create_session(guard.name(), "sleep", &["30".to_string()], "/tmp").unwrap();
1638 assert!(session_exists(name));
1639 }
1641
1642 assert!(!session_exists(name), "session should be killed on drop");
1643 }
1644
1645 #[test]
1646 #[serial]
1647 #[cfg_attr(not(feature = "integration"), ignore)]
1648 fn test_session_guard_cleanup_on_panic() {
1649 let name = "batty-test-guard-panic";
1650 let _ = kill_session(name);
1651
1652 let result = std::panic::catch_unwind(|| {
1653 let guard = TestSession::new(name);
1654 create_session(guard.name(), "sleep", &["30".to_string()], "/tmp").unwrap();
1655 assert!(session_exists(name));
1656 panic!("intentional panic to test cleanup");
1657 #[allow(unreachable_code)]
1658 drop(guard);
1659 });
1660
1661 assert!(result.is_err(), "should have panicked");
1662 assert!(
1664 !session_exists(name),
1665 "session should be cleaned up even after panic"
1666 );
1667 }
1668
1669 #[test]
1672 fn parse_tmux_version_empty_string() {
1673 assert_eq!(parse_tmux_version(""), None);
1674 }
1675
1676 #[test]
1677 fn parse_tmux_version_no_prefix() {
1678 assert_eq!(parse_tmux_version("3.4"), None);
1680 }
1681
1682 #[test]
1683 fn parse_tmux_version_major_only_no_dot() {
1684 assert_eq!(parse_tmux_version("tmux 3"), None);
1685 }
1686
1687 #[test]
1688 fn parse_tmux_version_multi_digit() {
1689 assert_eq!(parse_tmux_version("tmux 10.12"), Some((10, 12)));
1690 }
1691
1692 #[test]
1693 fn parse_tmux_version_trailing_whitespace() {
1694 assert_eq!(parse_tmux_version(" tmux 3.4 "), Some((3, 4)));
1695 }
1696
1697 #[test]
1698 fn parse_tmux_version_next_suffix() {
1699 assert_eq!(parse_tmux_version("tmux next-3.5"), None);
1701 }
1702
1703 #[test]
1704 fn parse_tmux_version_dot_no_minor() {
1705 assert_eq!(parse_tmux_version("tmux 3."), None);
1706 }
1707
1708 #[test]
1709 fn parse_tmux_version_double_suffix_letters() {
1710 assert_eq!(parse_tmux_version("tmux 3.3ab"), Some((3, 3)));
1711 }
1712
1713 #[test]
1716 fn capabilities_known_good_version_4() {
1717 let caps = TmuxCapabilities {
1718 version_raw: "tmux 4.0".to_string(),
1719 version: Some((4, 0)),
1720 pipe_pane: true,
1721 pipe_pane_only_if_missing: true,
1722 status_style: true,
1723 split_mode: SplitMode::Lines,
1724 };
1725 assert!(caps.known_good(), "4.0 should be known good");
1726 }
1727
1728 #[test]
1729 fn capabilities_known_good_version_2_9() {
1730 let caps = TmuxCapabilities {
1731 version_raw: "tmux 2.9".to_string(),
1732 version: Some((2, 9)),
1733 pipe_pane: true,
1734 pipe_pane_only_if_missing: false,
1735 status_style: true,
1736 split_mode: SplitMode::Percent,
1737 };
1738 assert!(!caps.known_good(), "2.9 should not be known good");
1739 }
1740
1741 #[test]
1742 fn capabilities_known_good_version_3_0() {
1743 let caps = TmuxCapabilities {
1744 version_raw: "tmux 3.0".to_string(),
1745 version: Some((3, 0)),
1746 pipe_pane: true,
1747 pipe_pane_only_if_missing: false,
1748 status_style: true,
1749 split_mode: SplitMode::Percent,
1750 };
1751 assert!(!caps.known_good(), "3.0 should not be known good");
1752 }
1753
1754 #[test]
1755 fn capabilities_known_good_none_version() {
1756 let caps = TmuxCapabilities {
1757 version_raw: "tmux unknown".to_string(),
1758 version: None,
1759 pipe_pane: false,
1760 pipe_pane_only_if_missing: false,
1761 status_style: false,
1762 split_mode: SplitMode::Disabled,
1763 };
1764 assert!(!caps.known_good(), "None version should not be known good");
1765 }
1766
1767 #[test]
1768 fn capabilities_remediation_message_includes_version() {
1769 let caps = TmuxCapabilities {
1770 version_raw: "tmux 2.8".to_string(),
1771 version: Some((2, 8)),
1772 pipe_pane: false,
1773 pipe_pane_only_if_missing: false,
1774 status_style: false,
1775 split_mode: SplitMode::Disabled,
1776 };
1777 let msg = caps.remediation_message();
1778 assert!(
1779 msg.contains("tmux 2.8"),
1780 "message should include detected version"
1781 );
1782 assert!(
1783 msg.contains("pipe-pane"),
1784 "message should mention pipe-pane requirement"
1785 );
1786 assert!(msg.contains("3.2"), "message should recommend >= 3.2");
1787 }
1788
1789 #[test]
1792 fn session_name_empty_input() {
1793 assert_eq!(session_name(""), "batty-");
1794 }
1795
1796 #[test]
1797 fn session_name_preserves_underscores() {
1798 assert_eq!(session_name("my_session"), "batty-my_session");
1799 }
1800
1801 #[test]
1802 fn session_name_replaces_colons_and_slashes() {
1803 assert_eq!(session_name("a:b/c"), "batty-a-b-c");
1804 }
1805
1806 #[test]
1807 fn session_name_replaces_multiple_dots() {
1808 assert_eq!(session_name("v1.2.3"), "batty-v1-2-3");
1809 }
1810
1811 #[test]
1814 fn pane_details_clone_and_eq() {
1815 let pd = PaneDetails {
1816 id: "%5".to_string(),
1817 command: "bash".to_string(),
1818 active: true,
1819 dead: false,
1820 };
1821 let cloned = pd.clone();
1822 assert_eq!(pd, cloned);
1823 assert_eq!(pd.id, "%5");
1824 assert!(pd.active);
1825 assert!(!pd.dead);
1826 }
1827
1828 #[test]
1829 fn pane_details_not_equal_different_id() {
1830 let a = PaneDetails {
1831 id: "%1".to_string(),
1832 command: "bash".to_string(),
1833 active: true,
1834 dead: false,
1835 };
1836 let b = PaneDetails {
1837 id: "%2".to_string(),
1838 command: "bash".to_string(),
1839 active: true,
1840 dead: false,
1841 };
1842 assert_ne!(a, b);
1843 }
1844
1845 #[test]
1848 fn split_mode_debug_and_eq() {
1849 assert_eq!(SplitMode::Lines, SplitMode::Lines);
1850 assert_ne!(SplitMode::Lines, SplitMode::Percent);
1851 assert_ne!(SplitMode::Percent, SplitMode::Disabled);
1852 let copied = SplitMode::Lines;
1853 assert_eq!(format!("{:?}", copied), "Lines");
1854 }
1855
1856 #[test]
1859 fn test_session_name_accessor() {
1860 let guard = TestSession::new("batty-test-accessor");
1861 assert_eq!(guard.name(), "batty-test-accessor");
1862 }
1864
1865 #[test]
1868 #[serial]
1869 fn pane_exists_for_valid_pane() {
1870 if !require_tmux_integration() {
1871 return;
1872 }
1873 let session = "batty-test-pane-exists";
1874 let _guard = TestSession::new(session);
1875 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1876
1877 let pane = pane_id(session).unwrap();
1878 assert!(pane_exists(&pane), "existing pane should be found");
1879 }
1880
1881 #[test]
1882 #[serial]
1883 fn session_exists_returns_false_after_kill() {
1884 if !require_tmux_integration() {
1885 return;
1886 }
1887 let session = "batty-test-sess-exists-gone";
1888 let _ = kill_session(session);
1889 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1890 assert!(session_exists(session));
1891
1892 kill_session(session).unwrap();
1893 assert!(
1894 !session_exists(session),
1895 "session should not exist after kill"
1896 );
1897 }
1898
1899 #[test]
1900 #[serial]
1901 fn pane_dead_for_running_process() {
1902 if !require_tmux_integration() {
1903 return;
1904 }
1905 let session = "batty-test-pane-dead-alive";
1906 let _guard = TestSession::new(session);
1907 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1908
1909 let pane = pane_id(session).unwrap();
1910 let dead = pane_dead(&pane).unwrap();
1911 assert!(!dead, "running process pane should not be dead");
1912 }
1913
1914 #[test]
1915 #[serial]
1916 fn list_sessions_with_prefix_finds_matching() {
1917 if !require_tmux_integration() {
1918 return;
1919 }
1920 let prefix = "batty-test-prefix-match";
1921 let s1 = format!("{prefix}-aaa");
1922 let s2 = format!("{prefix}-bbb");
1923 let _g1 = TestSession::new(s1.clone());
1924 let _g2 = TestSession::new(s2.clone());
1925
1926 create_session(&s1, "sleep", &["10".to_string()], "/tmp").unwrap();
1927 create_session(&s2, "sleep", &["10".to_string()], "/tmp").unwrap();
1928
1929 let found = list_sessions_with_prefix(prefix);
1930 assert!(
1931 found.contains(&s1),
1932 "should find first session, got: {found:?}"
1933 );
1934 assert!(
1935 found.contains(&s2),
1936 "should find second session, got: {found:?}"
1937 );
1938 }
1939
1940 #[test]
1941 #[serial]
1942 fn list_sessions_with_prefix_excludes_non_matching() {
1943 if !require_tmux_integration() {
1944 return;
1945 }
1946 let found = list_sessions_with_prefix("batty-test-zzz-nonexist-99999");
1947 assert!(found.is_empty(), "should find no sessions for bogus prefix");
1948 }
1949
1950 #[test]
1951 #[serial]
1952 fn split_window_horizontal_creates_new_pane() {
1953 if !require_tmux_integration() {
1954 return;
1955 }
1956 let session = "batty-test-hsplit";
1957 let _guard = TestSession::new(session);
1958 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1959
1960 let original = pane_id(session).unwrap();
1961 let new_pane = split_window_horizontal(&original, 50).unwrap();
1962 assert!(
1963 new_pane.starts_with('%'),
1964 "new pane id should start with %, got: {new_pane}"
1965 );
1966
1967 let panes = list_panes(session).unwrap();
1968 assert_eq!(panes.len(), 2, "should have 2 panes after split");
1969 assert!(panes.contains(&new_pane));
1970 }
1971
1972 #[test]
1973 #[serial]
1974 fn split_window_vertical_creates_new_pane() {
1975 if !require_tmux_integration() {
1976 return;
1977 }
1978 let session = "batty-test-vsplit";
1979 let _guard = TestSession::new(session);
1980 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1981
1982 let original = pane_id(session).unwrap();
1983 let new_pane = split_window_vertical_in_pane(session, &original, 50).unwrap();
1984 assert!(
1985 new_pane.starts_with('%'),
1986 "new pane id should start with %, got: {new_pane}"
1987 );
1988
1989 let panes = list_panes(session).unwrap();
1990 assert_eq!(panes.len(), 2, "should have 2 panes after split");
1991 assert!(panes.contains(&new_pane));
1992 }
1993
1994 #[test]
1995 #[serial]
1996 fn load_buffer_and_paste_buffer_injects_text() {
1997 if !require_tmux_integration() {
1998 return;
1999 }
2000 let session = "batty-test-paste-buf";
2001 let _guard = TestSession::new(session);
2002 create_session(session, "cat", &[], "/tmp").unwrap();
2003 std::thread::sleep(std::time::Duration::from_millis(200));
2004
2005 let pane = pane_id(session).unwrap();
2006 load_buffer("hello-from-buffer").unwrap();
2007 paste_buffer(&pane).unwrap();
2008
2009 std::thread::sleep(std::time::Duration::from_millis(300));
2010 let live_pane = pane_id(session).unwrap_or(pane);
2011 let content = capture_pane(&live_pane).unwrap();
2012 assert!(
2013 content.contains("hello-from-buffer"),
2014 "paste-buffer should inject text into pane, got: {content:?}"
2015 );
2016 }
2017
2018 #[test]
2019 #[serial]
2020 fn kill_pane_removes_pane() {
2021 if !require_tmux_integration() {
2022 return;
2023 }
2024 let session = "batty-test-kill-pane";
2025 let _guard = TestSession::new(session);
2026 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2027
2028 let original = pane_id(session).unwrap();
2029 let new_pane = split_window_horizontal(&original, 50).unwrap();
2030 let before = list_panes(session).unwrap();
2031 assert_eq!(before.len(), 2);
2032
2033 kill_pane(&new_pane).unwrap();
2034 let after = list_panes(session).unwrap();
2035 assert_eq!(after.len(), 1, "should have 1 pane after kill");
2036 assert!(!after.contains(&new_pane));
2037 }
2038
2039 #[test]
2040 #[serial]
2041 fn kill_pane_nonexistent_returns_error() {
2042 if !require_tmux_integration() {
2043 return;
2044 }
2045 let result = kill_pane("batty-test-no-such-session-xyz:0.0");
2048 let _ = result;
2051 }
2052
2053 #[test]
2054 #[serial]
2055 fn set_mouse_disable_and_enable() {
2056 if !require_tmux_integration() {
2057 return;
2058 }
2059 let session = "batty-test-mouse-toggle";
2060 let _guard = TestSession::new(session);
2061 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2062
2063 set_mouse(session, false).unwrap();
2065 let output = Command::new("tmux")
2066 .args(["show-options", "-t", session, "-v", "mouse"])
2067 .output()
2068 .unwrap();
2069 assert_eq!(
2070 String::from_utf8_lossy(&output.stdout).trim(),
2071 "off",
2072 "mouse should be disabled"
2073 );
2074
2075 set_mouse(session, true).unwrap();
2077 let output = Command::new("tmux")
2078 .args(["show-options", "-t", session, "-v", "mouse"])
2079 .output()
2080 .unwrap();
2081 assert_eq!(
2082 String::from_utf8_lossy(&output.stdout).trim(),
2083 "on",
2084 "mouse should be re-enabled"
2085 );
2086 }
2087
2088 #[test]
2089 #[serial]
2090 fn tmux_set_custom_option() {
2091 if !require_tmux_integration() {
2092 return;
2093 }
2094 let session = "batty-test-tmux-set";
2095 let _guard = TestSession::new(session);
2096 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2097
2098 tmux_set(session, "@batty_test_opt", "test-value").unwrap();
2099
2100 let output = Command::new("tmux")
2101 .args(["show-options", "-v", "-t", session, "@batty_test_opt"])
2102 .output()
2103 .unwrap();
2104 assert!(output.status.success());
2105 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test-value");
2106 }
2107
2108 #[test]
2109 #[serial]
2110 fn create_window_fails_for_missing_session() {
2111 if !require_tmux_integration() {
2112 return;
2113 }
2114 let result = create_window(
2115 "batty-test-nonexistent-session-99999",
2116 "test-win",
2117 "sleep",
2118 &["1".to_string()],
2119 "/tmp",
2120 );
2121 assert!(result.is_err(), "should fail for nonexistent session");
2122 assert!(
2123 result.unwrap_err().to_string().contains("not found"),
2124 "error should mention session not found"
2125 );
2126 }
2127
2128 #[test]
2129 #[serial]
2130 fn setup_pipe_pane_if_missing_works() {
2131 if !require_tmux_integration() {
2132 return;
2133 }
2134 let session = "batty-test-pipe-if-missing";
2135 let _guard = TestSession::new(session);
2136 let tmp = tempfile::tempdir().unwrap();
2137 let log_path = tmp.path().join("pipe-if-missing.log");
2138
2139 create_session(
2140 session,
2141 "bash",
2142 &["-c".to_string(), "sleep 10".to_string()],
2143 "/tmp",
2144 )
2145 .unwrap();
2146
2147 setup_pipe_pane_if_missing(session, &log_path).unwrap();
2150
2151 setup_pipe_pane_if_missing(session, &log_path).unwrap();
2153 }
2154
2155 #[test]
2156 #[serial]
2157 fn select_layout_even_after_splits() {
2158 if !require_tmux_integration() {
2159 return;
2160 }
2161 let session = "batty-test-layout-even";
2162 let _guard = TestSession::new(session);
2163 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2164
2165 let original = pane_id(session).unwrap();
2166 let _p2 = split_window_horizontal(&original, 50).unwrap();
2167
2168 select_layout_even(&original).unwrap();
2170 }
2171
2172 #[test]
2173 #[serial]
2174 fn rename_window_changes_name() {
2175 if !require_tmux_integration() {
2176 return;
2177 }
2178 let session = "batty-test-rename-win";
2179 let _guard = TestSession::new(session);
2180 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2181
2182 rename_window(&format!("{session}:0"), "custom-name").unwrap();
2183 let names = list_window_names(session).unwrap();
2184 assert!(
2185 names.contains(&"custom-name".to_string()),
2186 "window should be renamed, got: {names:?}"
2187 );
2188 }
2189
2190 #[test]
2191 #[serial]
2192 fn select_window_switches_active() {
2193 if !require_tmux_integration() {
2194 return;
2195 }
2196 let session = "batty-test-select-win";
2197 let _guard = TestSession::new(session);
2198 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2199 create_window(session, "second", "sleep", &["10".to_string()], "/tmp").unwrap();
2200
2201 select_window(&format!("{session}:second")).unwrap();
2203 }
2204
2205 #[test]
2206 #[serial]
2207 fn capture_pane_recent_zero_lines_returns_full() {
2208 if !require_tmux_integration() {
2209 return;
2210 }
2211 let session = "batty-test-capture-zero";
2212 let _guard = TestSession::new(session);
2213 create_session(
2214 session,
2215 "bash",
2216 &[
2217 "-c".to_string(),
2218 "echo 'zero-lines-test'; sleep 2".to_string(),
2219 ],
2220 "/tmp",
2221 )
2222 .unwrap();
2223 std::thread::sleep(std::time::Duration::from_millis(500));
2224
2225 let content = capture_pane_recent(session, 0).unwrap();
2227 assert!(
2228 content.contains("zero-lines-test"),
2229 "should capture full content with lines=0, got: {content:?}"
2230 );
2231 }
2232
2233 #[test]
2234 #[serial]
2235 fn list_pane_details_shows_command_info() {
2236 if !require_tmux_integration() {
2237 return;
2238 }
2239 let session = "batty-test-pane-details-cmd";
2240 let _guard = TestSession::new(session);
2241 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2242
2243 let details = list_pane_details(session).unwrap();
2244 assert_eq!(details.len(), 1);
2245 assert!(details[0].id.starts_with('%'));
2246 assert!(
2247 details[0].command == "sleep" || !details[0].command.is_empty(),
2248 "command should be reported"
2249 );
2250 assert!(!details[0].dead, "sleep pane should not be dead");
2251 }
2252
2253 #[test]
2254 #[serial]
2255 fn pane_id_returns_percent_prefixed() {
2256 if !require_tmux_integration() {
2257 return;
2258 }
2259 let session = "batty-test-paneid-fmt";
2260 let _guard = TestSession::new(session);
2261 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2262
2263 let id = pane_id(session).unwrap();
2264 assert!(
2265 id.starts_with('%'),
2266 "pane id should start with %, got: {id}"
2267 );
2268 }
2269
2270 #[test]
2271 #[serial]
2272 fn respawn_pane_restarts_running_pane() {
2273 if !require_tmux_integration() {
2274 return;
2275 }
2276 let session = "batty-test-respawn";
2277 let _guard = TestSession::new(session);
2278 create_session(session, "sleep", &["30".to_string()], "/tmp").unwrap();
2280
2281 let pane = pane_id(session).unwrap();
2282 respawn_pane(&pane, "sleep 10").unwrap();
2284 std::thread::sleep(std::time::Duration::from_millis(300));
2285
2286 assert!(pane_exists(&pane), "respawned pane should exist");
2288 }
2289}