Skip to main content

wrightty_bridge_kitty/
kitty.rs

1//! Functions that shell out to `kitty @` remote control commands.
2//!
3//! Requires kitty to be started with `allow_remote_control yes` in kitty.conf,
4//! or launched with `--listen-on unix:/path/to/socket`.
5//!
6//! Set `KITTY_LISTEN_ON` to the socket path if kitty is not using the default.
7
8use serde::Deserialize;
9use tokio::process::Command;
10
11/// A kitty window as returned by `kitty @ ls`.
12#[derive(Debug, Clone, Deserialize)]
13pub struct KittyWindow {
14    pub id: u64,
15    pub title: String,
16    pub is_focused: bool,
17    pub columns: u16,
18    pub lines: u16,
19    #[serde(default)]
20    pub pid: Option<u32>,
21    #[serde(default)]
22    pub cwd: Option<String>,
23}
24
25/// Intermediate structures for parsing `kitty @ ls` JSON output.
26#[derive(Debug, Deserialize)]
27struct KittyOsWindow {
28    tabs: Vec<KittyTab>,
29}
30
31#[derive(Debug, Deserialize)]
32struct KittyTab {
33    windows: Vec<KittyWindowRaw>,
34}
35
36#[derive(Debug, Deserialize)]
37struct KittyWindowRaw {
38    id: u64,
39    title: String,
40    is_focused: bool,
41    columns: u16,
42    lines: u16,
43    #[serde(default)]
44    pid: Option<u32>,
45    foreground_processes: Vec<KittyProcess>,
46}
47
48#[derive(Debug, Deserialize)]
49struct KittyProcess {
50    pid: u32,
51    cwd: String,
52}
53
54#[derive(Debug, thiserror::Error)]
55pub enum KittyError {
56    #[error("kitty command failed: {0}")]
57    CommandFailed(String),
58    #[error("failed to parse kitty output: {0}")]
59    ParseError(String),
60    #[error("window {0} not found")]
61    WindowNotFound(u64),
62    #[error("io error: {0}")]
63    Io(#[from] std::io::Error),
64}
65
66fn kitty_cmd(args: &[&str]) -> Command {
67    let cmd_str = std::env::var("KITTY_CMD").unwrap_or_else(|_| "kitty".to_string());
68    let parts: Vec<&str> = cmd_str.split_whitespace().collect();
69    let (program, prefix_args) = parts.split_first().expect("KITTY_CMD must not be empty");
70
71    let mut cmd = Command::new(program);
72    for arg in prefix_args {
73        cmd.arg(arg);
74    }
75    // Always prepend "@" subcommand for remote control
76    cmd.arg("@");
77
78    // If KITTY_LISTEN_ON is set, pass it as the --to argument
79    if let Ok(socket) = std::env::var("KITTY_LISTEN_ON") {
80        cmd.arg("--to");
81        cmd.arg(socket);
82    }
83
84    for arg in args {
85        cmd.arg(arg);
86    }
87    cmd
88}
89
90/// Check if kitty is reachable via remote control.
91pub async fn health_check() -> Result<(), KittyError> {
92    let output = kitty_cmd(&["ls"]).output().await?;
93
94    if !output.status.success() {
95        let stderr = String::from_utf8_lossy(&output.stderr);
96        return Err(KittyError::CommandFailed(format!("kitty not reachable: {stderr}")));
97    }
98
99    let os_windows: Vec<KittyOsWindow> = serde_json::from_slice(&output.stdout)
100        .map_err(|e| KittyError::ParseError(e.to_string()))?;
101
102    let total_windows: usize = os_windows
103        .iter()
104        .flat_map(|ow| ow.tabs.iter())
105        .map(|t| t.windows.len())
106        .sum();
107
108    if total_windows == 0 {
109        return Err(KittyError::CommandFailed("kitty has no windows".to_string()));
110    }
111
112    Ok(())
113}
114
115/// List all windows across all OS windows and tabs.
116pub async fn list_windows() -> Result<Vec<KittyWindow>, KittyError> {
117    let output = kitty_cmd(&["ls"]).output().await?;
118
119    if !output.status.success() {
120        let stderr = String::from_utf8_lossy(&output.stderr);
121        return Err(KittyError::CommandFailed(stderr.into_owned()));
122    }
123
124    let os_windows: Vec<KittyOsWindow> = serde_json::from_slice(&output.stdout)
125        .map_err(|e| KittyError::ParseError(e.to_string()))?;
126
127    let windows: Vec<KittyWindow> = os_windows
128        .into_iter()
129        .flat_map(|ow| ow.tabs)
130        .flat_map(|t| t.windows)
131        .map(|w| {
132            let cwd = w.foreground_processes.first().map(|p| p.cwd.clone());
133            let pid = w.pid.or_else(|| w.foreground_processes.first().map(|p| p.pid));
134            KittyWindow {
135                id: w.id,
136                title: w.title,
137                is_focused: w.is_focused,
138                columns: w.columns,
139                lines: w.lines,
140                pid,
141                cwd,
142            }
143        })
144        .collect();
145
146    Ok(windows)
147}
148
149/// Get screen text for a window via `kitty @ get-text --match id:<id> --extent screen`.
150pub async fn get_text(window_id: u64) -> Result<String, KittyError> {
151    let match_str = format!("id:{window_id}");
152    let output = kitty_cmd(&["get-text", "--match", &match_str, "--extent", "screen"])
153        .output()
154        .await?;
155
156    if !output.status.success() {
157        let stderr = String::from_utf8_lossy(&output.stderr);
158        return Err(KittyError::CommandFailed(stderr.into_owned()));
159    }
160
161    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
162}
163
164/// Get scrollback text for a window.
165pub async fn get_scrollback(window_id: u64) -> Result<String, KittyError> {
166    let match_str = format!("id:{window_id}");
167    let output = kitty_cmd(&["get-text", "--match", &match_str, "--extent", "all"])
168        .output()
169        .await?;
170
171    if !output.status.success() {
172        let stderr = String::from_utf8_lossy(&output.stderr);
173        return Err(KittyError::CommandFailed(stderr.into_owned()));
174    }
175
176    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
177}
178
179/// Send literal text to a window via `kitty @ send-text`.
180pub async fn send_text(window_id: u64, text: &str) -> Result<(), KittyError> {
181    let match_str = format!("id:{window_id}");
182    let output = kitty_cmd(&["send-text", "--match", &match_str, "--", text])
183        .output()
184        .await?;
185
186    if !output.status.success() {
187        let stderr = String::from_utf8_lossy(&output.stderr);
188        return Err(KittyError::CommandFailed(stderr.into_owned()));
189    }
190
191    Ok(())
192}
193
194/// Send a key sequence to a window via `kitty @ send-key`.
195pub async fn send_key(window_id: u64, key: &str) -> Result<(), KittyError> {
196    let match_str = format!("id:{window_id}");
197    let output = kitty_cmd(&["send-key", "--match", &match_str, "--", key])
198        .output()
199        .await?;
200
201    if !output.status.success() {
202        let stderr = String::from_utf8_lossy(&output.stderr);
203        return Err(KittyError::CommandFailed(stderr.into_owned()));
204    }
205
206    Ok(())
207}
208
209/// Launch a new kitty window. Returns the new window ID.
210pub async fn launch_window() -> Result<u64, KittyError> {
211    let output = kitty_cmd(&["launch", "--type=window"]).output().await?;
212
213    if !output.status.success() {
214        let stderr = String::from_utf8_lossy(&output.stderr);
215        return Err(KittyError::CommandFailed(stderr.into_owned()));
216    }
217
218    let stdout = String::from_utf8_lossy(&output.stdout);
219    let window_id: u64 = stdout
220        .trim()
221        .parse()
222        .map_err(|e: std::num::ParseIntError| KittyError::ParseError(e.to_string()))?;
223
224    Ok(window_id)
225}
226
227/// Close a kitty window.
228pub async fn close_window(window_id: u64) -> Result<(), KittyError> {
229    let match_str = format!("id:{window_id}");
230    let output = kitty_cmd(&["close-window", "--match", &match_str]).output().await?;
231
232    if !output.status.success() {
233        let stderr = String::from_utf8_lossy(&output.stderr);
234        return Err(KittyError::CommandFailed(stderr.into_owned()));
235    }
236
237    Ok(())
238}
239
240/// Resize a kitty window.
241pub async fn resize_window(window_id: u64, cols: u16, rows: u16) -> Result<(), KittyError> {
242    let match_str = format!("id:{window_id}");
243    let cols_str = cols.to_string();
244    let rows_str = rows.to_string();
245    let output = kitty_cmd(&[
246        "resize-window",
247        "--match", &match_str,
248        "--width", &cols_str,
249        "--height", &rows_str,
250    ])
251    .output()
252    .await?;
253
254    if !output.status.success() {
255        let stderr = String::from_utf8_lossy(&output.stderr);
256        return Err(KittyError::CommandFailed(stderr.into_owned()));
257    }
258
259    Ok(())
260}
261
262/// Find a window by ID.
263pub async fn find_window(window_id: u64) -> Result<KittyWindow, KittyError> {
264    let windows = list_windows().await?;
265    windows
266        .into_iter()
267        .find(|w| w.id == window_id)
268        .ok_or(KittyError::WindowNotFound(window_id))
269}