1use std::process::Command;
4
5pub struct WindowInfo {
8 pub index: u32,
9 pub name: String,
10 pub is_active: bool,
11 pub pane_path: String,
12}
13
14fn tmux(args: &[&str]) -> std::io::Result<std::process::Output> {
17 Command::new("tmux").args(args).output()
18}
19
20fn tmux_ok(args: &[&str]) -> bool {
21 tmux(args).is_ok_and(|o| o.status.success())
22}
23
24fn tmux_stdout(args: &[&str]) -> Result<String, String> {
25 let output = tmux(args).map_err(|e| format!("tmux: {e}"))?;
26 if !output.status.success() {
27 let stderr = String::from_utf8_lossy(&output.stderr);
28 return Err(format!("tmux: {}", stderr.trim()));
29 }
30 Ok(String::from_utf8_lossy(&output.stdout).to_string())
31}
32
33fn is_git_repo(dir: &str) -> bool {
35 Command::new("git")
36 .args(["-C", dir, "rev-parse", "--is-inside-work-tree"])
37 .stdout(std::process::Stdio::null())
38 .stderr(std::process::Stdio::null())
39 .status()
40 .is_ok_and(|s| s.success())
41}
42
43fn claude_cmd_and_window_name(name: &str, dir: &str) -> (String, String) {
46 if is_git_repo(dir) {
47 (format!("claude --worktree {name}"), format!("{name}(wt)"))
48 } else {
49 ("claude".to_string(), name.to_string())
50 }
51}
52
53pub const SESSION: &str = "cove";
56
57pub fn has_session() -> bool {
58 tmux_ok(&["has-session", "-t", SESSION])
59}
60
61pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
62 let out = tmux_stdout(&[
63 "list-windows",
64 "-t",
65 SESSION,
66 "-F",
67 "#{window_index}|#{window_name}|#{window_active}|#{pane_current_path}",
68 ])?;
69 Ok(parse_window_list(&out))
70}
71
72pub fn parse_window_list(output: &str) -> Vec<WindowInfo> {
75 let mut windows = Vec::new();
76 for line in output.lines() {
77 let parts: Vec<&str> = line.splitn(4, '|').collect();
78 if parts.len() < 4 {
79 continue;
80 }
81 windows.push(WindowInfo {
82 index: parts[0].parse().unwrap_or(0),
83 name: parts[1].to_string(),
84 is_active: parts[2] == "1",
85 pane_path: parts[3].to_string(),
86 });
87 }
88 windows
89}
90
91pub fn list_window_names() -> Result<Vec<String>, String> {
93 let out = tmux_stdout(&["list-windows", "-t", SESSION, "-F", "#{window_name}"])?;
94 Ok(out.lines().map(|s| s.to_string()).collect())
95}
96
97pub fn is_inside_tmux() -> bool {
98 std::env::var("TMUX").is_ok_and(|v| !v.is_empty())
99}
100
101pub fn new_session(name: &str, dir: &str, sidebar_bin: &str) -> Result<(), String> {
102 let (claude_cmd, window_name) = claude_cmd_and_window_name(name, dir);
103 let status = Command::new("tmux")
104 .args([
105 "new-session",
106 "-d",
107 "-s",
108 SESSION,
109 "-n",
110 &window_name,
111 "-c",
112 dir,
113 ";",
114 "set-option",
115 "-w",
116 "automatic-rename",
117 "off",
118 ";",
119 "set-option",
120 "-w",
121 "allow-rename",
122 "off",
123 ";",
124 "set-option",
125 "-w",
126 "remain-on-exit",
127 "on",
128 ";",
129 "set-hook",
130 "pane-died",
131 "respawn-pane",
132 ";",
133 "split-window",
134 "-h",
135 "-p",
136 "30",
137 "-c",
138 dir,
139 ";",
140 "split-window",
141 "-t",
142 ".2",
143 "-v",
144 "-b",
145 "-p",
146 "50",
147 sidebar_bin,
148 ";",
149 "select-pane",
150 "-t",
151 ".2",
152 ";",
153 "respawn-pane",
154 "-t",
155 ".1",
156 "-k",
157 &claude_cmd,
158 ";",
159 "set-hook",
160 "-w",
161 "window-layout-changed",
162 "run-shell 'tmux resize-pane -t #{session_name}:#{window_index}.1 -x $((#{window_width} * 70 / 100))'",
163 ])
164 .status()
165 .map_err(|e| format!("tmux: {e}"))?;
166
167 if !status.success() {
168 return Err("tmux new-session failed".to_string());
169 }
170 Ok(())
171}
172
173pub fn new_window(name: &str, dir: &str) -> Result<(), String> {
174 let (claude_cmd, window_name) = claude_cmd_and_window_name(name, dir);
178 let status = Command::new("tmux")
179 .args([
180 "new-window",
181 "-a",
182 "-t",
183 SESSION,
184 "-n",
185 &window_name,
186 "-c",
187 dir,
188 &claude_cmd,
189 ])
190 .status()
191 .map_err(|e| format!("tmux: {e}"))?;
192
193 if !status.success() {
194 return Err("tmux new-window failed".to_string());
195 }
196
197 let target = format!("{SESSION}:{window_name}");
199 let _ = tmux(&["set-option", "-w", "-t", &target, "automatic-rename", "off"]);
200 let _ = tmux(&["set-option", "-w", "-t", &target, "allow-rename", "off"]);
201
202 Ok(())
203}
204
205pub fn setup_layout(name: &str, dir: &str, sidebar_bin: &str) -> Result<(), String> {
206 let win = format!("{SESSION}:{name}");
207 let status = Command::new("tmux")
208 .args([
209 "set-option",
210 "-w",
211 "-t",
212 &win,
213 "remain-on-exit",
214 "on",
215 ";",
216 "set-hook",
217 "-w",
218 "-t",
219 &win,
220 "pane-died",
221 "respawn-pane",
222 ";",
223 "split-window",
224 "-t",
225 &win,
226 "-h",
227 "-p",
228 "30",
229 "-c",
230 dir,
231 ";",
232 "split-window",
233 "-t",
234 &format!("{win}.2"),
235 "-v",
236 "-b",
237 "-p",
238 "50",
239 sidebar_bin,
240 ";",
241 "select-pane",
242 "-t",
243 &format!("{win}.2"),
244 ";",
245 "set-hook",
246 "-w",
247 "-t",
248 &win,
249 "window-layout-changed",
250 &format!(
251 "run-shell 'tmux resize-pane -t {win}.1 -x $(( #{{window_width}} * 70 / 100 ))'"
252 ),
253 ])
254 .status()
255 .map_err(|e| format!("tmux: {e}"))?;
256
257 if !status.success() {
258 return Err("tmux setup-layout failed".to_string());
259 }
260 Ok(())
261}
262
263pub fn attach() -> Result<(), String> {
264 let status = Command::new("tmux")
265 .args(["attach", "-t", SESSION])
266 .status()
267 .map_err(|e| format!("tmux: {e}"))?;
268
269 if !status.success() {
270 return Err("tmux attach failed".to_string());
271 }
272 Ok(())
273}
274
275pub fn switch_client() -> Result<(), String> {
276 let status = Command::new("tmux")
277 .args(["switch-client", "-t", SESSION])
278 .status()
279 .map_err(|e| format!("tmux: {e}"))?;
280
281 if !status.success() {
282 return Err("tmux switch-client failed".to_string());
283 }
284 Ok(())
285}
286
287pub fn kill_window(name: &str) -> Result<(), String> {
288 let target = format!("{SESSION}:{name}");
289 tmux_stdout(&["kill-window", "-t", &target])?;
290 Ok(())
291}
292
293pub fn kill_session() -> Result<(), String> {
294 tmux_stdout(&["kill-session", "-t", SESSION])?;
295 Ok(())
296}
297
298pub fn select_window(index: u32) -> Result<(), String> {
299 let target = format!("{SESSION}:{index}");
300 let status = Command::new("tmux")
301 .args([
302 "select-window",
303 "-t",
304 &target,
305 ";",
306 "select-pane",
307 "-t",
308 ":.1",
309 ])
310 .status()
311 .map_err(|e| format!("tmux: {e}"))?;
312
313 if !status.success() {
314 return Err("tmux select-window failed".to_string());
315 }
316 Ok(())
317}
318
319pub struct PaneInfo {
321 pub window_index: u32,
322 pub command: String,
323 pub pane_id: String,
325}
326
327pub fn list_pane_commands() -> Result<Vec<PaneInfo>, String> {
329 let out = tmux_stdout(&[
330 "list-panes",
331 "-s",
332 "-t",
333 SESSION,
334 "-F",
335 "#{window_index}|#{pane_index}|#{pane_current_command}|#{pane_id}",
336 ])?;
337 Ok(parse_pane_list(&out))
338}
339
340pub fn parse_pane_list(output: &str) -> Vec<PaneInfo> {
344 let mut panes = Vec::new();
345 for line in output.lines() {
346 let parts: Vec<&str> = line.splitn(4, '|').collect();
347 if parts.len() < 4 {
348 continue;
349 }
350 if parts[1] != "1" {
352 continue;
353 }
354 panes.push(PaneInfo {
355 window_index: parts[0].parse().unwrap_or(0),
356 command: parts[2].to_string(),
357 pane_id: parts[3].to_string(),
358 });
359 }
360 panes
361}
362
363pub fn get_claude_pane_id(window_name: &str) -> Result<String, String> {
365 let target = format!("{SESSION}:{window_name}.1");
366 let out = tmux_stdout(&["display-message", "-t", &target, "-p", "#{pane_id}"])?;
367 Ok(out.trim().to_string())
368}
369
370pub fn set_window_option(window_name: &str, key: &str, value: &str) -> Result<(), String> {
371 let target = format!("{SESSION}:{window_name}");
372 tmux_stdout(&["set-option", "-w", "-t", &target, key, value])?;
373 Ok(())
374}
375
376pub fn get_window_option(pane_id: &str, key: &str) -> Result<String, String> {
377 let out = tmux_stdout(&["show-option", "-w", "-t", pane_id, "-v", key])?;
378 Ok(out.trim().to_string())
379}
380
381pub fn get_window_name(pane_id: &str) -> Result<String, String> {
382 let out = tmux_stdout(&["display-message", "-t", pane_id, "-p", "#{window_name}"])?;
383 Ok(out.trim().to_string())
384}
385
386pub fn rename_window(pane_id: &str, new_name: &str) -> Result<(), String> {
387 tmux_stdout(&["rename-window", "-t", pane_id, new_name])?;
388 Ok(())
389}
390
391pub fn send_keys(window_name: &str, keys: &[&str]) -> Result<(), String> {
393 let target = format!("{SESSION}:{window_name}.1");
394 let mut args = vec!["send-keys", "-t", &target];
395 args.extend_from_slice(keys);
396 tmux_stdout(&args)?;
397 Ok(())
398}
399
400pub fn pane_command(window_name: &str) -> Result<String, String> {
402 let target = format!("{SESSION}:{window_name}.1");
403 let out = tmux_stdout(&[
404 "display-message",
405 "-t",
406 &target,
407 "-p",
408 "#{pane_current_command}",
409 ])?;
410 Ok(out.trim().to_string())
411}
412
413pub fn disable_respawn(window_name: &str) -> Result<(), String> {
415 let target = format!("{SESSION}:{window_name}");
416 let _ = tmux_stdout(&["set-hook", "-u", "-w", "-t", &target, "pane-died"]);
417 let _ = tmux_stdout(&["set-option", "-w", "-t", &target, "remain-on-exit", "off"]);
418 Ok(())
419}
420
421pub fn select_window_sidebar(index: u32) -> Result<(), String> {
422 let target = format!("{SESSION}:{index}");
423 let status = Command::new("tmux")
424 .args([
425 "select-window",
426 "-t",
427 &target,
428 ";",
429 "select-pane",
430 "-t",
431 ":.2",
432 ])
433 .status()
434 .map_err(|e| format!("tmux: {e}"))?;
435
436 if !status.success() {
437 return Err("tmux select-window failed".to_string());
438 }
439 Ok(())
440}