Skip to main content

cove_cli/
tmux.rs

1// ── tmux Command wrappers ──
2
3use std::process::Command;
4
5// ── Types ──
6
7pub struct WindowInfo {
8    pub index: u32,
9    pub name: String,
10    pub is_active: bool,
11    pub pane_path: String,
12}
13
14// ── Helpers ──
15
16fn 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
33// ── Public API ──
34
35pub const SESSION: &str = "cove";
36
37pub fn has_session() -> bool {
38    tmux_ok(&["has-session", "-t", SESSION])
39}
40
41pub fn list_windows() -> Result<Vec<WindowInfo>, String> {
42    let out = tmux_stdout(&[
43        "list-windows",
44        "-t",
45        SESSION,
46        "-F",
47        "#{window_index}|#{window_name}|#{window_active}|#{pane_current_path}",
48    ])?;
49    Ok(parse_window_list(&out))
50}
51
52/// Parse tmux list-windows output into WindowInfo structs.
53/// Format: "index|name|active|path" per line.
54pub fn parse_window_list(output: &str) -> Vec<WindowInfo> {
55    let mut windows = Vec::new();
56    for line in output.lines() {
57        let parts: Vec<&str> = line.splitn(4, '|').collect();
58        if parts.len() < 4 {
59            continue;
60        }
61        windows.push(WindowInfo {
62            index: parts[0].parse().unwrap_or(0),
63            name: parts[1].to_string(),
64            is_active: parts[2] == "1",
65            pane_path: parts[3].to_string(),
66        });
67    }
68    windows
69}
70
71/// List window names only (for duplicate checking).
72pub fn list_window_names() -> Result<Vec<String>, String> {
73    let out = tmux_stdout(&["list-windows", "-t", SESSION, "-F", "#{window_name}"])?;
74    Ok(out.lines().map(|s| s.to_string()).collect())
75}
76
77pub fn is_inside_tmux() -> bool {
78    std::env::var("TMUX").is_ok_and(|v| !v.is_empty())
79}
80
81pub fn new_session(name: &str, dir: &str, sidebar_bin: &str) -> Result<(), String> {
82    let status = Command::new("tmux")
83        .args([
84            "new-session",
85            "-d",
86            "-s",
87            SESSION,
88            "-n",
89            name,
90            "-c",
91            dir,
92            ";",
93            "set-option",
94            "-w",
95            "remain-on-exit",
96            "on",
97            ";",
98            "set-hook",
99            "pane-died",
100            "respawn-pane",
101            ";",
102            "split-window",
103            "-h",
104            "-p",
105            "30",
106            "-c",
107            dir,
108            ";",
109            "split-window",
110            "-t",
111            ".2",
112            "-v",
113            "-b",
114            "-p",
115            "50",
116            sidebar_bin,
117            ";",
118            "select-pane",
119            "-t",
120            ".2",
121            ";",
122            "respawn-pane",
123            "-t",
124            ".1",
125            "-k",
126            "claude",
127            ";",
128            "set-hook",
129            "-w",
130            "window-layout-changed",
131            "run-shell 'tmux resize-pane -t #{session_name}:#{window_index}.1 -x $((#{window_width} * 70 / 100))'",
132        ])
133        .status()
134        .map_err(|e| format!("tmux: {e}"))?;
135
136    if !status.success() {
137        return Err("tmux new-session failed".to_string());
138    }
139    Ok(())
140}
141
142pub fn new_window(name: &str, dir: &str) -> Result<(), String> {
143    // -a = insert AFTER the target window, not AT its index.
144    // Without -a, `-t cove` resolves to the current window (e.g. cove:1)
145    // and tmux tries to create at that exact index, causing "index N in use".
146    let status = Command::new("tmux")
147        .args([
148            "new-window",
149            "-a",
150            "-t",
151            SESSION,
152            "-n",
153            name,
154            "-c",
155            dir,
156            "claude",
157        ])
158        .status()
159        .map_err(|e| format!("tmux: {e}"))?;
160
161    if !status.success() {
162        return Err("tmux new-window failed".to_string());
163    }
164    Ok(())
165}
166
167pub fn setup_layout(name: &str, dir: &str, sidebar_bin: &str) -> Result<(), String> {
168    let win = format!("{SESSION}:{name}");
169    let status = Command::new("tmux")
170        .args([
171            "set-option",
172            "-w",
173            "-t",
174            &win,
175            "remain-on-exit",
176            "on",
177            ";",
178            "set-hook",
179            "-w",
180            "-t",
181            &win,
182            "pane-died",
183            "respawn-pane",
184            ";",
185            "split-window",
186            "-t",
187            &win,
188            "-h",
189            "-p",
190            "30",
191            "-c",
192            dir,
193            ";",
194            "split-window",
195            "-t",
196            &format!("{win}.2"),
197            "-v",
198            "-b",
199            "-p",
200            "50",
201            sidebar_bin,
202            ";",
203            "select-pane",
204            "-t",
205            &format!("{win}.2"),
206            ";",
207            "set-hook",
208            "-w",
209            "-t",
210            &win,
211            "window-layout-changed",
212            &format!(
213                "run-shell 'tmux resize-pane -t {win}.1 -x $(( #{{window_width}} * 70 / 100 ))'"
214            ),
215        ])
216        .status()
217        .map_err(|e| format!("tmux: {e}"))?;
218
219    if !status.success() {
220        return Err("tmux setup-layout failed".to_string());
221    }
222    Ok(())
223}
224
225pub fn attach() -> Result<(), String> {
226    let status = Command::new("tmux")
227        .args(["attach", "-t", SESSION])
228        .status()
229        .map_err(|e| format!("tmux: {e}"))?;
230
231    if !status.success() {
232        return Err("tmux attach failed".to_string());
233    }
234    Ok(())
235}
236
237pub fn switch_client() -> Result<(), String> {
238    let status = Command::new("tmux")
239        .args(["switch-client", "-t", SESSION])
240        .status()
241        .map_err(|e| format!("tmux: {e}"))?;
242
243    if !status.success() {
244        return Err("tmux switch-client failed".to_string());
245    }
246    Ok(())
247}
248
249pub fn kill_window(name: &str) -> Result<(), String> {
250    let target = format!("{SESSION}:{name}");
251    tmux_stdout(&["kill-window", "-t", &target])?;
252    Ok(())
253}
254
255pub fn kill_session() -> Result<(), String> {
256    tmux_stdout(&["kill-session", "-t", SESSION])?;
257    Ok(())
258}
259
260pub fn select_window(index: u32) -> Result<(), String> {
261    let target = format!("{SESSION}:{index}");
262    let status = Command::new("tmux")
263        .args([
264            "select-window",
265            "-t",
266            &target,
267            ";",
268            "select-pane",
269            "-t",
270            ":.1",
271        ])
272        .status()
273        .map_err(|e| format!("tmux: {e}"))?;
274
275    if !status.success() {
276        return Err("tmux select-window failed".to_string());
277    }
278    Ok(())
279}
280
281/// Info about pane .1 in each window (for state detection).
282pub struct PaneInfo {
283    pub window_index: u32,
284    pub command: String,
285    /// Unique tmux pane identifier (e.g. "%0", "%3").
286    pub pane_id: String,
287}
288
289/// Get the foreground command and pane ID of pane .1 in every window.
290pub fn list_pane_commands() -> Result<Vec<PaneInfo>, String> {
291    let out = tmux_stdout(&[
292        "list-panes",
293        "-s",
294        "-t",
295        SESSION,
296        "-F",
297        "#{window_index}|#{pane_index}|#{pane_current_command}|#{pane_id}",
298    ])?;
299    Ok(parse_pane_list(&out))
300}
301
302/// Parse tmux list-panes output into PaneInfo structs.
303/// Format: "window_index|pane_index|command|pane_id" per line.
304/// Only returns panes with pane_index=1 (the Claude pane).
305pub fn parse_pane_list(output: &str) -> Vec<PaneInfo> {
306    let mut panes = Vec::new();
307    for line in output.lines() {
308        let parts: Vec<&str> = line.splitn(4, '|').collect();
309        if parts.len() < 4 {
310            continue;
311        }
312        // Only pane index 1 (the Claude pane)
313        if parts[1] != "1" {
314            continue;
315        }
316        panes.push(PaneInfo {
317            window_index: parts[0].parse().unwrap_or(0),
318            command: parts[2].to_string(),
319            pane_id: parts[3].to_string(),
320        });
321    }
322    panes
323}
324
325/// Get the pane_id (e.g. "%5") of pane .1 (the Claude pane) in a specific window.
326pub fn get_claude_pane_id(window_name: &str) -> Result<String, String> {
327    let target = format!("{SESSION}:{window_name}.1");
328    let out = tmux_stdout(&["display-message", "-t", &target, "-p", "#{pane_id}"])?;
329    Ok(out.trim().to_string())
330}
331
332pub fn set_window_option(window_name: &str, key: &str, value: &str) -> Result<(), String> {
333    let target = format!("{SESSION}:{window_name}");
334    tmux_stdout(&["set-option", "-w", "-t", &target, key, value])?;
335    Ok(())
336}
337
338pub fn get_window_option(pane_id: &str, key: &str) -> Result<String, String> {
339    let out = tmux_stdout(&["show-option", "-w", "-t", pane_id, "-v", key])?;
340    Ok(out.trim().to_string())
341}
342
343pub fn get_window_name(pane_id: &str) -> Result<String, String> {
344    let out = tmux_stdout(&["display-message", "-t", pane_id, "-p", "#{window_name}"])?;
345    Ok(out.trim().to_string())
346}
347
348pub fn rename_window(pane_id: &str, new_name: &str) -> Result<(), String> {
349    tmux_stdout(&["rename-window", "-t", pane_id, new_name])?;
350    Ok(())
351}
352
353/// Send keys to the Claude pane (.1) of a window.
354pub fn send_keys(window_name: &str, keys: &[&str]) -> Result<(), String> {
355    let target = format!("{SESSION}:{window_name}.1");
356    let mut args = vec!["send-keys", "-t", &target];
357    args.extend_from_slice(keys);
358    tmux_stdout(&args)?;
359    Ok(())
360}
361
362/// Get the foreground command running in the Claude pane (.1).
363pub fn pane_command(window_name: &str) -> Result<String, String> {
364    let target = format!("{SESSION}:{window_name}.1");
365    let out = tmux_stdout(&["display-message", "-t", &target, "-p", "#{pane_current_command}"])?;
366    Ok(out.trim().to_string())
367}
368
369/// Remove the pane-died hook so Claude isn't respawned after exit.
370pub fn disable_respawn(window_name: &str) -> Result<(), String> {
371    let target = format!("{SESSION}:{window_name}");
372    let _ = tmux_stdout(&["set-hook", "-u", "-w", "-t", &target, "pane-died"]);
373    let _ = tmux_stdout(&["set-option", "-w", "-t", &target, "remain-on-exit", "off"]);
374    Ok(())
375}
376
377pub fn select_window_sidebar(index: u32) -> Result<(), String> {
378    let target = format!("{SESSION}:{index}");
379    let status = Command::new("tmux")
380        .args([
381            "select-window",
382            "-t",
383            &target,
384            ";",
385            "select-pane",
386            "-t",
387            ":.2",
388        ])
389        .status()
390        .map_err(|e| format!("tmux: {e}"))?;
391
392    if !status.success() {
393        return Err("tmux select-window failed".to_string());
394    }
395    Ok(())
396}