wrightty_bridge_kitty/
kitty.rs1use serde::Deserialize;
9use tokio::process::Command;
10
11#[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#[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 cmd.arg("@");
77
78 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
90pub 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
115pub 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
149pub 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
164pub 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
179pub 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
194pub 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
209pub 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
227pub 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
240pub 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
262pub 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}