Skip to main content

tmx/
tmux.rs

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