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(None, &["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/// Returns the number of panes in the current tab.
176///
177/// # Errors
178/// - Invoking `zellij action list-panes` fails.
179pub fn pane_count() -> rootcause::Result<usize> {
180    let output = Command::new(BIN).args(["action", "list-panes"]).exec()?;
181    Ok(output.stdout.split(|&b| b == b'\n').filter(|l| !l.is_empty()).count())
182}
183
184/// Runs `zellij [--session <session>] action <args...>`.
185///
186/// # Errors
187/// - The `zellij` binary cannot be spawned or returns a nonzero exit status.
188pub fn action(session: Option<&str>, args: &[&str]) -> rootcause::Result<()> {
189    ytil_cmd::silent_cmd(BIN)
190        .args(zellij_action_args(session, args))
191        .exec()?;
192    Ok(())
193}
194
195/// Focuses a terminal pane by first selecting its tab and then selecting its pane.
196///
197/// # Errors
198/// - Either underlying `zellij action` call fails.
199pub fn focus_tab_terminal_pane(session: Option<&str>, tab_id: usize, pane_id: u32) -> rootcause::Result<()> {
200    let tab_id = tab_id.to_string();
201    let pane_id = format!("terminal_{pane_id}");
202    let tab_result = action(session, &["go-to-tab-by-id", &tab_id]);
203    let pane_result = action(session, &["focus-pane-id", &pane_id]);
204    tab_result?;
205    pane_result?;
206    Ok(())
207}
208
209/// Returns the running command of the currently focused pane by parsing `zellij action list-clients`.
210///
211/// Returns `None` if the command column is empty (default shell) or parsing fails.
212///
213/// # Errors
214/// - Invoking `zellij action list-clients` fails.
215pub fn focused_pane_command() -> rootcause::Result<Option<String>> {
216    let output = Command::new(BIN).args(["action", "list-clients"]).exec()?;
217    let stdout = String::from_utf8_lossy(&output.stdout);
218    let cmd = stdout
219        .lines()
220        .nth(1)
221        .and_then(|line| line.split_whitespace().nth(2))
222        .map(String::from);
223    Ok(cmd)
224}
225
226/// Moves focus to the pane in the given direction.
227///
228/// # Errors
229/// - The underlying `zellij action` call fails.
230pub fn move_focus(direction: Direction) -> rootcause::Result<()> {
231    action(None, &["move-focus", direction.as_str()])
232}
233
234/// Sends a raw byte (e.g. `27` for ESC) to the focused pane.
235///
236/// # Errors
237/// - The underlying `zellij action` call fails.
238pub fn write_byte(byte: u8) -> rootcause::Result<()> {
239    let s = byte.to_string();
240    action(None, &["write", &s])
241}
242
243/// Types a string into the focused pane as if the user typed it.
244///
245/// # Errors
246/// - The underlying `zellij action` call fails.
247pub fn write_chars(text: &str) -> rootcause::Result<()> {
248    action(None, &["write-chars", text])
249}
250
251/// Opens `$EDITOR` on `path` in a new pane in the given direction.
252///
253/// # Errors
254/// - The underlying `zellij action` call fails.
255pub fn edit(path: &str, direction: Direction, line_number: Option<i64>) -> rootcause::Result<()> {
256    let lnum_str;
257    let dir = direction.as_str();
258    let mut args = vec!["edit", "--direction", dir, path];
259    if let Some(n) = line_number {
260        lnum_str = n.to_string();
261        args.extend(["--line-number", &lnum_str]);
262    }
263    action(None, &args)
264}
265
266/// Opens a new pane running `command` in the given direction.
267///
268/// # Errors
269/// - The underlying `zellij action` call fails.
270pub fn new_pane(direction: Direction, command: &[&str]) -> rootcause::Result<()> {
271    let mut args = vec!["new-pane", "--direction", direction.as_str(), "--"];
272    args.extend(command);
273    action(None, &args)
274}
275
276/// Calls `zellij action resize increase <direction>` the given number of times.
277///
278/// # Errors
279/// - The underlying `zellij action` call fails.
280pub fn resize_increase(direction: Direction, times: u32) -> rootcause::Result<()> {
281    for _ in 0..times {
282        action(None, &["resize", "increase", direction.as_str()])?;
283    }
284    Ok(())
285}
286
287/// Runs a [`Command`] with inherited stdio so the child process can interact with
288/// the terminal directly (preserving ANSI colors, TTY detection, and interactivity).
289///
290/// Because stdio is inherited rather than captured, `stderr` and `stdout` in the
291/// returned [`CmdError::CmdFailure`](ytil_cmd::CmdError::CmdFailure) are always
292/// empty — the user already saw whatever the child printed.
293fn run_interactive(cmd: &mut Command) -> Result<(), Box<ytil_cmd::CmdError>> {
294    let status = cmd.status().map_err(|source| {
295        Box::new(ytil_cmd::CmdError::Io {
296            cmd: ytil_cmd::Cmd::from(&*cmd),
297            source,
298        })
299    })?;
300    if !status.success() {
301        return Err(Box::new(ytil_cmd::CmdError::CmdFailure {
302            cmd: ytil_cmd::Cmd::from(&*cmd),
303            stderr: String::new(),
304            stdout: String::new(),
305            status,
306        }));
307    }
308    Ok(())
309}
310
311fn zellij_action_args(session: Option<&str>, action_args: &[&str]) -> Vec<String> {
312    let mut args = Vec::new();
313    if let Some(session) = session {
314        args.push("--session".to_string());
315        args.push(session.to_string());
316    }
317    args.push("action".to_string());
318    args.extend(action_args.iter().map(|arg| (*arg).to_string()));
319    args
320}
321
322#[cfg(test)]
323mod tests {
324    use rstest::rstest;
325
326    use super::*;
327
328    const ANSI_LINE: &str = "\x1b[32;1madept-viola\x1b[m [Created \x1b[35;1m15s\x1b[m ago]";
329
330    #[rstest]
331    #[case::plain_text(
332        "my-session [Created 5m ago]",
333        Session {
334            name: "my-session".into(),
335            display: "my-session [Created 5m ago]".into(),
336        },
337    )]
338    #[case::ansi_codes(
339        ANSI_LINE,
340        Session {
341            name: "adept-viola".into(),
342            display: ANSI_LINE.into(),
343        },
344    )]
345    #[case::empty_line(
346        "",
347        Session {
348            name: String::new(),
349            display: String::new(),
350        },
351    )]
352    #[case::name_only(
353        "simple",
354        Session {
355            name: "simple".into(),
356            display: "simple".into(),
357        },
358    )]
359    fn session_new_parses_list_sessions_line(#[case] input: &str, #[case] expected: Session) {
360        pretty_assertions::assert_eq!(Session::new(input), expected);
361    }
362
363    #[test]
364    fn test_zellij_action_args_with_session_include_session_and_action() {
365        pretty_assertions::assert_eq!(
366            zellij_action_args(Some("work"), &["focus-pane-id", "terminal_42"]),
367            vec![
368                "--session".to_string(),
369                "work".to_string(),
370                "action".to_string(),
371                "focus-pane-id".to_string(),
372                "terminal_42".to_string(),
373            ],
374        );
375    }
376
377    #[test]
378    fn test_zellij_action_args_without_session_target_current_session() {
379        pretty_assertions::assert_eq!(
380            zellij_action_args(None, &["go-to-tab-by-id", "10"]),
381            vec!["action".to_string(), "go-to-tab-by-id".to_string(), "10".to_string(),],
382        );
383    }
384}