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/// Check whether `dir` is inside a git repository.
34fn 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
43/// Build the claude command and window name suffix based on whether the
44/// target directory is a git repo (uses --worktree) or not (plain claude).
45fn 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
53// ── Public API ──
54
55pub 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
72/// Parse tmux list-windows output into WindowInfo structs.
73/// Format: "index|name|active|path" per line.
74pub 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
91/// List window names only (for duplicate checking).
92pub 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    // -a = insert AFTER the target window, not AT its index.
175    // Without -a, `-t cove` resolves to the current window (e.g. cove:1)
176    // and tmux tries to create at that exact index, causing "index N in use".
177    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    // Lock the window name so Claude Code cannot overwrite it
198    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
319/// Info about pane .1 in each window (for state detection).
320pub struct PaneInfo {
321    pub window_index: u32,
322    pub command: String,
323    /// Unique tmux pane identifier (e.g. "%0", "%3").
324    pub pane_id: String,
325}
326
327/// Get the foreground command and pane ID of pane .1 in every window.
328pub 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
340/// Parse tmux list-panes output into PaneInfo structs.
341/// Format: "window_index|pane_index|command|pane_id" per line.
342/// Only returns panes with pane_index=1 (the Claude pane).
343pub 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        // Only pane index 1 (the Claude pane)
351        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
363/// Get the pane_id (e.g. "%5") of pane .1 (the Claude pane) in a specific window.
364pub 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
391/// Send keys to the Claude pane (.1) of a window.
392pub 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
400/// Get the foreground command running in the Claude pane (.1).
401pub 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
413/// Remove the pane-died hook so Claude isn't respawned after exit.
414pub 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}