Skip to main content

ytil_zellij/
lib.rs

1//! Query and control `Zellij` sessions and panes via the CLI.
2//!
3//! Thin wrappers around `zellij` subcommands for session management, pane control, focus,
4//! and text injection.
5
6use std::process::Command;
7
8use rootcause::prelude::ResultExt;
9use ytil_cmd::CmdExt;
10
11const BIN: &str = "zellij";
12
13/// Cardinal direction for pane operations.
14#[derive(Clone, Copy, Debug)]
15pub enum Direction {
16    Up,
17    Down,
18    Left,
19    Right,
20}
21
22impl Direction {
23    const fn as_str(self) -> &'static str {
24        match self {
25            Self::Up => "up",
26            Self::Down => "down",
27            Self::Left => "left",
28            Self::Right => "right",
29        }
30    }
31}
32
33/// Returns `true` when the process is running inside a Zellij session.
34pub fn is_active() -> bool {
35    std::env::var_os("ZELLIJ").is_some()
36}
37
38/// Returns all running Zellij sessions as `(name, display)` pairs.
39///
40/// `name` is the session name with ANSI codes stripped,
41/// `display` is the full ANSI-formatted line from `list-sessions`.
42///
43/// # Errors
44/// - Invoking `zellij list-sessions` fails.
45pub fn list_sessions() -> rootcause::Result<Vec<Session>> {
46    let mut cmd = Command::new(BIN);
47    cmd.args(["list-sessions"]);
48    let output = cmd
49        .output()
50        .map_err(|source| ytil_cmd::CmdError::Io {
51            cmd: ytil_cmd::Cmd::from(&cmd),
52            source,
53        })
54        .attach("operation=list-sessions")?;
55    let stdout = String::from_utf8_lossy(&output.stdout);
56
57    Ok(stdout.lines().filter(|l| !l.is_empty()).map(Session::new).collect())
58}
59
60/// A Zellij session with its plain name and ANSI-formatted display string.
61#[derive(Debug)]
62#[cfg_attr(test, derive(Eq, PartialEq))]
63pub struct Session {
64    /// Plain session name suitable for `kill-session`.
65    pub name: String,
66    /// Full ANSI-formatted line from `list-sessions`.
67    pub display: String,
68}
69
70impl Session {
71    /// Parses a line from `zellij list-sessions` into a [`Session`].
72    ///
73    /// Strips ANSI escape codes and takes the first whitespace-delimited token as the name.
74    fn new(line: &str) -> Self {
75        let mut plain = String::with_capacity(line.len());
76        let mut in_escape = false;
77        for c in line.chars() {
78            match (in_escape, c) {
79                (true, c) if c.is_ascii_alphabetic() => in_escape = false,
80                (false, '\x1b') => in_escape = true,
81                (false, c) => plain.push(c),
82                _ => {}
83            }
84        }
85
86        Self {
87            name: plain.split_whitespace().next().unwrap_or_default().to_string(),
88            display: line.to_string(),
89        }
90    }
91}
92
93/// Forwards arbitrary arguments to `zellij` with inherited stdio.
94///
95/// # Errors
96/// - Invoking `zellij` fails or exits with a nonzero status.
97pub fn forward(args: &[String]) -> rootcause::Result<()> {
98    let mut cmd = Command::new(BIN);
99    cmd.args(args);
100    Ok(run_interactive(&mut cmd)?)
101}
102
103/// Prints `zellij --help` directly to the terminal, preserving ANSI colors.
104///
105/// # Errors
106/// - Invoking `zellij --help` fails.
107pub fn help() -> rootcause::Result<()> {
108    let mut cmd = Command::new(BIN);
109    cmd.arg("--help");
110    Ok(run_interactive(&mut cmd)?)
111}
112
113/// Kills running Zellij session by name.
114///
115/// # Errors
116/// - Invoking `zellij kill-session` fails.
117pub fn kill_session(name: &str) -> rootcause::Result<()> {
118    ytil_cmd::silent_cmd(BIN)
119        .args(["kill-session", name])
120        .exec()
121        .attach(format!("session={name}"))?;
122    Ok(())
123}
124
125/// Attaches to a Zellij session by name.
126///
127/// When already inside Zellij, switches to the session in-place to avoid nesting.
128/// When outside, spawns an interactive `zellij attach` with inherited stdio.
129///
130/// # Errors
131/// - Invoking `zellij attach` or `zellij action switch-session` fails.
132pub fn attach_session(name: &str) -> rootcause::Result<()> {
133    if is_active() {
134        action(&["switch-session", name])
135            .attach(format!("session={name}"))
136            .attach("mode=switch")?;
137    } else {
138        let mut cmd = Command::new(BIN);
139        cmd.args(["attach", name]);
140        run_interactive(&mut cmd).attach(format!("session={name}"))?;
141    }
142    Ok(())
143}
144
145/// Creates a new Zellij session with the given layout and attaches to it.
146///
147/// Must be called from **outside** Zellij — the process replaces the current
148/// terminal with the new session (interactive, inherited stdio).
149///
150/// # Errors
151/// - Invoking `zellij` fails or exits with a nonzero exit status.
152pub fn new_session_with_layout(session: &str, layout: &str) -> rootcause::Result<()> {
153    let mut cmd = Command::new(BIN);
154    cmd.args(["--new-session-with-layout", layout, "--session", session]);
155    run_interactive(&mut cmd)
156        .attach(format!("session={session}"))
157        .attach(format!("layout={layout}"))?;
158    Ok(())
159}
160
161/// Deletes an exited (resurrectable) Zellij session by name.
162///
163/// Uses `--force` to also handle running sessions.
164///
165/// # Errors
166/// - Invoking `zellij delete-session` fails.
167pub fn delete_session(name: &str) -> rootcause::Result<()> {
168    ytil_cmd::silent_cmd(BIN)
169        .args(["delete-session", "--force", name])
170        .exec()
171        .attach(format!("session={name}"))?;
172    Ok(())
173}
174
175/// Runs a [`Command`] with inherited stdio so the child process can interact with
176/// the terminal directly (preserving ANSI colors, TTY detection, and interactivity).
177///
178/// Because stdio is inherited rather than captured, `stderr` and `stdout` in the
179/// returned [`CmdError::CmdFailure`](ytil_cmd::CmdError::CmdFailure) are always
180/// empty — the user already saw whatever the child printed.
181fn run_interactive(cmd: &mut Command) -> Result<(), Box<ytil_cmd::CmdError>> {
182    let status = cmd.status().map_err(|source| {
183        Box::new(ytil_cmd::CmdError::Io {
184            cmd: ytil_cmd::Cmd::from(&*cmd),
185            source,
186        })
187    })?;
188    if !status.success() {
189        return Err(Box::new(ytil_cmd::CmdError::CmdFailure {
190            cmd: ytil_cmd::Cmd::from(&*cmd),
191            stderr: String::new(),
192            stdout: String::new(),
193            status,
194        }));
195    }
196    Ok(())
197}
198
199/// Returns the number of panes in the current tab.
200///
201/// # Errors
202/// - Invoking `zellij action list-panes` fails.
203pub fn pane_count() -> rootcause::Result<usize> {
204    let output = Command::new(BIN).args(["action", "list-panes"]).exec()?;
205    Ok(output.stdout.split(|&b| b == b'\n').filter(|l| !l.is_empty()).count())
206}
207
208/// Runs `zellij action <args…>`.
209///
210/// # Errors
211/// - The `zellij` binary cannot be spawned or returns a nonzero exit status.
212pub fn action(args: &[&str]) -> rootcause::Result<()> {
213    let full: Vec<&str> = std::iter::once("action").chain(args.iter().copied()).collect();
214    ytil_cmd::silent_cmd(BIN).args(&full).exec()?;
215    Ok(())
216}
217
218/// Returns the running command of the currently focused pane by parsing `zellij action list-clients`.
219///
220/// Returns `None` if the command column is empty (default shell) or parsing fails.
221///
222/// # Errors
223/// - Invoking `zellij action list-clients` fails.
224pub fn focused_pane_command() -> rootcause::Result<Option<String>> {
225    let output = Command::new(BIN).args(["action", "list-clients"]).exec()?;
226    let stdout = String::from_utf8_lossy(&output.stdout);
227    let cmd = stdout
228        .lines()
229        .nth(1)
230        .and_then(|line| line.split_whitespace().nth(2))
231        .map(String::from);
232    Ok(cmd)
233}
234
235/// Moves focus to the pane in the given direction.
236///
237/// # Errors
238/// - The underlying `zellij action` call fails.
239pub fn move_focus(direction: Direction) -> rootcause::Result<()> {
240    action(&["move-focus", direction.as_str()])
241}
242
243/// Sends a raw byte (e.g. `27` for ESC) to the focused pane.
244///
245/// # Errors
246/// - The underlying `zellij action` call fails.
247pub fn write_byte(byte: u8) -> rootcause::Result<()> {
248    let s = byte.to_string();
249    action(&["write", &s])
250}
251
252/// Types a string into the focused pane as if the user typed it.
253///
254/// # Errors
255/// - The underlying `zellij action` call fails.
256pub fn write_chars(text: &str) -> rootcause::Result<()> {
257    action(&["write-chars", text])
258}
259
260/// Opens `$EDITOR` on `path` in a new pane in the given direction.
261///
262/// # Errors
263/// - The underlying `zellij action` call fails.
264pub fn edit(path: &str, direction: Direction, line_number: Option<i64>) -> rootcause::Result<()> {
265    let lnum_str;
266    let dir = direction.as_str();
267    let mut args = vec!["edit", "--direction", dir, path];
268    if let Some(n) = line_number {
269        lnum_str = n.to_string();
270        args.extend(["--line-number", &lnum_str]);
271    }
272    action(&args)
273}
274
275/// Opens a new pane running `command` in the given direction.
276///
277/// # Errors
278/// - The underlying `zellij action` call fails.
279pub fn new_pane(direction: Direction, command: &[&str]) -> rootcause::Result<()> {
280    let mut args = vec!["new-pane", "--direction", direction.as_str(), "--"];
281    args.extend(command);
282    action(&args)
283}
284
285/// Calls `zellij action resize increase <direction>` the given number of times.
286///
287/// # Errors
288/// - The underlying `zellij action` call fails.
289pub fn resize_increase(direction: Direction, times: u32) -> rootcause::Result<()> {
290    for _ in 0..times {
291        action(&["resize", "increase", direction.as_str()])?;
292    }
293    Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298    use rstest::rstest;
299
300    use super::*;
301
302    const ANSI_LINE: &str = "\x1b[32;1madept-viola\x1b[m [Created \x1b[35;1m15s\x1b[m ago]";
303
304    #[rstest]
305    #[case::plain_text(
306        "my-session [Created 5m ago]",
307        Session {
308            name: "my-session".into(),
309            display: "my-session [Created 5m ago]".into(),
310        },
311    )]
312    #[case::ansi_codes(
313        ANSI_LINE,
314        Session {
315            name: "adept-viola".into(),
316            display: ANSI_LINE.into(),
317        },
318    )]
319    #[case::empty_line(
320        "",
321        Session {
322            name: String::new(),
323            display: String::new(),
324        },
325    )]
326    #[case::name_only(
327        "simple",
328        Session {
329            name: "simple".into(),
330            display: "simple".into(),
331        },
332    )]
333    fn session_new_parses_list_sessions_line(#[case] input: &str, #[case] expected: Session) {
334        pretty_assertions::assert_eq!(Session::new(input), expected);
335    }
336}