Skip to main content

ninox_core/
tmux.rs

1use anyhow::{Context, Result};
2use tokio::process::Command;
3
4/// Metadata about a running tmux session from `list-sessions`.
5#[derive(Debug, Clone)]
6pub struct TmuxSession {
7    pub id:         String,
8    pub created_ms: i64,
9    pub pid:        Option<u32>,
10    pub tty:        Option<String>,
11}
12
13/// Run a tmux subcommand and return trimmed stdout.
14async fn run(args: &[&str]) -> Result<String> {
15    let out = Command::new("tmux")
16        .args(args)
17        .kill_on_drop(true)
18        .output()
19        .await
20        .context("tmux not found — install tmux (brew install tmux / apt install tmux)")?;
21    if !out.status.success() {
22        let stderr = String::from_utf8_lossy(&out.stderr);
23        anyhow::bail!("tmux {:?} failed: {}", args, stderr.trim());
24    }
25    Ok(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
26}
27
28/// Run tmux; swallow errors and return empty string on failure.
29/// Logs warnings for debugging; does not propagate errors.
30async fn run_best_effort(args: &[&str]) -> String {
31    match run(args).await {
32        Ok(result) => result,
33        Err(e) => {
34            tracing::warn!("tmux {:?} failed (ignored): {}", args, e);
35            String::new()
36        }
37    }
38}
39
40/// Shell-quote a string to prevent injection in tmux commands.
41/// Wraps the string in single quotes and escapes interior single quotes.
42fn shell_quote(s: &str) -> String {
43    format!("'{}'", s.replace('\'', "'\\''"))
44}
45
46/// Create a detached tmux session.  Kills a stale session with the same name
47/// if one exists, then hides the status bar so the terminal widget is clean.
48pub async fn create_session(
49    id:        &str,
50    workspace: &str,
51    cmd:       &str,
52    env:       &[(&str, &str)],
53) -> Result<()> {
54    // Build -e KEY=VALUE pairs
55    let mut env_pairs: Vec<String> = Vec::new();
56    for (k, v) in env {
57        anyhow::ensure!(!k.contains('='), "env key must not contain '=': {k}");
58        env_pairs.push(format!("{k}={v}"));
59    }
60    let mut extra: Vec<&str> = Vec::new();
61    for pair in &env_pairs {
62        // Values are passed as separate argv tokens via execve — no shell quoting needed.
63        extra.push("-e");
64        extra.push(pair.as_str());
65    }
66
67    // Wrap the command in a login shell so the full user PATH is available.
68    // tmux sessions do not inherit shell rc files, so tools installed via
69    // nvm / cargo / homebrew etc. would not be found otherwise.
70    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
71    let shell_cmd = format!("{shell} -l -c {}", shell_quote(cmd));
72
73    // Fix the terminal dimensions to match the canvas.  The canvas is roughly
74    // (window_width - 220px sidebar) / 7.8px_per_col ≈ 135 cols on a 1280-wide
75    // window.  Use 140 as a safe default; too-wide values push Claude Code's
76    // centered content off-screen.
77    let mut base = vec!["new-session", "-d", "-s", id, "-x", "140", "-y", "50", "-c", workspace];
78    base.extend_from_slice(&extra);
79    base.push(&shell_cmd);
80
81    for attempt in 0..2u8 {
82        match run(&base).await {
83            Ok(_) => break,
84            Err(e) if attempt == 0 && e.to_string().contains("duplicate session") => {
85                run_best_effort(&["kill-session", "-t", id]).await;
86            }
87            Err(e) => return Err(e),
88        }
89    }
90
91    // Best-effort: hide the tmux status bar so the terminal widget isn't cluttered.
92    if let Err(e) = run(&["set-option", "-t", id, "status", "off"]).await {
93        tracing::warn!("failed to hide tmux status bar: {}", e);
94    }
95    Ok(())
96}
97
98/// Kill a tmux session.  Succeeds even if the session doesn't exist.
99pub async fn kill_session(id: &str) -> Result<()> {
100    match run(&["kill-session", "-t", id]).await {
101        Ok(_) => Ok(()),
102        Err(e) => {
103            let msg = e.to_string();
104            if msg.contains("no server running")
105                || msg.contains("can't find session")
106                || msg.contains("session not found")
107                || msg.contains("no sessions")
108            {
109                Ok(())
110            } else {
111                Err(e)
112            }
113        }
114    }
115}
116
117/// Returns `true` if a tmux session with this name is currently running.
118pub async fn has_session(id: &str) -> bool {
119    run(&["has-session", "-t", id]).await.is_ok()
120}
121
122/// List every live tmux session.
123pub async fn list_sessions() -> Result<Vec<TmuxSession>> {
124    let raw = run_best_effort(&[
125        "list-sessions",
126        "-F",
127        "#{session_name}\t#{session_created}\t#{pane_pid}\t#{pane_tty}",
128    ])
129    .await;
130    Ok(raw
131        .lines()
132        .filter(|l| !l.is_empty())
133        .filter_map(|line| {
134            let mut cols = line.splitn(4, '\t');
135            let id  = cols.next()?.to_string();
136            let sec = cols.next().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
137            let pid = cols.next().and_then(|s| s.parse::<u32>().ok());
138            let tty = cols.next().map(str::to_string).filter(|s| !s.is_empty());
139            Some(TmuxSession { id, created_ms: sec * 1000, pid, tty })
140        })
141        .collect())
142}
143
144/// Return the tty device path (e.g. `/dev/ttys003`) for the session's active pane.
145pub async fn get_pane_tty(id: &str) -> Result<Option<String>> {
146    let out = run(&["list-panes", "-t", id, "-F", "#{pane_tty}"]).await?;
147    Ok(out
148        .lines()
149        .next()
150        .map(|s| s.trim().to_string())
151        .filter(|s| !s.is_empty()))
152}
153
154/// Start piping pane output to `dest_path` (regular file, not FIFO).
155/// Does NOT use `-o` so it force-restarts any existing pipe — required for reconnect.
156pub async fn pipe_pane(id: &str, dest_path: &str) -> Result<()> {
157    run(&["pipe-pane", "-t", id, &format!("cat > {}", shell_quote(dest_path))]).await?;
158    Ok(())
159}
160
161/// Resize a tmux window to the given dimensions.
162pub async fn resize_window(id: &str, cols: u16, rows: u16) -> Result<()> {
163    run(&[
164        "resize-window", "-t", id,
165        "-x", &cols.to_string(),
166        "-y", &rows.to_string(),
167    ]).await?;
168    Ok(())
169}
170
171/// Capture the current visible content of a pane with escape sequences.
172/// Used to replay initial output that was emitted before the FIFO pipe connected.
173pub async fn capture_pane(id: &str) -> Vec<u8> {
174    run(&["capture-pane", "-t", id, "-p", "-e"])
175        .await
176        .map(|s| s.into_bytes())
177        .unwrap_or_default()
178}
179
180/// Send text to a tmux session as if typed at the keyboard.
181/// The text is followed by Enter so the agent receives and acts on it.
182/// Uses `tmux send-keys -l` (literal mode) to avoid tmux interpreting
183/// special characters like `{`, `}`, arrows.
184pub async fn send_keys(session_id: &str, text: &str) -> Result<()> {
185    // Send the message text in literal mode
186    run(&["send-keys", "-t", session_id, "-l", text]).await?;
187    // Send Enter to submit
188    run(&["send-keys", "-t", session_id, "Enter"]).await?;
189    Ok(())
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    fn tmux_available() -> bool {
197        std::process::Command::new("tmux")
198            .args(["-V"])
199            .output()
200            .map(|o| o.status.success())
201            .unwrap_or(false)
202    }
203
204    fn unique_id() -> String {
205        format!(
206            "test-{}",
207            std::time::SystemTime::now()
208                .duration_since(std::time::UNIX_EPOCH)
209                .unwrap()
210                .as_millis()
211        )
212    }
213
214    #[tokio::test]
215    async fn create_and_has_and_kill() {
216        if !tmux_available() { return; }
217        let id = unique_id();
218        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
219        assert!(has_session(&id).await);
220        kill_session(&id).await.unwrap();
221        assert!(!has_session(&id).await);
222    }
223
224    #[tokio::test]
225    async fn list_includes_created() {
226        if !tmux_available() { return; }
227        let id = unique_id();
228        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
229        let sessions = list_sessions().await.unwrap();
230        assert!(sessions.iter().any(|s| s.id == id));
231        kill_session(&id).await.unwrap();
232    }
233
234    #[tokio::test]
235    async fn get_pane_tty_returns_dev_path() {
236        if !tmux_available() { return; }
237        let id = unique_id();
238        create_session(&id, "/tmp", "sleep 30", &[]).await.unwrap();
239        let tty = get_pane_tty(&id).await.unwrap();
240        assert!(tty.map(|t| t.starts_with("/dev/")).unwrap_or(false));
241        kill_session(&id).await.unwrap();
242    }
243
244    #[tokio::test]
245    async fn send_keys_builds_correct_command() {
246        // This test validates our argument construction without actually calling tmux.
247        // We test the shell_quote helper used by send_keys.
248        let quoted = shell_quote("hello world");
249        assert_eq!(quoted, "'hello world'");
250        let with_apostrophe = shell_quote("don't");
251        assert_eq!(with_apostrophe, "'don'\\''t'");
252    }
253}