Skip to main content

ctx_tui/multiplexers/
zellij.rs

1use std::collections::{BTreeMap, HashMap};
2use std::io::Write;
3use std::path::Path;
4use std::process::Stdio;
5
6use sha2::{Digest, Sha256};
7
8use crate::contexts::Context;
9use crate::git::new_command;
10use crate::layout::{Node, SplitDirection, resolve_layout};
11use crate::multiplexer::{Multiplexer, MultiplexerError, env_truthy, env_var};
12use crate::shellrun::via_shell;
13
14// macOS caps sockaddr_un paths at 104 bytes including the terminator, and
15// zellij offers no working way to relocate its socket dir, so session names
16// must be short enough for the socket path to fit.
17// See https://github.com/zellij-org/zellij/issues/5081.
18const SOCKET_PATH_MAX: usize = 103;
19
20/// Longest session name whose zellij socket path still fits, if capped.
21fn session_name_budget() -> Option<usize> {
22    if !cfg!(target_os = "macos") {
23        return None;
24    }
25    // zellij 0.44 places sockets in <tmp>/zellij-<uid>/<contract version>/<name>.
26    let uid = unsafe { libc::getuid() };
27    let sock_dir = std::env::temp_dir()
28        .join(format!("zellij-{uid}"))
29        .join("contract_version_1");
30    Some(SOCKET_PATH_MAX.saturating_sub(sock_dir.to_string_lossy().len() + 1))
31}
32
33fn session_name(ctx: &Context) -> String {
34    let name = format!("{}--{}", ctx.repo, ctx.name).replace(['.', ':'], "-");
35    session_name_within(name, session_name_budget())
36}
37
38fn session_name_within(name: String, budget: Option<usize>) -> String {
39    let Some(budget) = budget else {
40        return name;
41    };
42    if name.chars().count() <= budget {
43        return name;
44    }
45    // Truncate over-budget names; a digest of the full name keeps them
46    // unique. Cut by characters — a byte index could split a multi-byte
47    // character and panic.
48    let digest: String = Sha256::digest(name.as_bytes())
49        .iter()
50        .map(|byte| format!("{byte:02x}"))
51        .collect();
52    let keep = std::cmp::max(budget.saturating_sub(7), 1);
53    let kept: String = name.chars().take(keep).collect();
54    format!("{kept}-{}", &digest[..6])
55}
56
57fn kdl_string(value: &str) -> String {
58    let escaped = value
59        .replace('\\', "\\\\")
60        .replace('"', "\\\"")
61        .replace('\n', "\\n");
62    format!("\"{escaped}\"")
63}
64
65fn render_node(node: &Node, cwd: &Path, indent: usize) -> Result<String, MultiplexerError> {
66    let pad = "    ".repeat(indent);
67    let split = match node {
68        Node::Pane(pane) => {
69            let argv = match &pane.command {
70                Some(command) => shlex::split(command).ok_or_else(|| {
71                    MultiplexerError(format!("pane command has unbalanced quoting: {command}"))
72                })?,
73                None => Vec::new(),
74            };
75            let mut line = format!("{pad}pane");
76            if let Some(program) = argv.first() {
77                line += &format!(" command={}", kdl_string(program));
78            }
79            line += &format!(" cwd={}", kdl_string(&cwd.to_string_lossy()));
80            if pane.focus {
81                line += " focus=true";
82            }
83            if argv.len() > 1 {
84                let args = argv[1..]
85                    .iter()
86                    .map(|arg| kdl_string(arg))
87                    .collect::<Vec<_>>()
88                    .join(" ");
89                line += &format!(" {{\n{pad}    args {args}\n{pad}}}");
90            }
91            return Ok(line);
92        }
93        Node::Split(split) => split,
94    };
95    // Zellij's split_direction names the split axis, not the arrangement:
96    // "vertical" puts panes side by side, "horizontal" stacks them.
97    let direction = match split.direction {
98        SplitDirection::Row => "vertical",
99        SplitDirection::Column => "horizontal",
100    };
101    let children = split
102        .panes
103        .iter()
104        .map(|pane| render_node(pane, cwd, indent + 1))
105        .collect::<Result<Vec<_>, _>>()?
106        .join("\n");
107    Ok(format!(
108        "{pad}pane split_direction=\"{direction}\" {{\n{children}\n{pad}}}"
109    ))
110}
111
112fn render_layout(layout: &Node, cwd: &Path) -> Result<String, MultiplexerError> {
113    Ok(format!(
114        "layout {{\n    default_tab_template {{\n        pane size=1 borderless=true {{\n            plugin location=\"zellij:tab-bar\"\n        }}\n        children\n        pane size=2 borderless=true {{\n            plugin location=\"zellij:status-bar\"\n        }}\n    }}\n    tab {{\n{}\n    }}\n}}\n",
115        render_node(layout, cwd, 2)?
116    ))
117}
118
119/// The process environment with every ZELLIJ variable hidden.
120///
121/// Inside a session, zellij turns any --layout invocation into new tabs of
122/// the current session and never reaches the attach subcommand; hiding the
123/// session env makes the command run as if from outside.
124fn env_without_zellij() -> Vec<(String, String)> {
125    let mut env: BTreeMap<String, String> = std::env::vars().collect();
126    #[cfg(test)]
127    crate::testutil::overlay_env(&mut env);
128    env.retain(|key, _| !key.starts_with("ZELLIJ"));
129    env.into_iter().collect()
130}
131
132/// Run a zellij invocation that must succeed, folding its own error text
133/// (stderr, else stdout) into `message` on failure.
134fn run_zellij(mut cmd: std::process::Command, message: String) -> Result<(), MultiplexerError> {
135    let output = cmd
136        .stdout(Stdio::piped())
137        .stderr(Stdio::piped())
138        .output()
139        .map_err(|err| MultiplexerError(err.to_string()))?;
140    if output.status.success() {
141        return Ok(());
142    }
143    let stderr = String::from_utf8_lossy(&output.stderr);
144    let stdout = String::from_utf8_lossy(&output.stdout);
145    let detail = if stderr.trim().is_empty() {
146        stdout.trim().to_string()
147    } else {
148        stderr.trim().to_string()
149    };
150    Err(MultiplexerError(if detail.is_empty() {
151        message
152    } else {
153        format!("{message}: {detail}")
154    }))
155}
156
157pub struct ZellijMultiplexer {
158    layout: Node,
159}
160
161impl ZellijMultiplexer {
162    pub fn new(layout: Node) -> ZellijMultiplexer {
163        ZellijMultiplexer { layout }
164    }
165
166    fn write_layout_file(
167        &self,
168        ctx: &Context,
169        values: Option<&HashMap<String, String>>,
170    ) -> Result<String, MultiplexerError> {
171        let to_mux_err = |err: std::io::Error| MultiplexerError(err.to_string());
172        let mut file = tempfile::Builder::new()
173            .prefix("ctx-")
174            .suffix(".kdl")
175            .disable_cleanup(true)
176            .tempfile()
177            .map_err(to_mux_err)?;
178        let layout = via_shell(&resolve_layout(&self.layout, values)).map_err(to_mux_err)?;
179        file.write_all(render_layout(&layout, &ctx.path)?.as_bytes())
180            .map_err(to_mux_err)?;
181        Ok(file.path().to_string_lossy().into_owned())
182    }
183}
184
185impl Multiplexer for ZellijMultiplexer {
186    fn can_open_in_place(&self) -> bool {
187        // Inside zellij, open() re-points the current client and returns.
188        env_truthy("ZELLIJ")
189    }
190
191    fn exists(&self, ctx: &Context) -> bool {
192        let output = new_command("zellij")
193            .args(["list-sessions", "--short"])
194            .stdout(Stdio::piped())
195            .stderr(Stdio::null())
196            .output();
197        // Nonzero means no zellij server is running.
198        let Ok(output) = output else {
199            return false;
200        };
201        if !output.status.success() {
202            return false;
203        }
204        let session = session_name(ctx);
205        String::from_utf8_lossy(&output.stdout)
206            .lines()
207            .any(|line| line == session)
208    }
209
210    fn is_current(&self, ctx: &Context) -> bool {
211        env_var("ZELLIJ_SESSION_NAME").as_deref() == Some(session_name(ctx).as_str())
212    }
213
214    fn create(
215        &self,
216        ctx: &Context,
217        values: Option<&HashMap<String, String>>,
218    ) -> Result<(), MultiplexerError> {
219        if self.exists(ctx) {
220            return Ok(());
221        }
222        let session = session_name(ctx);
223        let layout_file = self.write_layout_file(ctx, values)?;
224        let mut cmd = std::process::Command::new("zellij");
225        cmd.env_clear().envs(env_without_zellij());
226        cmd.args([
227            "--layout",
228            &layout_file,
229            "attach",
230            "--create-background",
231            &session,
232        ]);
233        run_zellij(cmd, format!("zellij could not create '{session}'"))
234    }
235
236    fn open(
237        &self,
238        ctx: &Context,
239        values: Option<&HashMap<String, String>>,
240    ) -> Result<(), MultiplexerError> {
241        use std::os::unix::process::CommandExt;
242
243        let session = session_name(ctx);
244        let exists = self.exists(ctx);
245        if env_truthy("ZELLIJ") {
246            // A nested `zellij attach` cannot run inside a session, so re-point
247            // the already-attached client instead (zellij >= 0.44). The layout
248            // only takes effect when the target session doesn't exist yet.
249            let mut args = vec![
250                "action".to_string(),
251                "switch-session".to_string(),
252                session.clone(),
253            ];
254            if !exists {
255                args.push("--layout".to_string());
256                args.push(self.write_layout_file(ctx, values)?);
257            }
258            let mut cmd = new_command("zellij");
259            cmd.args(&args);
260            return run_zellij(cmd, format!("zellij could not switch to '{session}'"));
261        }
262        let err = if exists {
263            new_command("zellij").args(["attach", &session]).exec()
264        } else {
265            let layout_file = self.write_layout_file(ctx, values)?;
266            new_command("zellij")
267                .args([
268                    "--session",
269                    &session,
270                    "--new-session-with-layout",
271                    &layout_file,
272                ])
273                .exec()
274        };
275        Err(MultiplexerError(format!("could not exec zellij: {err}")))
276    }
277
278    fn kill(&self, ctx: &Context) -> Result<(), MultiplexerError> {
279        let session = session_name(ctx);
280        let output = new_command("zellij")
281            .args(["delete-session", "--force", &session])
282            .stdout(Stdio::null())
283            .stderr(Stdio::piped())
284            .output()
285            .map_err(|err| MultiplexerError(err.to_string()))?;
286        if !output.status.success() {
287            return Err(super::CmdError {
288                argv: vec![
289                    "zellij".to_string(),
290                    "delete-session".to_string(),
291                    "--force".to_string(),
292                    session,
293                ],
294                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
295            }
296            .into());
297        }
298        Ok(())
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use std::path::PathBuf;
305
306    use super::*;
307    use crate::layout::{Pane, Split};
308    use crate::testutil::{push_env, test_env};
309
310    fn ctx(repo: &str, name: &str) -> Context {
311        Context {
312            repo: repo.to_string(),
313            name: name.to_string(),
314            path: PathBuf::from("/w"),
315        }
316    }
317
318    fn pane(command: &str) -> Node {
319        Node::Pane(Pane {
320            command: Some(command.to_string()),
321            ..Pane::default()
322        })
323    }
324
325    #[test]
326    fn session_name_replaces_forbidden_characters() {
327        assert_eq!(
328            session_name_within("my.repo--a:b".replace(['.', ':'], "-"), None),
329            "my-repo--a-b"
330        );
331    }
332
333    #[test]
334    fn session_name_within_budget_is_unchanged() {
335        assert_eq!(
336            session_name_within("repo--short".to_string(), Some(20)),
337            "repo--short"
338        );
339    }
340
341    #[test]
342    fn session_name_over_budget_is_shortened() {
343        let name = session_name_within("repo--a-very-long-context-name".to_string(), Some(20));
344
345        assert_eq!(name.len(), 20);
346        assert!(name.starts_with("repo--a-very-"));
347    }
348
349    #[test]
350    fn session_name_survives_a_zero_budget() {
351        // A very long TMPDIR can eat the whole socket-path budget; the name
352        // must still shorten instead of underflowing or panicking.
353        let name = session_name_within("repo--name".to_string(), Some(0));
354
355        assert!(
356            name.len() <= 8,
357            "budget 0 must yield a minimal name: {name}"
358        );
359    }
360
361    #[test]
362    fn session_name_truncates_multibyte_names_by_character() {
363        let name = session_name_within("repo--überlanger-kontext-name".to_string(), Some(20));
364
365        assert_eq!(name.chars().count(), 20);
366        assert!(name.starts_with("repo--über"));
367    }
368
369    #[test]
370    fn shortened_session_names_stay_unique() {
371        let first = session_name_within("repo--a-very-long-context-name".to_string(), Some(20));
372        let second = session_name_within("repo--a-very-long-context-nam2".to_string(), Some(20));
373
374        assert_ne!(first, second);
375    }
376
377    #[test]
378    fn is_current_matches_the_session_env() {
379        let mux = ZellijMultiplexer::new(Node::Pane(Pane::default()));
380        let context = ctx("repo", "a");
381
382        {
383            let _env = push_env("ZELLIJ_SESSION_NAME", &session_name(&context));
384            assert!(mux.is_current(&context));
385        }
386        {
387            let _env = push_env("ZELLIJ_SESSION_NAME", "elsewhere");
388            assert!(!mux.is_current(&context));
389        }
390    }
391
392    #[test]
393    fn layout_puts_pane_in_cwd() {
394        let out = render_layout(&Node::Pane(Pane::default()), Path::new("/w")).unwrap();
395
396        assert!(out.contains("pane cwd=\"/w\""));
397    }
398
399    #[test]
400    fn layout_splits_command_into_args() {
401        let out = render_layout(&pane("nvim -R file.txt"), Path::new("/w")).unwrap();
402
403        assert!(out.contains("pane command=\"nvim\" cwd=\"/w\""));
404        assert!(out.contains("args \"-R\" \"file.txt\""));
405    }
406
407    #[test]
408    fn create_makes_a_background_session() {
409        let env = test_env();
410        let args_log = env.root().join("zellij-args.log");
411        let env_log = env.root().join("zellij-env.log");
412        // Only the create invocation must hide the session env; the exists()
413        // probe legitimately runs with it.
414        let script = format!(
415            "{{ printf '%s\\037' \"$@\"; printf '\\n'; }} >> {args}\n\
416             case \"$*\" in *--create-background*) printenv | grep '^ZELLIJ' >> {env} || true;; esac\n\
417             exit 0",
418            args = args_log.display(),
419            env = env_log.display()
420        );
421        let _guard = env.fake_cli("zellij", &script);
422        let _zellij = push_env("ZELLIJ", "0");
423        let _session = push_env("ZELLIJ_SESSION_NAME", "elsewhere");
424        let mux = ZellijMultiplexer::new(Node::Pane(Pane::default()));
425
426        mux.create(&ctx("repo", "a"), Some(&HashMap::new()))
427            .unwrap();
428
429        let text = std::fs::read_to_string(&args_log).unwrap();
430        let create_line = text
431            .lines()
432            .find(|line| line.contains("--create-background"))
433            .expect("create invocation logged");
434        let args: Vec<&str> = create_line
435            .split('\x1f')
436            .filter(|s| !s.is_empty())
437            .collect();
438        assert_eq!(args[0], "--layout");
439        assert_eq!(args[2..], ["attach", "--create-background", "repo--a"]);
440        // With the session env visible, zellij would open the layout as new
441        // tabs of the current session instead of creating one.
442        let leaked = std::fs::read_to_string(&env_log).unwrap_or_default();
443        assert_eq!(leaked.trim(), "");
444    }
445
446    #[test]
447    fn layout_file_resolves_builtin_panes() {
448        let mux = ZellijMultiplexer::new(Node::Pane(Pane {
449            builtin: Some("claude".to_string()),
450            ..Pane::default()
451        }));
452        let values = HashMap::from([("prompt".to_string(), "explore x".to_string())]);
453
454        let layout_file = mux
455            .write_layout_file(&ctx("repo", "a"), Some(&values))
456            .unwrap();
457
458        let content = std::fs::read_to_string(&layout_file).unwrap();
459        assert!(content.contains("command=\"sh\""));
460        let args_start = content.find("args \"").expect("args in layout") + 6;
461        let script_path =
462            &content[args_start..content[args_start..].find('"').unwrap() + args_start];
463        let script = std::fs::read_to_string(script_path).unwrap();
464        assert!(script.contains("ctx builtin claude trust; exec claude '\"'\"'explore x'\"'\"'"));
465    }
466
467    #[test]
468    fn layout_escapes_kdl_strings() {
469        let out = render_layout(&pane("claude 'say \"hi\"'"), Path::new("/w")).unwrap();
470
471        assert!(out.contains("args \"say \\\"hi\\\"\""));
472    }
473
474    #[test]
475    fn layout_marks_focus() {
476        let node = Node::Pane(Pane {
477            command: Some("nvim".to_string()),
478            focus: true,
479            ..Pane::default()
480        });
481
482        assert!(
483            render_layout(&node, Path::new("/w"))
484                .unwrap()
485                .contains("focus=true")
486        );
487    }
488
489    #[test]
490    fn layout_maps_row_to_vertical_split() {
491        let node = Node::Split(Split {
492            direction: SplitDirection::Row,
493            panes: vec![Node::Pane(Pane::default()), Node::Pane(Pane::default())],
494        });
495
496        let out = render_layout(&node, Path::new("/w")).unwrap();
497
498        assert!(out.contains("split_direction=\"vertical\""));
499    }
500
501    #[test]
502    fn layout_maps_column_to_horizontal_split() {
503        let node = Node::Split(Split {
504            direction: SplitDirection::Column,
505            panes: vec![Node::Pane(Pane::default()), Node::Pane(Pane::default())],
506        });
507
508        let out = render_layout(&node, Path::new("/w")).unwrap();
509
510        assert!(out.contains("split_direction=\"horizontal\""));
511    }
512}