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