Skip to main content

ytil_wezterm/
lib.rs

1//! Discover `WezTerm` panes and build command strings for sending text, submitting input or activating panes.
2//!
3//! Query panes (`wezterm cli list`) and generate shell‑safe strings for actions. Helpers locate sibling
4//! panes in the same tab by title and derive absolute working directories from file URIs.
5
6use std::path::PathBuf;
7use std::process::Command;
8
9use rootcause::prelude::ResultExt;
10use rootcause::report;
11use serde::Deserialize;
12
13const BIN: &str = "wezterm";
14
15/// Generates a command string to send text to a specific [`WeztermPane`] using the `WezTerm` CLI.
16pub fn send_text_to_pane_cmd(text: &str, pane_id: i64) -> String {
17    format!("{BIN} cli send-text {text} --pane-id '{pane_id}' --no-paste")
18}
19
20/// Generates a command string to submit (send a carriage return) to a specific [`WeztermPane`].
21pub fn submit_pane_cmd(pane_id: i64) -> String {
22    format!(r#"printf "\r" | {BIN} cli send-text --pane-id '{pane_id}' --no-paste"#)
23}
24
25/// Generates a command string to activate a specific [`WeztermPane`] using the `WezTerm` CLI.
26pub fn activate_pane_cmd(pane_id: i64) -> String {
27    format!("{BIN} cli activate-pane --pane-id '{pane_id}'")
28}
29
30/// Retrieves the current pane ID from the `WEZTERM_PANE` environment variable.
31///
32/// # Errors
33/// - A required environment variable is missing or invalid Unicode.
34/// - `WEZTERM_PANE` cannot be parsed as an integer.
35/// - `WEZTERM_PANE` is unset.
36pub fn get_current_pane_id() -> rootcause::Result<i64> {
37    let value = std::env::var("WEZTERM_PANE").context("error missing WEZTERM_PANE environment variable")?;
38    Ok(value
39        .parse()
40        .context("error parsing WEZTERM_PANE value as i64")
41        .attach_with(|| format!("value={value:?}"))?)
42}
43
44/// Retrieves all `WezTerm` panes using the `WezTerm` CLI.
45///
46/// The `envs` parameter is required because `WezTerm` may not be found in the PATH
47/// when called by the `oe` CLI when a file path is clicked in `WezTerm` itself.
48///
49/// # Errors
50/// - Invoking `wezterm` (list command) fails or returns a non-zero exit status.
51/// - Output JSON cannot be deserialized into panes.
52pub fn get_all_panes(envs: &[(&str, &str)]) -> rootcause::Result<Vec<WeztermPane>> {
53    let mut cmd = Command::new(BIN);
54    cmd.args(["cli", "list", "--format", "json"]);
55    cmd.envs(envs.iter().copied());
56    Ok(
57        serde_json::from_slice(&cmd.output().context("error running wezterm cli list")?.stdout)
58            .context("error parsing wezterm cli list output")
59            .attach("format=JSON")?,
60    )
61}
62
63/// Finds a sibling [`WeztermPane`] in the same tab that matches one of the given titles.
64///
65/// # Errors
66/// - No pane in the same tab matches any of `pane_titles`.
67/// - The current pane ID is not present in `panes`.
68pub fn get_sibling_pane_with_titles(
69    panes: &[WeztermPane],
70    current_pane_id: i64,
71    pane_titles: &[&str],
72) -> rootcause::Result<WeztermPane> {
73    let current_pane_tab_id = panes
74        .iter()
75        .find(|w| w.pane_id == current_pane_id)
76        .ok_or_else(|| report!("error finding current pane"))
77        .attach_with(|| format!("pane_id={current_pane_id} panes={panes:#?}"))?
78        .tab_id;
79
80    Ok(panes
81        .iter()
82        .find(|w| w.tab_id == current_pane_tab_id && pane_titles.contains(&w.title.as_str()))
83        .ok_or_else(|| {
84            report!("error finding pane title in tab")
85                .attach(format!("pane_titles={pane_titles:#?} tab_id={current_pane_tab_id}"))
86        })?
87        .clone())
88}
89
90/// Represents a `WezTerm` pane with all its properties and state information.
91#[derive(Clone, Debug, Deserialize)]
92#[cfg_attr(any(test, feature = "fake"), derive(fake::Dummy))]
93pub struct WeztermPane {
94    /// The shape of the cursor.
95    pub cursor_shape: String,
96    /// The visibility state of the cursor.
97    pub cursor_visibility: String,
98    /// The X coordinate of the cursor.
99    pub cursor_x: i64,
100    /// The Y coordinate of the cursor.
101    pub cursor_y: i64,
102    /// The current working directory as a file URI.
103    pub cwd: PathBuf,
104    /// Whether this pane is currently active.
105    pub is_active: bool,
106    /// Whether this pane is zoomed (maximized).
107    pub is_zoomed: bool,
108    /// The left column position of the pane.
109    pub left_col: i64,
110    /// The unique ID of this pane.
111    pub pane_id: i64,
112    /// The size dimensions of the pane.
113    pub size: WeztermPaneSize,
114    /// The ID of the tab containing this pane.
115    pub tab_id: i64,
116    /// The title of the tab containing this pane.
117    pub tab_title: String,
118    /// The title of the pane.
119    pub title: String,
120    /// The top row position of the pane.
121    pub top_row: i64,
122    /// The TTY device name associated with this pane.
123    pub tty_name: String,
124    /// The ID of the window containing this pane.
125    pub window_id: i64,
126    /// The title of the window containing this pane.
127    pub window_title: String,
128    /// The workspace name.
129    pub workspace: String,
130}
131
132impl WeztermPane {
133    /// Given two [`WeztermPane`] checks if they are in the same tab and if the first
134    /// has a current working directory that is the same or a child of the second one.
135    pub fn is_sibling_terminal_pane_of(&self, other: &Self) -> bool {
136        self.pane_id != other.pane_id && self.tab_id == other.tab_id && self.cwd.starts_with(&other.cwd)
137    }
138
139    /// Converts the current working directory from a file URI to an absolute [`PathBuf`].
140    pub fn absolute_cwd(&self) -> PathBuf {
141        let mut path_parts = self.cwd.components();
142        path_parts.next(); // Skip `file://`
143        path_parts.next(); // Skip hostname
144        PathBuf::from("/").join(path_parts.collect::<PathBuf>())
145    }
146}
147
148/// Represents the size and dimensions of a `WezTerm` pane.
149#[derive(Clone, Debug, Deserialize)]
150#[cfg_attr(any(test, feature = "fake"), derive(fake::Dummy))]
151pub struct WeztermPaneSize {
152    /// Number of character columns in the pane.
153    pub cols: i64,
154    /// Dots per inch (DPI) of the display.
155    pub dpi: i64,
156    /// Height of the pane in pixels.
157    pub pixel_height: i64,
158    /// Width of the pane in pixels.
159    pub pixel_width: i64,
160    /// Number of character rows in the pane.
161    pub rows: i64,
162}
163
164#[cfg(test)]
165mod tests {
166    use test_that::prelude::*;
167
168    use super::*;
169
170    #[test]
171    fn test_send_text_to_pane_cmd_returns_the_expected_bash_cmd_string() {
172        assert_that!(
173            send_text_to_pane_cmd("echo hi", 7),
174            eq("wezterm cli send-text echo hi --pane-id '7' --no-paste")
175        );
176    }
177
178    #[test]
179    fn test_submit_pane_cmd_returns_the_expected_bash_cmd_string() {
180        assert_that!(
181            submit_pane_cmd(3),
182            eq("printf \"\\r\" | wezterm cli send-text --pane-id '3' --no-paste")
183        );
184    }
185
186    #[test]
187    fn test_activate_pane_cmd_returns_the_expected_bash_cmd_string() {
188        assert_that!(activate_pane_cmd(9), eq("wezterm cli activate-pane --pane-id '9'"));
189    }
190
191    #[test]
192    fn test_get_sibling_pane_with_titles_returns_the_expected_match_in_same_tab() {
193        let panes = vec![
194            pane_with(1, 10, "file:///host/home/user/project", "hx"),
195            pane_with(2, 10, "file:///host/home/user/project", "shell"),
196            pane_with(3, 11, "file:///host/home/user/other", "hx"),
197        ];
198        assert_that!(
199            get_sibling_pane_with_titles(&panes, 2, &["hx"]),
200            ok(result_of!(|sibling: &WeztermPane| sibling.pane_id, eq(1)))
201        );
202    }
203
204    #[test]
205    fn test_get_sibling_pane_with_titles_errors_when_no_title_match() {
206        let panes = vec![pane_with(1, 10, "file:///host/home/user/project", "shell")];
207        assert_that!(
208            (get_sibling_pane_with_titles(&panes, 1, &["hx"])).map(|_| ()),
209            err(displays_as(contains_substring("error finding pane title in tab")))
210        );
211    }
212
213    #[test]
214    fn test_absolute_cwd_strips_file_uri_prefix() {
215        let pane = pane_with(1, 10, "file:///localhost/Users/alice/src", "hx");
216        let abs = pane.absolute_cwd();
217        assert_that!(abs.to_string_lossy().as_ref(), starts_with("/Users/alice/src"));
218    }
219
220    #[test]
221    fn test_is_sibling_terminal_pane_of_when_pane_titles_match_returns_expected_bool() {
222        let root = pane_with(1, 10, "file:///localhost/Users/alice/src", "hx");
223        let child = pane_with(2, 10, "file:///localhost/Users/alice/src/project", "shell");
224        let other_tab = pane_with(3, 11, "file:///localhost/Users/alice/src/project", "shell");
225        assert_that!(child.is_sibling_terminal_pane_of(&root), eq(true));
226        assert_that!(root.is_sibling_terminal_pane_of(&root), eq(false));
227        assert_that!(other_tab.is_sibling_terminal_pane_of(&root), eq(false));
228    }
229
230    fn pane_with(id: i64, tab: i64, cwd: &str, title: &str) -> WeztermPane {
231        WeztermPane {
232            cursor_shape: "Block".into(),
233            cursor_visibility: "Visible".into(),
234            cursor_x: 0,
235            cursor_y: 0,
236            cwd: PathBuf::from(cwd),
237            is_active: false,
238            is_zoomed: false,
239            left_col: 0,
240            pane_id: id,
241            size: WeztermPaneSize {
242                cols: 80,
243                dpi: 96,
244                pixel_height: 800,
245                pixel_width: 600,
246                rows: 24,
247            },
248            tab_id: tab,
249            tab_title: "tab".into(),
250            title: title.into(),
251            top_row: 0,
252            tty_name: "tty".into(),
253            window_id: 1,
254            window_title: "win".into(),
255            workspace: "default".into(),
256        }
257    }
258}