1use std::process::{Command, Stdio};
8use std::io::Write as _;
9
10use eyre::{Result, bail};
11
12#[derive(Debug, Clone)]
17pub struct PaneInfo {
18 pub id: String,
19 pub title: String,
20 pub command: Option<String>,
21 pub active: bool,
22}
23
24pub fn own_pane_id() -> Result<String> {
30 std::env::var("TMUX_PANE")
31 .map(|id| normalize_pane_id(&id))
32 .map_err(|_| eyre::eyre!("TMUX_PANE not set — not inside tmux?"))
33}
34
35pub fn session_name() -> Result<String> {
37 if let Ok(val) = std::env::var("TMUX") {
39 if let Some(path) = val.split(',').next() {
40 if let Some(name) = path.rsplit('/').next() {
41 if !name.is_empty() {
42 return Ok(name.to_string());
43 }
44 }
45 }
46 }
47 tmux_output(&["display-message", "-p", "#{session_name}"])
49}
50
51pub fn send(pane_id: &str, text: &str) -> Result<()> {
60 if text.len() <= 200 {
61 tmux(&["send-keys", "-t", pane_id, text, "Enter"])?;
62 } else {
63 let mut child = Command::new("tmux")
65 .args(["load-buffer", "-"])
66 .stdin(Stdio::piped())
67 .stdout(Stdio::piped())
68 .stderr(Stdio::piped())
69 .spawn()?;
70 if let Some(mut stdin) = child.stdin.take() {
71 stdin.write_all(text.as_bytes())?;
72 }
73 let output = child.wait_with_output()?;
74 if !output.status.success() {
75 bail!(
76 "tmux load-buffer failed: {}",
77 String::from_utf8_lossy(&output.stderr).trim()
78 );
79 }
80 tmux(&["paste-buffer", "-t", pane_id])?;
82 tmux(&["send-keys", "-t", pane_id, "Enter"])?;
84 }
85 Ok(())
86}
87
88pub fn spawn(cmd: &str, args: &[&str], _name: Option<&str>) -> Result<String> {
94 let full_cmd = if args.is_empty() {
95 cmd.to_string()
96 } else {
97 format!("{} {}", cmd, args.join(" "))
98 };
99
100 let output = Command::new("tmux")
101 .args(["split-window", "-h", "-P", "-F", "#{pane_id}", &full_cmd])
102 .output()?;
103 if !output.status.success() {
104 bail!(
105 "tmux split-window failed: {}",
106 String::from_utf8_lossy(&output.stderr).trim()
107 );
108 }
109 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
110}
111
112pub fn close(pane_id: &str) -> Result<()> {
114 tmux(&["kill-pane", "-t", pane_id])
115}
116
117pub fn list_panes() -> Result<Vec<PaneInfo>> {
123 let raw = tmux_output(&[
124 "list-panes", "-a", "-F",
125 "#{pane_id}|#{pane_title}|#{pane_current_command}|#{pane_active}",
126 ])?;
127 let mut panes = Vec::new();
128 for line in raw.lines() {
129 let parts: Vec<&str> = line.splitn(4, '|').collect();
130 if parts.len() < 4 {
131 continue;
132 }
133 panes.push(PaneInfo {
134 id: parts[0].to_string(),
135 title: parts[1].to_string(),
136 command: if parts[2].is_empty() {
137 None
138 } else {
139 Some(parts[2].to_string())
140 },
141 active: parts[3] == "1",
142 });
143 }
144 Ok(panes)
145}
146
147pub fn list_pane_ids() -> Result<Vec<String>> {
149 Ok(list_panes()?.into_iter().map(|p| p.id).collect())
150}
151
152pub fn normalize_pane_id(input: &str) -> String {
157 if input.starts_with('%') {
158 input.to_string()
159 } else {
160 format!("%{input}")
161 }
162}
163
164pub fn dump(pane_id: &str) -> Result<String> {
166 tmux_output(&["capture-pane", "-t", pane_id, "-p", "-S", "-"])
167}
168
169pub fn wait_for_stable_output(pane_id: &str, max_secs: u64, settle_secs: u64) {
175 let start = std::time::Instant::now();
176 let timeout = std::time::Duration::from_secs(max_secs);
177 loop {
178 if start.elapsed() >= timeout {
179 break;
180 }
181 if let Ok(text) = dump(pane_id) {
182 if !text.trim().is_empty() {
183 std::thread::sleep(std::time::Duration::from_secs(settle_secs));
184 return;
185 }
186 }
187 std::thread::sleep(std::time::Duration::from_millis(250));
188 }
189}
190
191fn tmux(args: &[&str]) -> Result<()> {
196 let output = Command::new("tmux").args(args).output()?;
197 if !output.status.success() {
198 bail!(
199 "tmux {} failed: {}",
200 args.first().unwrap_or(&""),
201 String::from_utf8_lossy(&output.stderr).trim()
202 );
203 }
204 Ok(())
205}
206
207fn tmux_output(args: &[&str]) -> Result<String> {
208 let output = Command::new("tmux").args(args).output()?;
209 if !output.status.success() {
210 bail!(
211 "tmux {} failed: {}",
212 args.first().unwrap_or(&""),
213 String::from_utf8_lossy(&output.stderr).trim()
214 );
215 }
216 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
217}