Skip to main content

tmx/
tmux.rs

1use std::io;
2use std::process::{Command, Stdio};
3
4use serde::Serialize;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
7pub struct Session {
8    pub name: String,
9    pub windows: u32,
10    pub attached: bool,
11}
12
13pub fn list_sessions() -> io::Result<Vec<Session>> {
14    let output = Command::new("tmux")
15        .args([
16            "list-sessions",
17            "-F",
18            "#{session_name}:#{session_windows}:#{session_attached}",
19        ])
20        .output()?;
21
22    if !output.status.success() {
23        return Err(io::Error::other(format!(
24            "tmux list-sessions failed: {}",
25            String::from_utf8_lossy(&output.stderr).trim()
26        )));
27    }
28
29    let stdout = String::from_utf8_lossy(&output.stdout);
30    Ok(parse_sessions(&stdout))
31}
32
33pub fn parse_sessions(stdout: &str) -> Vec<Session> {
34    stdout
35        .trim()
36        .lines()
37        .filter(|line| !line.is_empty())
38        .filter_map(parse_session_line)
39        .collect()
40}
41
42fn parse_session_line(line: &str) -> Option<Session> {
43    let mut parts = line.splitn(3, ':');
44    let name = parts.next()?.to_string();
45    let windows = parts.next()?.parse().ok()?;
46    let attached_raw = parts.next()?;
47    Some(Session {
48        name,
49        windows,
50        attached: attached_raw == "1",
51    })
52}
53
54pub fn session_exists(name: &str) -> bool {
55    Command::new("tmux")
56        .args(["has-session", "-t", name])
57        .stdout(Stdio::null())
58        .stderr(Stdio::null())
59        .status()
60        .map(|s| s.success())
61        .unwrap_or(false)
62}
63
64pub fn create_session(name: &str, path: &str) -> io::Result<()> {
65    let mut cmd = Command::new("tmux");
66    cmd.args(["new-session", "-d", "-s", name]);
67    if !path.is_empty() {
68        cmd.args(["-c", path]);
69    }
70    let status = cmd.status()?;
71    if !status.success() {
72        return Err(io::Error::other(format!(
73            "tmux new-session exited with status {status}"
74        )));
75    }
76    Ok(())
77}
78
79pub fn switch_session(name: &str) -> io::Result<()> {
80    // Inside tmux: switch the current client.
81    if std::env::var_os("TMUX").is_some() {
82        let status = Command::new("tmux")
83            .args(["switch-client", "-t", name])
84            .status()?;
85        if !status.success() {
86            return Err(io::Error::other(format!(
87                "tmux switch-client exited with status {status}"
88            )));
89        }
90        return Ok(());
91    }
92    // Otherwise attach — inherit stdio so the user sees the session.
93    let status = Command::new("tmux")
94        .args(["attach-session", "-t", name])
95        .stdin(Stdio::inherit())
96        .stdout(Stdio::inherit())
97        .stderr(Stdio::inherit())
98        .status()?;
99    if !status.success() {
100        return Err(io::Error::other(format!(
101            "tmux attach-session exited with status {status}"
102        )));
103    }
104    Ok(())
105}
106
107/// Capture the visible content of a single tmux pane as plain text.
108/// `target` is the `session:window.pane` form. ANSI escapes are stripped
109/// by tmux (no `-e`), which is what we want for pattern matching.
110pub fn capture_pane(target: &str) -> io::Result<String> {
111    let output = Command::new("tmux")
112        .args(["capture-pane", "-p", "-t", target])
113        .output()?;
114    if !output.status.success() {
115        return Err(io::Error::other(format!(
116            "tmux capture-pane -t {target} failed: {}",
117            String::from_utf8_lossy(&output.stderr).trim()
118        )));
119    }
120    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
121}
122
123/// Switch the current client to a specific pane (`session:window.pane`).
124/// `switch-client -t session:window.pane` is unreliable across tmux versions
125/// for selecting the window/pane within the session, so we do the dance
126/// explicitly: select-pane first (which carries window selection), then
127/// switch the client. All errors are surfaced.
128pub fn switch_to_pane(target: &str) -> io::Result<()> {
129    let session = target.split(':').next().unwrap_or(target);
130    let status = Command::new("tmux")
131        .args(["select-pane", "-t", target])
132        .status()?;
133    if !status.success() {
134        return Err(io::Error::other(format!(
135            "tmux select-pane -t {target} exited with status {status}"
136        )));
137    }
138    if std::env::var_os("TMUX").is_some() {
139        let status = Command::new("tmux")
140            .args(["switch-client", "-t", session])
141            .status()?;
142        if !status.success() {
143            return Err(io::Error::other(format!(
144                "tmux switch-client -t {session} exited with status {status}"
145            )));
146        }
147    } else {
148        let status = Command::new("tmux")
149            .args(["attach-session", "-t", session])
150            .stdin(Stdio::inherit())
151            .stdout(Stdio::inherit())
152            .stderr(Stdio::inherit())
153            .status()?;
154        if !status.success() {
155            return Err(io::Error::other(format!(
156                "tmux attach-session -t {session} exited with status {status}"
157            )));
158        }
159    }
160    Ok(())
161}
162
163pub fn kill_session(name: &str) -> io::Result<()> {
164    let status = Command::new("tmux")
165        .args(["kill-session", "-t", name])
166        .status()?;
167    if !status.success() {
168        return Err(io::Error::other(format!(
169            "tmux kill-session exited with status {status}"
170        )));
171    }
172    Ok(())
173}
174
175/// Kill a session by its stable id (e.g. `$3`). Safer than by name when
176/// sessions may be renamed concurrently.
177pub fn kill_session_id(id: &str) -> io::Result<()> {
178    let status = Command::new("tmux")
179        .args(["kill-session", "-t", id])
180        .status()?;
181    if !status.success() {
182        return Err(io::Error::other(format!(
183            "tmux kill-session exited with status {status}"
184        )));
185    }
186    Ok(())
187}
188
189/// Return the current tmux session id (e.g. `$3`), or `None` if not inside
190/// a tmux server or the query fails.
191pub fn current_session_id() -> Option<String> {
192    let output = Command::new("tmux")
193        .args(["display-message", "-p", "#{session_id}"])
194        .output()
195        .ok()?;
196    if !output.status.success() {
197        return None;
198    }
199    let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
200    if id.is_empty() { None } else { Some(id) }
201}
202
203pub fn apply_layout(session_name: &str, _project_type: &str) -> io::Result<()> {
204    Command::new("tmux")
205        .args([
206            "rename-window",
207            "-t",
208            &format!("{session_name}:0"),
209            "editor",
210        ])
211        .stdout(Stdio::null())
212        .stderr(Stdio::null())
213        .status()
214        .ok();
215    Ok(())
216}
217
218pub fn cleanup_sessions() -> io::Result<Vec<String>> {
219    let sessions = list_sessions()?;
220    let mut killed = Vec::new();
221    for session in sessions {
222        if !session.attached && kill_session(&session.name).is_ok() {
223            killed.push(session.name);
224        }
225    }
226    Ok(killed)
227}
228
229/// Normalise a tmux session name. Mirrors the convention used elsewhere
230/// (dots and slashes become safe characters).
231pub fn safe_session_name(repo: &str, branch: &str) -> String {
232    let combined = format!("{repo}-{branch}");
233    combined.replace('/', "-").replace('.', "_")
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn parse_sessions_handles_multiple_lines() {
242        let input = "main:3:1\nside:1:0\n";
243        let sessions = parse_sessions(input);
244        assert_eq!(sessions.len(), 2);
245        assert_eq!(
246            sessions[0],
247            Session {
248                name: "main".into(),
249                windows: 3,
250                attached: true
251            }
252        );
253        assert_eq!(
254            sessions[1],
255            Session {
256                name: "side".into(),
257                windows: 1,
258                attached: false
259            }
260        );
261    }
262
263    #[test]
264    fn parse_sessions_handles_session_with_colons_in_name() {
265        // splitn(3) means trailing colons stay in the windows/attached fields,
266        // not the name. The Go version behaves identically.
267        let input = "weird:name:5:1";
268        let sessions = parse_sessions(input);
269        assert_eq!(sessions.len(), 0); // "name" is not a valid u32 → dropped
270    }
271
272    #[test]
273    fn parse_sessions_skips_empty_lines() {
274        let input = "\nfoo:1:0\n\n";
275        let sessions = parse_sessions(input);
276        assert_eq!(sessions.len(), 1);
277        assert_eq!(sessions[0].name, "foo");
278    }
279
280    #[test]
281    fn parse_sessions_empty_input() {
282        assert!(parse_sessions("").is_empty());
283        assert!(parse_sessions("\n\n").is_empty());
284    }
285
286    #[test]
287    fn safe_session_name_replaces_special_chars() {
288        assert_eq!(safe_session_name("my.repo", "feat/x"), "my_repo-feat-x");
289        assert_eq!(safe_session_name("plain", "main"), "plain-main");
290    }
291}