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