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_dimensions(target: &str) -> Result<(u16, u16)> {
335 let output = Command::new("tmux")
336 .args([
337 "display-message",
338 "-p",
339 "-t",
340 target,
341 "#{pane_width} #{pane_height}",
342 ])
343 .output()
344 .with_context(|| format!("failed to query pane dimensions for target '{target}'"))?;
345
346 if !output.status.success() {
347 let stderr = String::from_utf8_lossy(&output.stderr);
348 return Err(TmuxError::command_failed(
349 "display-message #{pane_width} #{pane_height}",
350 Some(target),
351 &stderr,
352 )
353 .into());
354 }
355
356 let stdout = String::from_utf8_lossy(&output.stdout);
357 let mut parts = stdout.split_whitespace();
358 let width = parts
359 .next()
360 .context("tmux pane width missing")?
361 .parse()
362 .context("invalid tmux pane width")?;
363 let height = parts
364 .next()
365 .context("tmux pane height missing")?
366 .parse()
367 .context("invalid tmux pane height")?;
368 Ok((width, height))
369}
370
371pub fn pane_current_path(target: &str) -> Result<String> {
373 let output = Command::new("tmux")
374 .args([
375 "display-message",
376 "-p",
377 "-t",
378 target,
379 "#{pane_current_path}",
380 ])
381 .output()
382 .with_context(|| format!("failed to resolve pane current path for target '{target}'"))?;
383
384 if !output.status.success() {
385 let stderr = String::from_utf8_lossy(&output.stderr);
386 bail!("tmux display-message pane_current_path failed: {stderr}");
387 }
388
389 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
390 if path.is_empty() {
391 bail!("tmux returned empty pane current path for target '{target}'");
392 }
393 Ok(path)
394}
395
396pub fn session_path(session: &str) -> Result<String> {
398 let output = Command::new("tmux")
399 .args(["display-message", "-p", "-t", session, "#{session_path}"])
400 .output()
401 .with_context(|| format!("failed to resolve session path for '{session}'"))?;
402
403 if !output.status.success() {
404 let stderr = String::from_utf8_lossy(&output.stderr);
405 bail!("tmux display-message session_path failed: {stderr}");
406 }
407
408 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
409 if path.is_empty() {
410 bail!("tmux returned empty session path for '{session}'");
411 }
412 Ok(path)
413}
414
415pub fn create_session(session: &str, program: &str, args: &[String], work_dir: &str) -> Result<()> {
420 if session_exists(session) {
421 return Err(TmuxError::SessionExists {
422 session: session.to_string(),
423 }
424 .into());
425 }
426
427 let mut cmd = Command::new("tmux");
430 cmd.args(["new-session", "-d", "-s", session, "-c", work_dir]);
431 cmd.args(["-x", "220", "-y", "50"]);
433 cmd.args(["env", "-u", "CLAUDECODE"]);
437 cmd.arg(program);
438 for arg in args {
439 cmd.arg(arg);
440 }
441
442 let output = cmd
443 .output()
444 .with_context(|| format!("failed to create tmux session '{session}'"))?;
445
446 if !output.status.success() {
447 let stderr = String::from_utf8_lossy(&output.stderr);
448 return Err(TmuxError::command_failed("new-session", Some(session), &stderr).into());
449 }
450
451 if let Err(e) = set_mouse(session, true) {
452 warn!(
453 session = session,
454 error = %e,
455 "failed to enable tmux mouse mode"
456 );
457 }
458
459 info!(session = session, "tmux session created");
460 Ok(())
461}
462
463pub fn create_window(
465 session: &str,
466 window_name: &str,
467 program: &str,
468 args: &[String],
469 work_dir: &str,
470) -> Result<()> {
471 if !session_exists(session) {
472 bail!("tmux session '{session}' not found");
473 }
474
475 let mut cmd = Command::new("tmux");
476 cmd.args([
477 "new-window",
478 "-d",
479 "-t",
480 session,
481 "-n",
482 window_name,
483 "-c",
484 work_dir,
485 ]);
486 cmd.args(["env", "-u", "CLAUDECODE"]);
489 cmd.arg(program);
490 for arg in args {
491 cmd.arg(arg);
492 }
493
494 let output = cmd
495 .output()
496 .with_context(|| format!("failed to create tmux window '{window_name}'"))?;
497
498 if !output.status.success() {
499 let stderr = String::from_utf8_lossy(&output.stderr);
500 bail!("tmux new-window failed: {stderr}");
501 }
502
503 Ok(())
504}
505
506pub fn rename_window(target: &str, new_name: &str) -> Result<()> {
508 let output = Command::new("tmux")
509 .args(["rename-window", "-t", target, new_name])
510 .output()
511 .with_context(|| format!("failed to rename tmux window target '{target}'"))?;
512
513 if !output.status.success() {
514 let stderr = String::from_utf8_lossy(&output.stderr);
515 bail!("tmux rename-window failed: {stderr}");
516 }
517
518 Ok(())
519}
520
521pub fn select_window(target: &str) -> Result<()> {
523 let output = Command::new("tmux")
524 .args(["select-window", "-t", target])
525 .output()
526 .with_context(|| format!("failed to select tmux window '{target}'"))?;
527
528 if !output.status.success() {
529 let stderr = String::from_utf8_lossy(&output.stderr);
530 bail!("tmux select-window failed: {stderr}");
531 }
532
533 Ok(())
534}
535
536pub fn setup_pipe_pane(target: &str, log_path: &Path) -> Result<()> {
541 if let Some(parent) = log_path.parent() {
543 std::fs::create_dir_all(parent)
544 .with_context(|| format!("failed to create log directory: {}", parent.display()))?;
545 }
546
547 let pipe_cmd = format!("cat >> {}", log_path.display());
548 let output = Command::new("tmux")
549 .args(["pipe-pane", "-t", target, &pipe_cmd])
550 .output()
551 .with_context(|| format!("failed to set up pipe-pane for target '{target}'"))?;
552
553 if !output.status.success() {
554 let stderr = String::from_utf8_lossy(&output.stderr);
555 bail!("tmux pipe-pane failed: {stderr}");
556 }
557
558 info!(target = target, log = %log_path.display(), "pipe-pane configured");
559 Ok(())
560}
561
562pub fn setup_pipe_pane_if_missing(target: &str, log_path: &Path) -> Result<()> {
564 if let Some(parent) = log_path.parent() {
565 std::fs::create_dir_all(parent)
566 .with_context(|| format!("failed to create log directory: {}", parent.display()))?;
567 }
568
569 let pipe_cmd = format!("cat >> {}", log_path.display());
570 let output = Command::new("tmux")
571 .args(["pipe-pane", "-o", "-t", target, &pipe_cmd])
572 .output()
573 .with_context(|| format!("failed to set up pipe-pane (-o) for target '{target}'"))?;
574
575 if !output.status.success() {
576 let stderr = String::from_utf8_lossy(&output.stderr);
577 bail!("tmux pipe-pane -o failed: {stderr}");
578 }
579
580 info!(
581 target = target,
582 log = %log_path.display(),
583 "pipe-pane ensured (only-if-missing)"
584 );
585 Ok(())
586}
587
588pub fn attach(session: &str) -> Result<()> {
592 if !session_exists(session) {
593 bail!(
594 "tmux session '{session}' not found — is batty running? \
595 Start with `batty start` first"
596 );
597 }
598
599 let inside_tmux = std::env::var("TMUX").is_ok();
600
601 let (cmd, args) = if inside_tmux {
602 ("switch-client", vec!["-t", session])
603 } else {
604 ("attach-session", vec!["-t", session])
605 };
606
607 let status = Command::new("tmux")
608 .arg(cmd)
609 .args(&args)
610 .status()
611 .with_context(|| format!("failed to {cmd} to tmux session '{session}'"))?;
612
613 if !status.success() {
614 bail!("tmux {cmd} to '{session}' failed");
615 }
616
617 Ok(())
618}
619
620pub fn send_keys(target: &str, keys: &str, press_enter: bool) -> Result<()> {
625 if !keys.is_empty() {
626 let output = Command::new("tmux")
629 .args(["send-keys", "-t", target, "-l", "--", keys])
630 .output()
631 .with_context(|| format!("failed to send keys to target '{target}'"))?;
632
633 if !output.status.success() {
634 let stderr = String::from_utf8_lossy(&output.stderr);
635 return Err(TmuxError::command_failed("send-keys", Some(target), &stderr).into());
636 }
637 }
638
639 if press_enter {
640 if !keys.is_empty() {
643 std::thread::sleep(std::time::Duration::from_millis(SEND_KEYS_SUBMIT_DELAY_MS));
644 }
645
646 let output = Command::new("tmux")
647 .args(["send-keys", "-t", target, "Enter"])
648 .output()
649 .with_context(|| format!("failed to send Enter to target '{target}'"))?;
650
651 if !output.status.success() {
652 let stderr = String::from_utf8_lossy(&output.stderr);
653 return Err(TmuxError::command_failed("send-keys Enter", Some(target), &stderr).into());
654 }
655 }
656
657 debug!(target = target, keys = keys, "sent keys");
658 Ok(())
659}
660
661pub fn list_sessions_with_prefix(prefix: &str) -> Vec<String> {
663 let output = Command::new("tmux")
664 .args(["list-sessions", "-F", "#{session_name}"])
665 .output();
666
667 match output {
668 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
669 .lines()
670 .filter(|name| name.starts_with(prefix))
671 .map(|s| s.to_string())
672 .collect(),
673 _ => Vec::new(),
674 }
675}
676
677pub struct TestSession {
682 name: String,
683}
684
685impl TestSession {
686 pub fn new(name: impl Into<String>) -> Self {
688 Self { name: name.into() }
689 }
690
691 pub fn name(&self) -> &str {
693 &self.name
694 }
695}
696
697impl Drop for TestSession {
698 fn drop(&mut self) {
699 let _ = kill_session(&self.name);
700 }
701}
702
703pub fn kill_session(session: &str) -> Result<()> {
705 if !session_exists(session) {
706 return Ok(()); }
708
709 let output = Command::new("tmux")
710 .args(["kill-session", "-t", session])
711 .output()
712 .with_context(|| format!("failed to kill tmux session '{session}'"))?;
713
714 if !output.status.success() {
715 let stderr = String::from_utf8_lossy(&output.stderr);
716 bail!("tmux kill-session failed: {stderr}");
717 }
718
719 info!(session = session, "tmux session killed");
720 Ok(())
721}
722
723pub fn capture_pane(target: &str) -> Result<String> {
728 capture_pane_recent(target, 0)
729}
730
731pub fn capture_pane_recent(target: &str, lines: u32) -> Result<String> {
733 let mut args = vec![
734 "capture-pane".to_string(),
735 "-t".to_string(),
736 target.to_string(),
737 "-p".to_string(),
738 ];
739 if lines > 0 {
740 args.push("-S".to_string());
741 args.push(format!("-{lines}"));
742 }
743
744 let output = Command::new("tmux")
745 .args(&args)
746 .output()
747 .with_context(|| format!("failed to capture pane for target '{target}'"))?;
748
749 if !output.status.success() {
750 let stderr = String::from_utf8_lossy(&output.stderr);
751 return Err(TmuxError::command_failed("capture-pane", Some(target), &stderr).into());
752 }
753
754 Ok(String::from_utf8_lossy(&output.stdout).to_string())
755}
756
757pub fn set_mouse(session: &str, enabled: bool) -> Result<()> {
759 let value = if enabled { "on" } else { "off" };
760 tmux_set(session, "mouse", value)
761}
762
763fn bind_supervisor_hotkey(session: &str, key: &str, action: &str) -> Result<()> {
764 let output = Command::new("tmux")
765 .args([
766 "bind-key",
767 "-T",
768 "prefix",
769 key,
770 "set-option",
771 "-t",
772 session,
773 SUPERVISOR_CONTROL_OPTION,
774 action,
775 ])
776 .output()
777 .with_context(|| format!("failed to bind supervisor hotkey '{key}' for '{session}'"))?;
778
779 if !output.status.success() {
780 let stderr = String::from_utf8_lossy(&output.stderr);
781 bail!("tmux bind-key {key} failed: {stderr}");
782 }
783
784 Ok(())
785}
786
787pub fn configure_supervisor_hotkeys(session: &str) -> Result<()> {
791 tmux_set(session, SUPERVISOR_CONTROL_OPTION, "")?;
792 bind_supervisor_hotkey(session, "P", "pause")?;
793 bind_supervisor_hotkey(session, "R", "resume")?;
794 Ok(())
795}
796
797pub fn take_supervisor_hotkey_action(session: &str) -> Result<Option<String>> {
801 let output = Command::new("tmux")
802 .args([
803 "show-options",
804 "-v",
805 "-t",
806 session,
807 SUPERVISOR_CONTROL_OPTION,
808 ])
809 .output()
810 .with_context(|| {
811 format!("failed to read supervisor control option for session '{session}'")
812 })?;
813
814 if !output.status.success() {
815 let stderr = String::from_utf8_lossy(&output.stderr);
816 bail!("tmux show-options supervisor control failed: {stderr}");
817 }
818
819 let action = String::from_utf8_lossy(&output.stdout).trim().to_string();
820 if action.is_empty() {
821 return Ok(None);
822 }
823
824 tmux_set(session, SUPERVISOR_CONTROL_OPTION, "")?;
825 Ok(Some(action))
826}
827
828#[cfg(test)]
832pub fn list_panes(session: &str) -> Result<Vec<String>> {
833 let output = Command::new("tmux")
834 .args(["list-panes", "-t", session, "-F", "#{pane_id}"])
835 .output()
836 .with_context(|| format!("failed to list panes for session '{session}'"))?;
837
838 if !output.status.success() {
839 let stderr = String::from_utf8_lossy(&output.stderr);
840 bail!("tmux list-panes failed: {stderr}");
841 }
842
843 let panes = String::from_utf8_lossy(&output.stdout)
844 .lines()
845 .map(|s| s.to_string())
846 .collect();
847
848 Ok(panes)
849}
850
851#[cfg(test)]
852fn list_window_names(session: &str) -> Result<Vec<String>> {
853 let output = Command::new("tmux")
854 .args(["list-windows", "-t", session, "-F", "#{window_name}"])
855 .output()
856 .with_context(|| format!("failed to list windows for session '{session}'"))?;
857
858 if !output.status.success() {
859 let stderr = String::from_utf8_lossy(&output.stderr);
860 bail!("tmux list-windows failed: {stderr}");
861 }
862
863 Ok(String::from_utf8_lossy(&output.stdout)
864 .lines()
865 .map(|line| line.trim().to_string())
866 .filter(|line| !line.is_empty())
867 .collect())
868}
869
870pub fn list_pane_details(session: &str) -> Result<Vec<PaneDetails>> {
872 let output = Command::new("tmux")
873 .args([
874 "list-panes",
875 "-t",
876 session,
877 "-F",
878 "#{pane_id}\t#{pane_current_command}\t#{pane_active}\t#{pane_dead}",
879 ])
880 .output()
881 .with_context(|| format!("failed to list pane details for session '{session}'"))?;
882
883 if !output.status.success() {
884 let stderr = String::from_utf8_lossy(&output.stderr);
885 bail!("tmux list-panes details failed: {stderr}");
886 }
887
888 let mut panes = Vec::new();
889 for line in String::from_utf8_lossy(&output.stdout).lines() {
890 let mut parts = line.split('\t');
891 let Some(id) = parts.next() else { continue };
892 let Some(command) = parts.next() else {
893 continue;
894 };
895 let Some(active) = parts.next() else { continue };
896 let Some(dead) = parts.next() else { continue };
897 panes.push(PaneDetails {
898 id: id.to_string(),
899 command: command.to_string(),
900 active: active == "1",
901 dead: dead == "1",
902 });
903 }
904
905 Ok(panes)
906}
907
908pub fn split_window_horizontal(target_pane: &str, size_pct: u32) -> Result<String> {
913 let size = format!("{size_pct}%");
914 let output = Command::new("tmux")
915 .args([
916 "split-window",
917 "-h",
918 "-t",
919 target_pane,
920 "-l",
921 &size,
922 "-P",
923 "-F",
924 "#{pane_id}",
925 ])
926 .output()
927 .with_context(|| format!("failed to split pane '{target_pane}' horizontally"))?;
928
929 if !output.status.success() {
930 let stderr = String::from_utf8_lossy(&output.stderr);
931 bail!("tmux split-window -h failed: {stderr}");
932 }
933
934 let pane_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
935 debug!(target_pane, pane_id = %pane_id, size_pct, "horizontal split created");
936 Ok(pane_id)
937}
938
939pub fn split_window_vertical_in_pane(
943 _session: &str,
944 pane_id: &str,
945 size_pct: u32,
946) -> Result<String> {
947 let size = format!("{size_pct}%");
949 let output = Command::new("tmux")
950 .args([
951 "split-window",
952 "-v",
953 "-t",
954 pane_id,
955 "-l",
956 &size,
957 "-P",
958 "-F",
959 "#{pane_id}",
960 ])
961 .output()
962 .with_context(|| format!("failed to split pane '{pane_id}' vertically"))?;
963
964 if !output.status.success() {
965 let stderr = String::from_utf8_lossy(&output.stderr);
966 bail!("tmux split-window -v failed for pane '{pane_id}': {stderr}");
967 }
968
969 let new_pane = String::from_utf8_lossy(&output.stdout).trim().to_string();
970 debug!(pane_id = %new_pane, parent = pane_id, size_pct, "vertical split created");
971 Ok(new_pane)
972}
973
974pub fn select_layout_even(target_pane: &str) -> Result<()> {
976 let output = Command::new("tmux")
977 .args(["select-layout", "-E", "-t", target_pane])
978 .output()
979 .with_context(|| format!("failed to even layout for pane '{target_pane}'"))?;
980
981 if !output.status.success() {
982 let stderr = String::from_utf8_lossy(&output.stderr);
983 bail!("tmux select-layout -E failed: {stderr}");
984 }
985
986 Ok(())
987}
988
989const BATTY_BUFFER_NAME: &str = "batty-inject";
992
993pub fn load_buffer(content: &str) -> Result<()> {
998 let tmp = std::env::temp_dir().join(format!("batty-buf-{}", std::process::id()));
999 std::fs::write(&tmp, content)
1000 .with_context(|| format!("failed to write buffer file {}", tmp.display()))?;
1001
1002 let output = Command::new("tmux")
1003 .args([
1004 "load-buffer",
1005 "-b",
1006 BATTY_BUFFER_NAME,
1007 &tmp.to_string_lossy(),
1008 ])
1009 .output()
1010 .context("failed to run tmux load-buffer")?;
1011
1012 let _ = std::fs::remove_file(&tmp);
1013
1014 if !output.status.success() {
1015 let stderr = String::from_utf8_lossy(&output.stderr);
1016 bail!("tmux load-buffer failed: {stderr}");
1017 }
1018
1019 Ok(())
1020}
1021
1022pub fn paste_buffer(target: &str) -> Result<()> {
1027 let output = Command::new("tmux")
1028 .args(["paste-buffer", "-d", "-b", BATTY_BUFFER_NAME, "-t", target])
1029 .output()
1030 .with_context(|| format!("failed to paste buffer into '{target}'"))?;
1031
1032 if !output.status.success() {
1033 let stderr = String::from_utf8_lossy(&output.stderr);
1034 bail!("tmux paste-buffer failed: {stderr}");
1035 }
1036
1037 Ok(())
1038}
1039
1040pub fn kill_pane(target: &str) -> Result<()> {
1042 let output = Command::new("tmux")
1043 .args(["kill-pane", "-t", target])
1044 .output()
1045 .with_context(|| format!("failed to kill pane '{target}'"))?;
1046
1047 if !output.status.success() {
1048 let stderr = String::from_utf8_lossy(&output.stderr);
1049 if !stderr.contains("not found") {
1051 bail!("tmux kill-pane failed: {stderr}");
1052 }
1053 }
1054
1055 Ok(())
1056}
1057
1058pub fn respawn_pane(target: &str, command: &str) -> Result<()> {
1060 let output = Command::new("tmux")
1061 .args(["respawn-pane", "-t", target, "-k", command])
1062 .output()
1063 .with_context(|| format!("failed to respawn pane '{target}'"))?;
1064
1065 if !output.status.success() {
1066 let stderr = String::from_utf8_lossy(&output.stderr);
1067 bail!("tmux respawn-pane failed: {stderr}");
1068 }
1069
1070 Ok(())
1071}
1072
1073pub fn tmux_set(session: &str, option: &str, value: &str) -> Result<()> {
1075 let output = Command::new("tmux")
1076 .args(["set", "-t", session, option, value])
1077 .output()
1078 .with_context(|| format!("failed to set tmux option '{option}' for session '{session}'"))?;
1079
1080 if !output.status.success() {
1081 let stderr = String::from_utf8_lossy(&output.stderr);
1082 bail!("tmux set {option} failed: {stderr}");
1083 }
1084
1085 Ok(())
1086}
1087
1088#[cfg(test)]
1097mod tests {
1098 use super::*;
1099 use serial_test::serial;
1100
1101 #[test]
1102 fn session_name_convention() {
1103 assert_eq!(session_name("phase-1"), "batty-phase-1");
1104 assert_eq!(session_name("phase-2"), "batty-phase-2");
1105 assert_eq!(session_name("phase-2.5"), "batty-phase-2-5");
1106 assert_eq!(session_name("phase 3"), "batty-phase-3");
1107 }
1108
1109 #[test]
1110 #[serial]
1111 #[cfg_attr(not(feature = "integration"), ignore)]
1112 fn check_tmux_finds_binary() {
1113 let version = check_tmux().unwrap();
1114 assert!(
1115 version.starts_with("tmux"),
1116 "expected tmux version, got: {version}"
1117 );
1118 }
1119
1120 #[test]
1121 fn parse_tmux_version_supports_minor_suffixes() {
1122 assert_eq!(parse_tmux_version("tmux 3.4"), Some((3, 4)));
1123 assert_eq!(parse_tmux_version("tmux 3.3a"), Some((3, 3)));
1124 assert_eq!(parse_tmux_version("tmux 2.9"), Some((2, 9)));
1125 assert_eq!(parse_tmux_version("tmux unknown"), None);
1126 }
1127
1128 #[test]
1129 fn capabilities_known_good_matrix() {
1130 let good = TmuxCapabilities {
1131 version_raw: "tmux 3.2".to_string(),
1132 version: Some((3, 2)),
1133 pipe_pane: true,
1134 pipe_pane_only_if_missing: true,
1135 status_style: true,
1136 split_mode: SplitMode::Lines,
1137 };
1138 assert!(good.known_good());
1139
1140 let fallback = TmuxCapabilities {
1141 version_raw: "tmux 3.1".to_string(),
1142 version: Some((3, 1)),
1143 pipe_pane: true,
1144 pipe_pane_only_if_missing: false,
1145 status_style: true,
1146 split_mode: SplitMode::Percent,
1147 };
1148 assert!(!fallback.known_good());
1149 }
1150
1151 #[test]
1152 #[serial]
1153 #[cfg_attr(not(feature = "integration"), ignore)]
1154 fn capability_probe_reports_pipe_pane() {
1155 let caps = probe_capabilities().unwrap();
1156 assert!(
1157 caps.pipe_pane,
1158 "pipe-pane should be available for batty runtime"
1159 );
1160 }
1161
1162 #[test]
1163 #[serial]
1164 #[cfg_attr(not(feature = "integration"), ignore)]
1165 fn nonexistent_session_does_not_exist() {
1166 assert!(!session_exists("batty-test-nonexistent-12345"));
1167 }
1168
1169 #[test]
1170 #[serial]
1171 fn create_and_kill_session() {
1172 let session = "batty-test-lifecycle";
1173 let _ = kill_session(session);
1175
1176 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1178 assert!(session_exists(session));
1179
1180 kill_session(session).unwrap();
1182 assert!(!session_exists(session));
1183 }
1184
1185 #[test]
1186 #[serial]
1187 #[cfg_attr(not(feature = "integration"), ignore)]
1188 fn session_path_returns_working_directory() {
1189 let session = "batty-test-session-path";
1190 let _ = kill_session(session);
1191
1192 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1193 let path = session_path(session).unwrap();
1194 assert_eq!(path, "/tmp");
1195
1196 kill_session(session).unwrap();
1197 }
1198
1199 #[test]
1200 #[serial]
1201 #[cfg_attr(not(feature = "integration"), ignore)]
1202 fn pane_current_path_returns_working_directory() {
1203 let session = "batty-test-pane-current-path";
1204 let _ = kill_session(session);
1205
1206 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1207 let pane = pane_id(session).unwrap();
1208 let path = pane_current_path(&pane).unwrap();
1209 assert_eq!(
1210 std::fs::canonicalize(&path).unwrap(),
1211 std::fs::canonicalize("/tmp").unwrap()
1212 );
1213
1214 kill_session(session).unwrap();
1215 }
1216
1217 #[test]
1218 #[serial]
1219 #[cfg_attr(not(feature = "integration"), ignore)]
1220 fn duplicate_session_is_error() {
1221 let session = "batty-test-dup";
1222 let _ = kill_session(session);
1223
1224 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1225
1226 let result = create_session(session, "sleep", &["10".to_string()], "/tmp");
1227 assert!(result.is_err());
1228 assert!(result.unwrap_err().to_string().contains("already exists"));
1229
1230 kill_session(session).unwrap();
1231 }
1232
1233 #[test]
1234 #[serial]
1235 #[cfg_attr(not(feature = "integration"), ignore)]
1236 fn create_window_adds_named_window_to_existing_session() {
1237 let session = "batty-test-window-create";
1238 let _ = kill_session(session);
1239
1240 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1241 rename_window(&format!("{session}:0"), "agent-1").unwrap();
1242 create_window(session, "agent-2", "sleep", &["10".to_string()], "/tmp").unwrap();
1243
1244 let names = list_window_names(session).unwrap();
1245 assert!(names.contains(&"agent-1".to_string()));
1246 assert!(names.contains(&"agent-2".to_string()));
1247
1248 select_window(&format!("{session}:agent-1")).unwrap();
1249 kill_session(session).unwrap();
1250 }
1251
1252 #[test]
1253 #[serial]
1254 #[cfg_attr(not(feature = "integration"), ignore)]
1255 fn create_window_unsets_claudecode_from_session_environment() {
1256 let session = "batty-test-window-unset-claudecode";
1257 let _ = kill_session(session);
1258
1259 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1260
1261 let output = Command::new("tmux")
1262 .args(["set-environment", "-t", session, "CLAUDECODE", "1"])
1263 .output()
1264 .unwrap();
1265 assert!(
1266 output.status.success(),
1267 "failed to set CLAUDECODE in tmux session: {}",
1268 String::from_utf8_lossy(&output.stderr)
1269 );
1270
1271 create_window(
1272 session,
1273 "env-check",
1274 "bash",
1275 &[
1276 "-lc".to_string(),
1277 "printf '%s' \"${CLAUDECODE:-unset}\"; sleep 1".to_string(),
1278 ],
1279 "/tmp",
1280 )
1281 .unwrap();
1282
1283 std::thread::sleep(std::time::Duration::from_millis(300));
1284
1285 let content = capture_pane(&format!("{session}:env-check")).unwrap();
1286 assert!(
1287 content.contains("unset"),
1288 "expected CLAUDECODE to be unset in new window, got: {content:?}"
1289 );
1290
1291 kill_session(session).unwrap();
1292 }
1293
1294 #[test]
1295 #[serial]
1296 #[cfg_attr(not(feature = "integration"), ignore)]
1297 fn create_session_enables_mouse_mode() {
1298 let session = "batty-test-mouse";
1299 let _ = kill_session(session);
1300
1301 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1302
1303 let output = Command::new("tmux")
1304 .args(["show-options", "-t", session, "-v", "mouse"])
1305 .output()
1306 .unwrap();
1307 assert!(output.status.success());
1308 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1309 assert_eq!(value, "on", "expected tmux mouse mode to be enabled");
1310
1311 kill_session(session).unwrap();
1312 }
1313
1314 #[test]
1315 #[serial]
1316 #[cfg_attr(not(feature = "integration"), ignore)]
1317 fn send_keys_to_session() {
1318 let session = "batty-test-sendkeys";
1319 let _ = kill_session(session);
1320
1321 create_session(session, "cat", &[], "/tmp").unwrap();
1323
1324 std::thread::sleep(std::time::Duration::from_millis(200));
1326
1327 send_keys(session, "hello", true).unwrap();
1329
1330 kill_session(session).unwrap();
1332 }
1333
1334 #[test]
1335 #[serial]
1336 #[cfg_attr(not(feature = "integration"), ignore)]
1337 fn send_keys_with_enter_submits_line() {
1338 let session = "batty-test-sendkeys-enter";
1339 let _ = kill_session(session);
1340
1341 let tmp = tempfile::tempdir().unwrap();
1342 let log_path = tmp.path().join("sendkeys.log");
1343
1344 create_session(session, "cat", &[], "/tmp").unwrap();
1345 setup_pipe_pane(session, &log_path).unwrap();
1346 std::thread::sleep(std::time::Duration::from_millis(200));
1347
1348 send_keys(session, "supervisor ping", true).unwrap();
1349 std::thread::sleep(std::time::Duration::from_millis(300));
1350
1351 let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1352 assert!(
1353 content.contains("supervisor ping"),
1354 "expected injected text in pane log, got: {content:?}"
1355 );
1356 assert!(
1357 content.contains("supervisor ping\r\n") || content.contains("supervisor ping\n"),
1358 "expected submitted line ending in pane log, got: {content:?}"
1359 );
1360
1361 kill_session(session).unwrap();
1362 }
1363
1364 #[test]
1365 #[serial]
1366 #[cfg_attr(not(feature = "integration"), ignore)]
1367 fn send_keys_enter_only_submits_prompt() {
1368 let session = "batty-test-sendkeys-enter-only";
1369 let _ = kill_session(session);
1370
1371 let tmp = tempfile::tempdir().unwrap();
1372 let log_path = tmp.path().join("sendkeys-enter-only.log");
1373
1374 create_session(session, "cat", &[], "/tmp").unwrap();
1375 let pane = pane_id(session).unwrap();
1376 setup_pipe_pane(&pane, &log_path).unwrap();
1377 std::thread::sleep(std::time::Duration::from_millis(200));
1378
1379 send_keys(&pane, "", true).unwrap();
1380 std::thread::sleep(std::time::Duration::from_millis(300));
1381
1382 let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1383 assert!(
1384 content.contains("\r\n") || content.contains('\n'),
1385 "expected submitted empty line in pane log, got: {content:?}"
1386 );
1387
1388 kill_session(session).unwrap();
1389 }
1390
1391 #[test]
1392 #[serial]
1393 #[cfg_attr(not(feature = "integration"), ignore)]
1394 fn pipe_pane_captures_output() {
1395 let session = "batty-test-pipe";
1396 let _ = kill_session(session);
1397
1398 let tmp = tempfile::tempdir().unwrap();
1399 let log_path = tmp.path().join("pty-output.log");
1400
1401 create_session(session, "bash", &[], "/tmp").unwrap();
1403 let pane = pane_id(session).unwrap();
1404
1405 setup_pipe_pane(&pane, &log_path).unwrap();
1407
1408 std::thread::sleep(std::time::Duration::from_millis(200));
1410
1411 send_keys(&pane, "echo pipe-test-output", true).unwrap();
1413
1414 let mut found = false;
1416 for _ in 0..10 {
1417 std::thread::sleep(std::time::Duration::from_millis(200));
1418 if log_path.exists() {
1419 let content = std::fs::read_to_string(&log_path).unwrap_or_default();
1420 if !content.is_empty() {
1421 found = true;
1422 break;
1423 }
1424 }
1425 }
1426
1427 kill_session(session).unwrap();
1428 assert!(found, "pipe-pane log should have captured output");
1429 }
1430
1431 #[test]
1432 #[serial]
1433 #[cfg_attr(not(feature = "integration"), ignore)]
1434 fn capture_pane_returns_content() {
1435 let session = "batty-test-capture";
1436 let _ = kill_session(session);
1437
1438 create_session(
1439 session,
1440 "bash",
1441 &["-c".to_string(), "echo 'capture-test'; sleep 2".to_string()],
1442 "/tmp",
1443 )
1444 .unwrap();
1445 std::thread::sleep(std::time::Duration::from_millis(500));
1446
1447 let content = capture_pane(session).unwrap();
1448 assert!(
1450 !content.trim().is_empty(),
1451 "capture-pane should return content"
1452 );
1453
1454 kill_session(session).unwrap();
1455 }
1456
1457 #[test]
1458 #[serial]
1459 #[cfg_attr(not(feature = "integration"), ignore)]
1460 fn capture_pane_recent_returns_content() {
1461 let session = "batty-test-capture-recent";
1462 let _ = kill_session(session);
1463
1464 create_session(
1465 session,
1466 "bash",
1467 &[
1468 "-c".to_string(),
1469 "echo 'capture-recent-test'; sleep 2".to_string(),
1470 ],
1471 "/tmp",
1472 )
1473 .unwrap();
1474 std::thread::sleep(std::time::Duration::from_millis(500));
1475
1476 let content = capture_pane_recent(session, 10).unwrap();
1477 assert!(content.contains("capture-recent-test"));
1478
1479 kill_session(session).unwrap();
1480 }
1481
1482 #[test]
1483 #[serial]
1484 #[cfg_attr(not(feature = "integration"), ignore)]
1485 fn list_panes_returns_at_least_one() {
1486 let session = "batty-test-panes";
1487 let _ = kill_session(session);
1488
1489 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1490
1491 let panes = list_panes(session).unwrap();
1492 assert!(!panes.is_empty(), "session should have at least one pane");
1493
1494 kill_session(session).unwrap();
1495 }
1496
1497 #[test]
1498 #[serial]
1499 #[cfg_attr(not(feature = "integration"), ignore)]
1500 fn list_pane_details_includes_active_flag() {
1501 let session = "batty-test-pane-details";
1502 let _ = kill_session(session);
1503
1504 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1505
1506 let panes = list_pane_details(session).unwrap();
1507 assert!(
1508 !panes.is_empty(),
1509 "expected at least one pane detail record"
1510 );
1511 assert!(
1512 panes.iter().any(|p| p.active),
1513 "expected one active pane, got: {panes:?}"
1514 );
1515
1516 kill_session(session).unwrap();
1517 }
1518
1519 #[test]
1520 #[serial]
1521 #[cfg_attr(not(feature = "integration"), ignore)]
1522 fn configure_supervisor_hotkeys_initializes_control_option() {
1523 let session = "batty-test-supervisor-hotkeys";
1524 let _ = kill_session(session);
1525
1526 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1527 configure_supervisor_hotkeys(session).unwrap();
1528
1529 let output = Command::new("tmux")
1530 .args([
1531 "show-options",
1532 "-v",
1533 "-t",
1534 session,
1535 SUPERVISOR_CONTROL_OPTION,
1536 ])
1537 .output()
1538 .unwrap();
1539 assert!(output.status.success());
1540 assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
1541
1542 kill_session(session).unwrap();
1543 }
1544
1545 #[test]
1546 #[serial]
1547 #[cfg_attr(not(feature = "integration"), ignore)]
1548 fn take_supervisor_hotkey_action_reads_and_clears() {
1549 let session = "batty-test-supervisor-action";
1550 let _ = kill_session(session);
1551
1552 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1553 configure_supervisor_hotkeys(session).unwrap();
1554 tmux_set(session, SUPERVISOR_CONTROL_OPTION, "pause").unwrap();
1555
1556 let first = take_supervisor_hotkey_action(session).unwrap();
1557 assert_eq!(first.as_deref(), Some("pause"));
1558
1559 let second = take_supervisor_hotkey_action(session).unwrap();
1560 assert!(second.is_none(), "expected action to be cleared");
1561
1562 kill_session(session).unwrap();
1563 }
1564
1565 #[test]
1566 #[serial]
1567 #[cfg_attr(not(feature = "integration"), ignore)]
1568 fn kill_nonexistent_session_is_ok() {
1569 kill_session("batty-test-nonexistent-kill-99999").unwrap();
1571 }
1572
1573 #[test]
1574 #[serial]
1575 #[cfg_attr(not(feature = "integration"), ignore)]
1576 fn session_with_short_lived_process() {
1577 let session = "batty-test-shortlived";
1578 let _ = kill_session(session);
1579
1580 create_session(session, "echo", &["done".to_string()], "/tmp").unwrap();
1582
1583 std::thread::sleep(std::time::Duration::from_millis(500));
1585
1586 let _ = kill_session(session);
1589 }
1590
1591 #[test]
1592 #[serial]
1593 #[cfg_attr(not(feature = "integration"), ignore)]
1594 fn test_session_guard_cleanup_on_drop() {
1595 let name = "batty-test-guard-drop";
1596 let _ = kill_session(name);
1597
1598 {
1599 let guard = TestSession::new(name);
1600 create_session(guard.name(), "sleep", &["30".to_string()], "/tmp").unwrap();
1601 assert!(session_exists(name));
1602 }
1604
1605 assert!(!session_exists(name), "session should be killed on drop");
1606 }
1607
1608 #[test]
1609 #[serial]
1610 #[cfg_attr(not(feature = "integration"), ignore)]
1611 fn test_session_guard_cleanup_on_panic() {
1612 let name = "batty-test-guard-panic";
1613 let _ = kill_session(name);
1614
1615 let result = std::panic::catch_unwind(|| {
1616 let guard = TestSession::new(name);
1617 create_session(guard.name(), "sleep", &["30".to_string()], "/tmp").unwrap();
1618 assert!(session_exists(name));
1619 panic!("intentional panic to test cleanup");
1620 #[allow(unreachable_code)]
1621 drop(guard);
1622 });
1623
1624 assert!(result.is_err(), "should have panicked");
1625 assert!(
1627 !session_exists(name),
1628 "session should be cleaned up even after panic"
1629 );
1630 }
1631
1632 #[test]
1635 fn parse_tmux_version_empty_string() {
1636 assert_eq!(parse_tmux_version(""), None);
1637 }
1638
1639 #[test]
1640 fn parse_tmux_version_no_prefix() {
1641 assert_eq!(parse_tmux_version("3.4"), None);
1643 }
1644
1645 #[test]
1646 fn parse_tmux_version_major_only_no_dot() {
1647 assert_eq!(parse_tmux_version("tmux 3"), None);
1648 }
1649
1650 #[test]
1651 fn parse_tmux_version_multi_digit() {
1652 assert_eq!(parse_tmux_version("tmux 10.12"), Some((10, 12)));
1653 }
1654
1655 #[test]
1656 fn parse_tmux_version_trailing_whitespace() {
1657 assert_eq!(parse_tmux_version(" tmux 3.4 "), Some((3, 4)));
1658 }
1659
1660 #[test]
1661 fn parse_tmux_version_next_suffix() {
1662 assert_eq!(parse_tmux_version("tmux next-3.5"), None);
1664 }
1665
1666 #[test]
1667 fn parse_tmux_version_dot_no_minor() {
1668 assert_eq!(parse_tmux_version("tmux 3."), None);
1669 }
1670
1671 #[test]
1672 fn parse_tmux_version_double_suffix_letters() {
1673 assert_eq!(parse_tmux_version("tmux 3.3ab"), Some((3, 3)));
1674 }
1675
1676 #[test]
1679 fn capabilities_known_good_version_4() {
1680 let caps = TmuxCapabilities {
1681 version_raw: "tmux 4.0".to_string(),
1682 version: Some((4, 0)),
1683 pipe_pane: true,
1684 pipe_pane_only_if_missing: true,
1685 status_style: true,
1686 split_mode: SplitMode::Lines,
1687 };
1688 assert!(caps.known_good(), "4.0 should be known good");
1689 }
1690
1691 #[test]
1692 fn capabilities_known_good_version_2_9() {
1693 let caps = TmuxCapabilities {
1694 version_raw: "tmux 2.9".to_string(),
1695 version: Some((2, 9)),
1696 pipe_pane: true,
1697 pipe_pane_only_if_missing: false,
1698 status_style: true,
1699 split_mode: SplitMode::Percent,
1700 };
1701 assert!(!caps.known_good(), "2.9 should not be known good");
1702 }
1703
1704 #[test]
1705 fn capabilities_known_good_version_3_0() {
1706 let caps = TmuxCapabilities {
1707 version_raw: "tmux 3.0".to_string(),
1708 version: Some((3, 0)),
1709 pipe_pane: true,
1710 pipe_pane_only_if_missing: false,
1711 status_style: true,
1712 split_mode: SplitMode::Percent,
1713 };
1714 assert!(!caps.known_good(), "3.0 should not be known good");
1715 }
1716
1717 #[test]
1718 fn capabilities_known_good_none_version() {
1719 let caps = TmuxCapabilities {
1720 version_raw: "tmux unknown".to_string(),
1721 version: None,
1722 pipe_pane: false,
1723 pipe_pane_only_if_missing: false,
1724 status_style: false,
1725 split_mode: SplitMode::Disabled,
1726 };
1727 assert!(!caps.known_good(), "None version should not be known good");
1728 }
1729
1730 #[test]
1731 fn capabilities_remediation_message_includes_version() {
1732 let caps = TmuxCapabilities {
1733 version_raw: "tmux 2.8".to_string(),
1734 version: Some((2, 8)),
1735 pipe_pane: false,
1736 pipe_pane_only_if_missing: false,
1737 status_style: false,
1738 split_mode: SplitMode::Disabled,
1739 };
1740 let msg = caps.remediation_message();
1741 assert!(
1742 msg.contains("tmux 2.8"),
1743 "message should include detected version"
1744 );
1745 assert!(
1746 msg.contains("pipe-pane"),
1747 "message should mention pipe-pane requirement"
1748 );
1749 assert!(msg.contains("3.2"), "message should recommend >= 3.2");
1750 }
1751
1752 #[test]
1755 fn session_name_empty_input() {
1756 assert_eq!(session_name(""), "batty-");
1757 }
1758
1759 #[test]
1760 fn session_name_preserves_underscores() {
1761 assert_eq!(session_name("my_session"), "batty-my_session");
1762 }
1763
1764 #[test]
1765 fn session_name_replaces_colons_and_slashes() {
1766 assert_eq!(session_name("a:b/c"), "batty-a-b-c");
1767 }
1768
1769 #[test]
1770 fn session_name_replaces_multiple_dots() {
1771 assert_eq!(session_name("v1.2.3"), "batty-v1-2-3");
1772 }
1773
1774 #[test]
1777 fn pane_details_clone_and_eq() {
1778 let pd = PaneDetails {
1779 id: "%5".to_string(),
1780 command: "bash".to_string(),
1781 active: true,
1782 dead: false,
1783 };
1784 let cloned = pd.clone();
1785 assert_eq!(pd, cloned);
1786 assert_eq!(pd.id, "%5");
1787 assert!(pd.active);
1788 assert!(!pd.dead);
1789 }
1790
1791 #[test]
1792 fn pane_details_not_equal_different_id() {
1793 let a = PaneDetails {
1794 id: "%1".to_string(),
1795 command: "bash".to_string(),
1796 active: true,
1797 dead: false,
1798 };
1799 let b = PaneDetails {
1800 id: "%2".to_string(),
1801 command: "bash".to_string(),
1802 active: true,
1803 dead: false,
1804 };
1805 assert_ne!(a, b);
1806 }
1807
1808 #[test]
1811 fn split_mode_debug_and_eq() {
1812 assert_eq!(SplitMode::Lines, SplitMode::Lines);
1813 assert_ne!(SplitMode::Lines, SplitMode::Percent);
1814 assert_ne!(SplitMode::Percent, SplitMode::Disabled);
1815 let copied = SplitMode::Lines;
1816 assert_eq!(format!("{:?}", copied), "Lines");
1817 }
1818
1819 #[test]
1822 fn test_session_name_accessor() {
1823 let guard = TestSession::new("batty-test-accessor");
1824 assert_eq!(guard.name(), "batty-test-accessor");
1825 }
1827
1828 #[test]
1831 #[serial]
1832 fn pane_exists_for_valid_pane() {
1833 let session = "batty-test-pane-exists";
1834 let _guard = TestSession::new(session);
1835 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1836
1837 let pane = pane_id(session).unwrap();
1838 assert!(pane_exists(&pane), "existing pane should be found");
1839 }
1840
1841 #[test]
1842 #[serial]
1843 fn session_exists_returns_false_after_kill() {
1844 let session = "batty-test-sess-exists-gone";
1845 let _ = kill_session(session);
1846 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1847 assert!(session_exists(session));
1848
1849 kill_session(session).unwrap();
1850 assert!(
1851 !session_exists(session),
1852 "session should not exist after kill"
1853 );
1854 }
1855
1856 #[test]
1857 #[serial]
1858 fn pane_dead_for_running_process() {
1859 let session = "batty-test-pane-dead-alive";
1860 let _guard = TestSession::new(session);
1861 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1862
1863 let pane = pane_id(session).unwrap();
1864 let dead = pane_dead(&pane).unwrap();
1865 assert!(!dead, "running process pane should not be dead");
1866 }
1867
1868 #[test]
1869 #[serial]
1870 fn list_sessions_with_prefix_finds_matching() {
1871 let prefix = "batty-test-prefix-match";
1872 let s1 = format!("{prefix}-aaa");
1873 let s2 = format!("{prefix}-bbb");
1874 let _g1 = TestSession::new(s1.clone());
1875 let _g2 = TestSession::new(s2.clone());
1876
1877 create_session(&s1, "sleep", &["10".to_string()], "/tmp").unwrap();
1878 create_session(&s2, "sleep", &["10".to_string()], "/tmp").unwrap();
1879
1880 let found = list_sessions_with_prefix(prefix);
1881 assert!(
1882 found.contains(&s1),
1883 "should find first session, got: {found:?}"
1884 );
1885 assert!(
1886 found.contains(&s2),
1887 "should find second session, got: {found:?}"
1888 );
1889 }
1890
1891 #[test]
1892 #[serial]
1893 fn list_sessions_with_prefix_excludes_non_matching() {
1894 let found = list_sessions_with_prefix("batty-test-zzz-nonexist-99999");
1895 assert!(found.is_empty(), "should find no sessions for bogus prefix");
1896 }
1897
1898 #[test]
1899 #[serial]
1900 fn split_window_horizontal_creates_new_pane() {
1901 let session = "batty-test-hsplit";
1902 let _guard = TestSession::new(session);
1903 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1904
1905 let original = pane_id(session).unwrap();
1906 let new_pane = split_window_horizontal(&original, 50).unwrap();
1907 assert!(
1908 new_pane.starts_with('%'),
1909 "new pane id should start with %, got: {new_pane}"
1910 );
1911
1912 let panes = list_panes(session).unwrap();
1913 assert_eq!(panes.len(), 2, "should have 2 panes after split");
1914 assert!(panes.contains(&new_pane));
1915 }
1916
1917 #[test]
1918 #[serial]
1919 fn split_window_vertical_creates_new_pane() {
1920 let session = "batty-test-vsplit";
1921 let _guard = TestSession::new(session);
1922 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1923
1924 let original = pane_id(session).unwrap();
1925 let new_pane = split_window_vertical_in_pane(session, &original, 50).unwrap();
1926 assert!(
1927 new_pane.starts_with('%'),
1928 "new pane id should start with %, got: {new_pane}"
1929 );
1930
1931 let panes = list_panes(session).unwrap();
1932 assert_eq!(panes.len(), 2, "should have 2 panes after split");
1933 assert!(panes.contains(&new_pane));
1934 }
1935
1936 #[test]
1937 #[serial]
1938 fn load_buffer_and_paste_buffer_injects_text() {
1939 let session = "batty-test-paste-buf";
1940 let _guard = TestSession::new(session);
1941 create_session(session, "cat", &[], "/tmp").unwrap();
1942 std::thread::sleep(std::time::Duration::from_millis(200));
1943
1944 let pane = pane_id(session).unwrap();
1945 load_buffer("hello-from-buffer").unwrap();
1946 paste_buffer(&pane).unwrap();
1947
1948 std::thread::sleep(std::time::Duration::from_millis(300));
1949 let live_pane = pane_id(session).unwrap_or(pane);
1950 let content = capture_pane(&live_pane).unwrap();
1951 assert!(
1952 content.contains("hello-from-buffer"),
1953 "paste-buffer should inject text into pane, got: {content:?}"
1954 );
1955 }
1956
1957 #[test]
1958 #[serial]
1959 fn kill_pane_removes_pane() {
1960 let session = "batty-test-kill-pane";
1961 let _guard = TestSession::new(session);
1962 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1963
1964 let original = pane_id(session).unwrap();
1965 let new_pane = split_window_horizontal(&original, 50).unwrap();
1966 let before = list_panes(session).unwrap();
1967 assert_eq!(before.len(), 2);
1968
1969 kill_pane(&new_pane).unwrap();
1970 let after = list_panes(session).unwrap();
1971 assert_eq!(after.len(), 1, "should have 1 pane after kill");
1972 assert!(!after.contains(&new_pane));
1973 }
1974
1975 #[test]
1976 #[serial]
1977 fn kill_pane_nonexistent_returns_error() {
1978 let result = kill_pane("batty-test-no-such-session-xyz:0.0");
1981 let _ = result;
1984 }
1985
1986 #[test]
1987 #[serial]
1988 fn set_mouse_disable_and_enable() {
1989 let session = "batty-test-mouse-toggle";
1990 let _guard = TestSession::new(session);
1991 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
1992
1993 set_mouse(session, false).unwrap();
1995 let output = Command::new("tmux")
1996 .args(["show-options", "-t", session, "-v", "mouse"])
1997 .output()
1998 .unwrap();
1999 assert_eq!(
2000 String::from_utf8_lossy(&output.stdout).trim(),
2001 "off",
2002 "mouse should be disabled"
2003 );
2004
2005 set_mouse(session, true).unwrap();
2007 let output = Command::new("tmux")
2008 .args(["show-options", "-t", session, "-v", "mouse"])
2009 .output()
2010 .unwrap();
2011 assert_eq!(
2012 String::from_utf8_lossy(&output.stdout).trim(),
2013 "on",
2014 "mouse should be re-enabled"
2015 );
2016 }
2017
2018 #[test]
2019 #[serial]
2020 fn tmux_set_custom_option() {
2021 let session = "batty-test-tmux-set";
2022 let _guard = TestSession::new(session);
2023 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2024
2025 tmux_set(session, "@batty_test_opt", "test-value").unwrap();
2026
2027 let output = Command::new("tmux")
2028 .args(["show-options", "-v", "-t", session, "@batty_test_opt"])
2029 .output()
2030 .unwrap();
2031 assert!(output.status.success());
2032 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test-value");
2033 }
2034
2035 #[test]
2036 #[serial]
2037 fn create_window_fails_for_missing_session() {
2038 let result = create_window(
2039 "batty-test-nonexistent-session-99999",
2040 "test-win",
2041 "sleep",
2042 &["1".to_string()],
2043 "/tmp",
2044 );
2045 assert!(result.is_err(), "should fail for nonexistent session");
2046 assert!(
2047 result.unwrap_err().to_string().contains("not found"),
2048 "error should mention session not found"
2049 );
2050 }
2051
2052 #[test]
2053 #[serial]
2054 fn setup_pipe_pane_if_missing_works() {
2055 let session = "batty-test-pipe-if-missing";
2056 let _guard = TestSession::new(session);
2057 let tmp = tempfile::tempdir().unwrap();
2058 let log_path = tmp.path().join("pipe-if-missing.log");
2059
2060 create_session(
2061 session,
2062 "bash",
2063 &["-c".to_string(), "sleep 10".to_string()],
2064 "/tmp",
2065 )
2066 .unwrap();
2067
2068 setup_pipe_pane_if_missing(session, &log_path).unwrap();
2071
2072 setup_pipe_pane_if_missing(session, &log_path).unwrap();
2074 }
2075
2076 #[test]
2077 #[serial]
2078 fn select_layout_even_after_splits() {
2079 let session = "batty-test-layout-even";
2080 let _guard = TestSession::new(session);
2081 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2082
2083 let original = pane_id(session).unwrap();
2084 let _p2 = split_window_horizontal(&original, 50).unwrap();
2085
2086 select_layout_even(&original).unwrap();
2088 }
2089
2090 #[test]
2091 #[serial]
2092 fn rename_window_changes_name() {
2093 let session = "batty-test-rename-win";
2094 let _guard = TestSession::new(session);
2095 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2096
2097 rename_window(&format!("{session}:0"), "custom-name").unwrap();
2098 let names = list_window_names(session).unwrap();
2099 assert!(
2100 names.contains(&"custom-name".to_string()),
2101 "window should be renamed, got: {names:?}"
2102 );
2103 }
2104
2105 #[test]
2106 #[serial]
2107 fn select_window_switches_active() {
2108 let session = "batty-test-select-win";
2109 let _guard = TestSession::new(session);
2110 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2111 create_window(session, "second", "sleep", &["10".to_string()], "/tmp").unwrap();
2112
2113 select_window(&format!("{session}:second")).unwrap();
2115 }
2116
2117 #[test]
2118 #[serial]
2119 fn capture_pane_recent_zero_lines_returns_full() {
2120 let session = "batty-test-capture-zero";
2121 let _guard = TestSession::new(session);
2122 create_session(
2123 session,
2124 "bash",
2125 &[
2126 "-c".to_string(),
2127 "echo 'zero-lines-test'; sleep 2".to_string(),
2128 ],
2129 "/tmp",
2130 )
2131 .unwrap();
2132 std::thread::sleep(std::time::Duration::from_millis(500));
2133
2134 let content = capture_pane_recent(session, 0).unwrap();
2136 assert!(
2137 content.contains("zero-lines-test"),
2138 "should capture full content with lines=0, got: {content:?}"
2139 );
2140 }
2141
2142 #[test]
2143 #[serial]
2144 fn list_pane_details_shows_command_info() {
2145 let session = "batty-test-pane-details-cmd";
2146 let _guard = TestSession::new(session);
2147 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2148
2149 let details = list_pane_details(session).unwrap();
2150 assert_eq!(details.len(), 1);
2151 assert!(details[0].id.starts_with('%'));
2152 assert!(
2153 details[0].command == "sleep" || !details[0].command.is_empty(),
2154 "command should be reported"
2155 );
2156 assert!(!details[0].dead, "sleep pane should not be dead");
2157 }
2158
2159 #[test]
2160 #[serial]
2161 fn pane_id_returns_percent_prefixed() {
2162 let session = "batty-test-paneid-fmt";
2163 let _guard = TestSession::new(session);
2164 create_session(session, "sleep", &["10".to_string()], "/tmp").unwrap();
2165
2166 let id = pane_id(session).unwrap();
2167 assert!(
2168 id.starts_with('%'),
2169 "pane id should start with %, got: {id}"
2170 );
2171 }
2172
2173 #[test]
2174 #[serial]
2175 fn respawn_pane_restarts_running_pane() {
2176 let session = "batty-test-respawn";
2177 let _guard = TestSession::new(session);
2178 create_session(session, "sleep", &["30".to_string()], "/tmp").unwrap();
2180
2181 let pane = pane_id(session).unwrap();
2182 respawn_pane(&pane, "sleep 10").unwrap();
2184 std::thread::sleep(std::time::Duration::from_millis(300));
2185
2186 assert!(pane_exists(&pane), "respawned pane should exist");
2188 }
2189}