Skip to main content

ctx_tui/multiplexers/
tmux.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::process::Stdio;
4
5use super::CmdError;
6use crate::contexts::Context;
7use crate::git::new_command;
8use crate::layout::{Node, Pane, SplitDirection, resolve_layout};
9use crate::multiplexer::{Multiplexer, MultiplexerError, env_truthy};
10use crate::shellrun::via_shell;
11
12fn session_name(ctx: &Context) -> String {
13    let raw = format!("{}--{}", ctx.repo, ctx.name);
14    // tmux forbids '.' and ':' in session names.
15    raw.replace(['.', ':'], "-")
16}
17
18fn tmux(args: &[&str]) -> Result<String, CmdError> {
19    let argv = || {
20        ["tmux"]
21            .iter()
22            .copied()
23            .chain(args.iter().copied())
24            .map(str::to_string)
25            .collect()
26    };
27    let output = new_command("tmux")
28        .args(args)
29        .stdout(Stdio::piped())
30        .stderr(Stdio::piped())
31        .output()
32        .map_err(|err| CmdError {
33            argv: argv(),
34            stderr: err.to_string(),
35        })?;
36    if !output.status.success() {
37        return Err(CmdError {
38            argv: argv(),
39            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
40        });
41    }
42    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
43}
44
45/// The leaf a split hands its original region to.
46fn first_pane(node: &Node) -> &Pane {
47    match node {
48        Node::Pane(pane) => pane,
49        Node::Split(split) => first_pane(&split.panes[0]),
50    }
51}
52
53/// Subdivide pane_id according to the layout, returning (pane_id, pane) leaves.
54fn build<'a>(
55    node: &'a Node,
56    pane_id: &str,
57    cwd: &Path,
58) -> Result<Vec<(String, &'a Pane)>, CmdError> {
59    let split = match node {
60        Node::Pane(pane) => return Ok(vec![(pane_id.to_string(), pane)]),
61        Node::Split(split) => split,
62    };
63    let flag = match split.direction {
64        SplitDirection::Row => "-h",
65        SplitDirection::Column => "-v",
66    };
67    let cwd_str = cwd.to_string_lossy();
68    let mut regions = vec![pane_id.to_string()];
69    for child in &split.panes[1..] {
70        let target = regions.last().expect("regions starts non-empty").clone();
71        let mut args = vec![
72            "split-window",
73            flag,
74            "-t",
75            &target,
76            "-c",
77            &cwd_str,
78            "-P",
79            "-F",
80            "#{pane_id}",
81        ];
82        let command = &first_pane(child).command;
83        if let Some(command) = command {
84            args.push(command);
85        }
86        regions.push(tmux(&args)?);
87    }
88    let mut leaves = Vec::new();
89    for (child, region) in split.panes.iter().zip(&regions) {
90        leaves.extend(build(child, region, cwd)?);
91    }
92    Ok(leaves)
93}
94
95fn create_session(session: &str, cwd: &Path, layout: &Node) -> Result<(), MultiplexerError> {
96    // Commands run as the panes' start commands: delivering them by typing
97    // into a shell instead races its startup, and the kernel's canonical
98    // line buffer truncates what arrives too early to 1024 bytes on macOS.
99    let cwd_str = cwd.to_string_lossy();
100    let mut args = vec![
101        "new-session",
102        "-d",
103        "-s",
104        session,
105        "-c",
106        &cwd_str,
107        "-P",
108        "-F",
109        "#{pane_id}",
110    ];
111    let command = &first_pane(layout).command;
112    if let Some(command) = command {
113        args.push(command);
114    }
115    let built = tmux(&args).and_then(|first| build(layout, &first, cwd));
116    let leaves = match built {
117        Ok(leaves) => leaves,
118        Err(err) => {
119            let _ = tmux(&["kill-session", "-t", &format!("={session}")]);
120            if err.stderr.contains("command too long") {
121                return Err(MultiplexerError(
122                    "a pane command exceeds tmux's ~16KB limit".to_string(),
123                ));
124            }
125            return Err(err.into());
126        }
127    };
128    let focused = leaves
129        .iter()
130        .find(|(_, pane)| pane.focus)
131        .or_else(|| leaves.first())
132        .map(|(pane_id, _)| pane_id.clone())
133        .expect("a layout always has at least one pane");
134    tmux(&["select-pane", "-t", &focused])?;
135    Ok(())
136}
137
138pub struct TmuxMultiplexer {
139    layout: Node,
140}
141
142impl TmuxMultiplexer {
143    pub fn new(layout: Node) -> TmuxMultiplexer {
144        TmuxMultiplexer { layout }
145    }
146}
147
148impl Multiplexer for TmuxMultiplexer {
149    fn can_open_in_place(&self) -> bool {
150        // Inside tmux, open() switches the client and returns.
151        env_truthy("TMUX")
152    }
153
154    fn exists(&self, ctx: &Context) -> bool {
155        new_command("tmux")
156            .args(["has-session", "-t", &format!("={}", session_name(ctx))])
157            .stdout(Stdio::null())
158            .stderr(Stdio::null())
159            .status()
160            .map(|status| status.success())
161            .unwrap_or(false)
162    }
163
164    fn is_current(&self, ctx: &Context) -> bool {
165        if !env_truthy("TMUX") {
166            return false;
167        }
168        tmux(&["display-message", "-p", "#S"])
169            .map(|session| session == session_name(ctx))
170            .unwrap_or(false)
171    }
172
173    fn create(
174        &self,
175        ctx: &Context,
176        values: Option<&HashMap<String, String>>,
177    ) -> Result<(), MultiplexerError> {
178        if !self.exists(ctx) {
179            let layout = via_shell(&resolve_layout(&self.layout, values))
180                .map_err(|err| MultiplexerError(err.to_string()))?;
181            create_session(&session_name(ctx), &ctx.path, &layout)?;
182        }
183        Ok(())
184    }
185
186    fn open(
187        &self,
188        ctx: &Context,
189        values: Option<&HashMap<String, String>>,
190    ) -> Result<(), MultiplexerError> {
191        let session = session_name(ctx);
192        self.create(ctx, values)?;
193        if env_truthy("TMUX") {
194            tmux(&["switch-client", "-t", &format!("={session}")])?;
195            Ok(())
196        } else {
197            use std::os::unix::process::CommandExt;
198
199            let err = new_command("tmux")
200                .args(["attach-session", "-t", &format!("={session}")])
201                .exec();
202            Err(MultiplexerError(format!("could not exec tmux: {err}")))
203        }
204    }
205
206    fn kill(&self, ctx: &Context) -> Result<(), MultiplexerError> {
207        tmux(&["kill-session", "-t", &format!("={}", session_name(ctx))])?;
208        Ok(())
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use std::path::PathBuf;
215
216    use super::*;
217    use crate::testutil::{push_env, test_env};
218
219    fn ctx(repo: &str, name: &str) -> Context {
220        Context {
221            repo: repo.to_string(),
222            name: name.to_string(),
223            path: PathBuf::from("/w"),
224        }
225    }
226
227    /// A tmux stub that logs every invocation (unit-separated args, one call
228    /// per line) and answers -P queries with a fresh pane id.
229    fn stub_tmux(
230        env: &crate::testutil::TestEnv,
231        extra: &str,
232    ) -> (PathBuf, crate::testutil::EnvGuard) {
233        let log = env.root().join("tmux.log");
234        let script = format!(
235            "{{ printf '%s\\037' \"$@\"; printf '\\n'; }} >> {log}\n{extra}\necho \"%$(grep -c '' {log})\"",
236            log = log.display()
237        );
238        let guard = env.fake_cli("tmux", &script);
239        (log, guard)
240    }
241
242    fn calls(log: &Path) -> Vec<Vec<String>> {
243        let Ok(text) = std::fs::read_to_string(log) else {
244            return Vec::new();
245        };
246        text.lines()
247            .map(|line| {
248                line.split('\x1f')
249                    .filter(|part| !part.is_empty())
250                    .map(str::to_string)
251                    .collect()
252            })
253            .collect()
254    }
255
256    #[test]
257    fn session_name_replaces_forbidden_characters() {
258        assert_eq!(session_name(&ctx("my.repo", "a:b")), "my-repo--a-b");
259    }
260
261    #[test]
262    fn is_current_is_false_outside_tmux() {
263        let _tmux_unset = push_env("TMUX", "");
264
265        assert!(!TmuxMultiplexer::new(Node::Pane(Pane::default())).is_current(&ctx("repo", "a")));
266    }
267
268    #[test]
269    fn is_current_compares_the_attached_session() {
270        let env = test_env();
271        let _guard = env.fake_cli("tmux", "echo 'repo--a'");
272        let _tmux = push_env("TMUX", "/tmp/tmux-1/default,1,0");
273        let mux = TmuxMultiplexer::new(Node::Pane(Pane::default()));
274
275        assert!(mux.is_current(&ctx("repo", "a")));
276        assert!(!mux.is_current(&ctx("repo", "b")));
277    }
278
279    #[test]
280    fn open_resolves_builtin_panes_on_session_creation() {
281        let env = test_env();
282        // has-session fails: the session does not exist yet.
283        let (log, _guard) = stub_tmux(&env, "[ \"$1\" = has-session ] && exit 1");
284        let _tmux = push_env("TMUX", "/tmp/tmux-1/default,1,0");
285        let layout = Node::Pane(Pane {
286            builtin: Some("claude".to_string()),
287            ..Pane::default()
288        });
289        let mux = TmuxMultiplexer::new(layout);
290        let values = HashMap::from([("prompt".to_string(), "hi".to_string())]);
291
292        mux.open(&ctx("repo", "a"), Some(&values)).unwrap();
293
294        let new_sessions: Vec<_> = calls(&log)
295            .into_iter()
296            .filter(|call| call[0] == "new-session")
297            .collect();
298        assert_eq!(new_sessions.len(), 1);
299        let launcher = shlex::split(new_sessions[0].last().unwrap()).unwrap();
300        assert_eq!(launcher[0], "sh");
301        let script = std::fs::read_to_string(&launcher[1]).unwrap();
302        assert!(script.contains("exec sh -c 'ctx builtin claude trust; exec claude hi' <&9 9<&-"));
303    }
304
305    #[test]
306    fn split_panes_start_their_own_commands() {
307        let env = test_env();
308        let (log, _guard) = stub_tmux(&env, "");
309        let layout = Node::Split(crate::layout::Split {
310            direction: SplitDirection::Row,
311            panes: vec![
312                Node::Pane(Pane {
313                    command: Some("nvim".to_string()),
314                    ..Pane::default()
315                }),
316                Node::Pane(Pane::default()),
317                Node::Pane(Pane {
318                    command: Some("htop".to_string()),
319                    ..Pane::default()
320                }),
321            ],
322        });
323
324        create_session("s", Path::new("/w"), &layout).unwrap();
325
326        let calls = calls(&log);
327        let new_sessions: Vec<_> = calls.iter().filter(|c| c[0] == "new-session").collect();
328        assert_eq!(new_sessions.len(), 1);
329        assert_eq!(new_sessions[0].last().unwrap(), "nvim");
330        let splits: Vec<_> = calls.iter().filter(|c| c[0] == "split-window").collect();
331        assert_eq!(splits.len(), 2);
332        assert_eq!(splits[0].last().unwrap(), "#{pane_id}");
333        assert_eq!(splits[1].last().unwrap(), "htop");
334    }
335
336    #[test]
337    fn reports_an_over_long_pane_command() {
338        let env = test_env();
339        let log = env.root().join("tmux.log");
340        let script = format!(
341            "{{ printf '%s\\037' \"$@\"; printf '\\n'; }} >> {log}\n\
342             [ \"$1\" = new-session ] && {{ echo 'command too long' >&2; exit 1; }}\n\
343             exit 0",
344            log = log.display()
345        );
346        let _guard = env.fake_cli("tmux", &script);
347        let layout = Node::Pane(Pane {
348            command: Some(format!("claude {}", "x".repeat(20_000))),
349            ..Pane::default()
350        });
351
352        let err = create_session("s", Path::new("/w"), &layout).expect_err("must fail");
353
354        assert!(err.to_string().contains("16KB"));
355        let kills: Vec<_> = calls(&log)
356            .into_iter()
357            .filter(|call| call[0] == "kill-session")
358            .collect();
359        assert_eq!(kills, vec![vec!["kill-session", "-t", "=s"]]);
360    }
361
362    #[test]
363    fn create_does_not_attach() {
364        let env = test_env();
365        let (log, _guard) = stub_tmux(&env, "[ \"$1\" = has-session ] && exit 1");
366        let _tmux = push_env("TMUX", "/tmp/tmux-1/default,1,0");
367        let mux = TmuxMultiplexer::new(Node::Pane(Pane::default()));
368
369        mux.create(&ctx("repo", "a"), Some(&HashMap::new()))
370            .unwrap();
371
372        let calls = calls(&log);
373        assert!(calls.iter().any(|call| call[0] == "new-session"));
374        assert!(
375            !calls
376                .iter()
377                .any(|call| call[0] == "switch-client" || call[0] == "attach-session")
378        );
379    }
380}