@moejay/wrightty 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.github/workflows/ci.yml +90 -0
  2. package/.github/workflows/release.yml +177 -0
  3. package/Cargo.lock +2662 -0
  4. package/Cargo.toml +38 -0
  5. package/PROTOCOL.md +1351 -0
  6. package/README.md +386 -0
  7. package/agents/ceo/AGENTS.md +24 -0
  8. package/agents/ceo/HEARTBEAT.md +72 -0
  9. package/agents/ceo/SOUL.md +33 -0
  10. package/agents/ceo/TOOLS.md +3 -0
  11. package/agents/founding-engineer/AGENTS.md +44 -0
  12. package/crates/wrightty/Cargo.toml +43 -0
  13. package/crates/wrightty/src/client_cmds.rs +366 -0
  14. package/crates/wrightty/src/discover.rs +78 -0
  15. package/crates/wrightty/src/main.rs +100 -0
  16. package/crates/wrightty/src/server.rs +100 -0
  17. package/crates/wrightty/src/term.rs +338 -0
  18. package/crates/wrightty-bridge-ghostty/Cargo.toml +27 -0
  19. package/crates/wrightty-bridge-ghostty/src/ghostty.rs +422 -0
  20. package/crates/wrightty-bridge-ghostty/src/lib.rs +2 -0
  21. package/crates/wrightty-bridge-ghostty/src/main.rs +146 -0
  22. package/crates/wrightty-bridge-ghostty/src/rpc.rs +307 -0
  23. package/crates/wrightty-bridge-kitty/Cargo.toml +26 -0
  24. package/crates/wrightty-bridge-kitty/src/kitty.rs +269 -0
  25. package/crates/wrightty-bridge-kitty/src/lib.rs +2 -0
  26. package/crates/wrightty-bridge-kitty/src/main.rs +124 -0
  27. package/crates/wrightty-bridge-kitty/src/rpc.rs +304 -0
  28. package/crates/wrightty-bridge-tmux/Cargo.toml +26 -0
  29. package/crates/wrightty-bridge-tmux/src/lib.rs +2 -0
  30. package/crates/wrightty-bridge-tmux/src/main.rs +119 -0
  31. package/crates/wrightty-bridge-tmux/src/rpc.rs +291 -0
  32. package/crates/wrightty-bridge-tmux/src/tmux.rs +215 -0
  33. package/crates/wrightty-bridge-wezterm/Cargo.toml +26 -0
  34. package/crates/wrightty-bridge-wezterm/src/lib.rs +2 -0
  35. package/crates/wrightty-bridge-wezterm/src/main.rs +119 -0
  36. package/crates/wrightty-bridge-wezterm/src/rpc.rs +339 -0
  37. package/crates/wrightty-bridge-wezterm/src/wezterm.rs +190 -0
  38. package/crates/wrightty-bridge-zellij/Cargo.toml +27 -0
  39. package/crates/wrightty-bridge-zellij/src/lib.rs +2 -0
  40. package/crates/wrightty-bridge-zellij/src/main.rs +125 -0
  41. package/crates/wrightty-bridge-zellij/src/rpc.rs +328 -0
  42. package/crates/wrightty-bridge-zellij/src/zellij.rs +199 -0
  43. package/crates/wrightty-client/Cargo.toml +16 -0
  44. package/crates/wrightty-client/src/client.rs +254 -0
  45. package/crates/wrightty-client/src/lib.rs +2 -0
  46. package/crates/wrightty-core/Cargo.toml +21 -0
  47. package/crates/wrightty-core/src/input.rs +212 -0
  48. package/crates/wrightty-core/src/lib.rs +4 -0
  49. package/crates/wrightty-core/src/screen.rs +325 -0
  50. package/crates/wrightty-core/src/session.rs +249 -0
  51. package/crates/wrightty-core/src/session_manager.rs +77 -0
  52. package/crates/wrightty-protocol/Cargo.toml +13 -0
  53. package/crates/wrightty-protocol/src/error.rs +8 -0
  54. package/crates/wrightty-protocol/src/events.rs +138 -0
  55. package/crates/wrightty-protocol/src/lib.rs +4 -0
  56. package/crates/wrightty-protocol/src/methods.rs +321 -0
  57. package/crates/wrightty-protocol/src/types.rs +201 -0
  58. package/crates/wrightty-server/Cargo.toml +23 -0
  59. package/crates/wrightty-server/src/lib.rs +2 -0
  60. package/crates/wrightty-server/src/main.rs +65 -0
  61. package/crates/wrightty-server/src/rpc.rs +455 -0
  62. package/crates/wrightty-server/src/state.rs +39 -0
  63. package/examples/basic_command.py +53 -0
  64. package/examples/interactive_tui.py +86 -0
  65. package/examples/record_session.py +96 -0
  66. package/install.sh +81 -0
  67. package/package.json +24 -0
  68. package/sdks/node/package-lock.json +85 -0
  69. package/sdks/node/package.json +44 -0
  70. package/sdks/node/src/client.ts +94 -0
  71. package/sdks/node/src/index.ts +19 -0
  72. package/sdks/node/src/terminal.ts +258 -0
  73. package/sdks/node/src/types.ts +105 -0
  74. package/sdks/node/tsconfig.json +17 -0
  75. package/sdks/python/README.md +96 -0
  76. package/sdks/python/pyproject.toml +42 -0
  77. package/sdks/python/wrightty/__init__.py +6 -0
  78. package/sdks/python/wrightty/cli.py +210 -0
  79. package/sdks/python/wrightty/client.py +136 -0
  80. package/sdks/python/wrightty/mcp_server.py +434 -0
  81. package/sdks/python/wrightty/terminal.py +333 -0
  82. package/skills/wrightty/SKILL.md +261 -0
  83. package/src/lib.rs +1 -0
  84. package/tests/integration_test.rs +618 -0
@@ -0,0 +1,307 @@
1
+ //! jsonrpsee RPC module that maps wrightty protocol methods to Ghostty IPC.
2
+
3
+ use jsonrpsee::types::ErrorObjectOwned;
4
+ use jsonrpsee::RpcModule;
5
+
6
+ use wrightty_protocol::error;
7
+ use wrightty_protocol::methods::*;
8
+ use wrightty_protocol::types::*;
9
+
10
+ use crate::ghostty;
11
+
12
+ fn proto_err(code: i32, msg: impl Into<String>) -> ErrorObjectOwned {
13
+ ErrorObjectOwned::owned(code, msg.into(), None::<()>)
14
+ }
15
+
16
+ fn not_supported(method: &str) -> ErrorObjectOwned {
17
+ proto_err(
18
+ error::NOT_SUPPORTED,
19
+ format!("{method} is not supported by the ghostty bridge"),
20
+ )
21
+ }
22
+
23
+ /// Parse a session ID string into a Ghostty window ID (u64).
24
+ fn parse_window_id(session_id: &str) -> Result<u64, ErrorObjectOwned> {
25
+ session_id
26
+ .parse::<u64>()
27
+ .map_err(|_| proto_err(error::SESSION_NOT_FOUND, format!("invalid session id: {session_id}")))
28
+ }
29
+
30
+ /// Convert a wrightty `KeyInput` to an xdotool-compatible key name.
31
+ ///
32
+ /// xdotool key names reference:
33
+ /// <https://gitlab.com/cunidev/gestures/-/wikis/xdotool-list-of-key-codes>
34
+ fn encode_key_to_xdotool(key: &KeyInput) -> String {
35
+ match key {
36
+ KeyInput::Shorthand(s) => shorthand_to_xdotool(s),
37
+ KeyInput::Structured(event) => key_event_to_xdotool(event),
38
+ }
39
+ }
40
+
41
+ fn shorthand_to_xdotool(s: &str) -> String {
42
+ // Handle modifier combos like "ctrl+c", "alt+shift+f"
43
+ if s.contains('+') {
44
+ // xdotool uses "ctrl+c" style, just lowercase it
45
+ return s
46
+ .split('+')
47
+ .map(normalize_key_name)
48
+ .collect::<Vec<_>>()
49
+ .join("+");
50
+ }
51
+ normalize_key_name(s)
52
+ }
53
+
54
+ fn normalize_key_name(name: &str) -> String {
55
+ match name {
56
+ "Enter" | "Return" => "Return".to_string(),
57
+ "Tab" => "Tab".to_string(),
58
+ "Backspace" => "BackSpace".to_string(),
59
+ "Delete" => "Delete".to_string(),
60
+ "Escape" | "Esc" => "Escape".to_string(),
61
+ "ArrowUp" | "Up" => "Up".to_string(),
62
+ "ArrowDown" | "Down" => "Down".to_string(),
63
+ "ArrowRight" | "Right" => "Right".to_string(),
64
+ "ArrowLeft" | "Left" => "Left".to_string(),
65
+ "Home" => "Home".to_string(),
66
+ "End" => "End".to_string(),
67
+ "PageUp" => "Page_Up".to_string(),
68
+ "PageDown" => "Page_Down".to_string(),
69
+ "Insert" => "Insert".to_string(),
70
+ "ctrl" | "Ctrl" | "control" | "Control" => "ctrl".to_string(),
71
+ "alt" | "Alt" => "alt".to_string(),
72
+ "shift" | "Shift" => "shift".to_string(),
73
+ "super" | "Super" | "meta" | "Meta" => "super".to_string(),
74
+ _ => name.to_lowercase(),
75
+ }
76
+ }
77
+
78
+ fn key_event_to_xdotool(event: &KeyEvent) -> String {
79
+ let has_ctrl = event.modifiers.iter().any(|m| matches!(m, Modifier::Ctrl));
80
+ let has_alt = event.modifiers.iter().any(|m| matches!(m, Modifier::Alt));
81
+ let has_shift = event.modifiers.iter().any(|m| matches!(m, Modifier::Shift));
82
+
83
+ let base = match &event.key {
84
+ KeyType::Char => event
85
+ .char
86
+ .as_deref()
87
+ .map(normalize_key_name)
88
+ .unwrap_or_default(),
89
+ KeyType::Enter => "Return".to_string(),
90
+ KeyType::Tab => "Tab".to_string(),
91
+ KeyType::Backspace => "BackSpace".to_string(),
92
+ KeyType::Delete => "Delete".to_string(),
93
+ KeyType::Escape => "Escape".to_string(),
94
+ KeyType::ArrowUp => "Up".to_string(),
95
+ KeyType::ArrowDown => "Down".to_string(),
96
+ KeyType::ArrowRight => "Right".to_string(),
97
+ KeyType::ArrowLeft => "Left".to_string(),
98
+ KeyType::Home => "Home".to_string(),
99
+ KeyType::End => "End".to_string(),
100
+ KeyType::PageUp => "Page_Up".to_string(),
101
+ KeyType::PageDown => "Page_Down".to_string(),
102
+ KeyType::Insert => "Insert".to_string(),
103
+ KeyType::F => format!("F{}", event.n.unwrap_or(1)),
104
+ };
105
+
106
+ let mut parts: Vec<&str> = vec![];
107
+ if has_ctrl {
108
+ parts.push("ctrl");
109
+ }
110
+ if has_alt {
111
+ parts.push("alt");
112
+ }
113
+ if has_shift {
114
+ parts.push("shift");
115
+ }
116
+
117
+ if parts.is_empty() {
118
+ base
119
+ } else {
120
+ format!("{}+{base}", parts.join("+"))
121
+ }
122
+ }
123
+
124
+ pub fn build_rpc_module() -> anyhow::Result<RpcModule<()>> {
125
+ let mut module = RpcModule::new(());
126
+
127
+ // --- Wrightty.getInfo ---
128
+ module.register_async_method("Wrightty.getInfo", |_params, _state, _| async move {
129
+ serde_json::to_value(GetInfoResult {
130
+ info: ServerInfo {
131
+ version: "0.1.0".to_string(),
132
+ implementation: "wrightty-bridge-ghostty".to_string(),
133
+ capabilities: Capabilities {
134
+ screenshot: vec![],
135
+ max_sessions: 128,
136
+ supports_resize: false,
137
+ supports_scrollback: false,
138
+ supports_mouse: false,
139
+ supports_session_create: true,
140
+ supports_color_palette: false,
141
+ supports_raw_output: false,
142
+ supports_shell_integration: false,
143
+ events: vec![],
144
+ },
145
+ },
146
+ })
147
+ .map_err(|e| proto_err(-32603, e.to_string()))
148
+ })?;
149
+
150
+ // --- Session.create ---
151
+ module.register_async_method("Session.create", |_params, _state, _| async move {
152
+ let window_id = ghostty::new_window()
153
+ .await
154
+ .map_err(|e| proto_err(error::SPAWN_FAILED, e.to_string()))?;
155
+
156
+ serde_json::to_value(SessionCreateResult {
157
+ session_id: window_id.to_string(),
158
+ })
159
+ .map_err(|e| proto_err(-32603, e.to_string()))
160
+ })?;
161
+
162
+ // --- Session.destroy ---
163
+ module.register_async_method("Session.destroy", |params, _state, _| async move {
164
+ let p: SessionDestroyParams = params.parse()?;
165
+ let window_id = parse_window_id(&p.session_id)?;
166
+
167
+ ghostty::close_window(window_id)
168
+ .await
169
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
170
+
171
+ serde_json::to_value(SessionDestroyResult { exit_code: None })
172
+ .map_err(|e| proto_err(-32603, e.to_string()))
173
+ })?;
174
+
175
+ // --- Session.list ---
176
+ module.register_async_method("Session.list", |_params, _state, _| async move {
177
+ let windows = ghostty::list_windows()
178
+ .await
179
+ .map_err(|e| proto_err(-32603, e.to_string()))?;
180
+
181
+ let sessions: Vec<SessionInfo> = windows
182
+ .into_iter()
183
+ .map(|w| SessionInfo {
184
+ session_id: w.id.to_string(),
185
+ title: w.title,
186
+ cwd: w.cwd,
187
+ cols: w.cols,
188
+ rows: w.rows,
189
+ pid: w.pid,
190
+ running: true,
191
+ alternate_screen: false,
192
+ })
193
+ .collect();
194
+
195
+ serde_json::to_value(SessionListResult { sessions })
196
+ .map_err(|e| proto_err(-32603, e.to_string()))
197
+ })?;
198
+
199
+ // --- Session.getInfo ---
200
+ module.register_async_method("Session.getInfo", |params, _state, _| async move {
201
+ let p: SessionGetInfoParams = params.parse()?;
202
+ let window_id = parse_window_id(&p.session_id)?;
203
+
204
+ let w = ghostty::find_window(window_id)
205
+ .await
206
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
207
+
208
+ let info = SessionInfo {
209
+ session_id: w.id.to_string(),
210
+ title: w.title,
211
+ cwd: w.cwd,
212
+ cols: w.cols,
213
+ rows: w.rows,
214
+ pid: w.pid,
215
+ running: true,
216
+ alternate_screen: false,
217
+ };
218
+
219
+ serde_json::to_value(info).map_err(|e| proto_err(-32603, e.to_string()))
220
+ })?;
221
+
222
+ // --- Input.sendText ---
223
+ module.register_async_method("Input.sendText", |params, _state, _| async move {
224
+ let p: InputSendTextParams = params.parse()?;
225
+ let window_id = parse_window_id(&p.session_id)?;
226
+
227
+ ghostty::send_text(window_id, &p.text)
228
+ .await
229
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
230
+
231
+ Ok::<_, ErrorObjectOwned>(serde_json::json!({}))
232
+ })?;
233
+
234
+ // --- Input.sendKeys ---
235
+ module.register_async_method("Input.sendKeys", |params, _state, _| async move {
236
+ let p: InputSendKeysParams = params.parse()?;
237
+ let window_id = parse_window_id(&p.session_id)?;
238
+
239
+ for key in &p.keys {
240
+ // Single literal characters go via send_text for better compatibility
241
+ let is_literal_char =
242
+ matches!(key, KeyInput::Shorthand(s) if s.len() == 1 && !s.contains('+'));
243
+
244
+ if is_literal_char {
245
+ let text = match key {
246
+ KeyInput::Shorthand(s) => s.clone(),
247
+ _ => unreachable!(),
248
+ };
249
+ ghostty::send_text(window_id, &text)
250
+ .await
251
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
252
+ } else {
253
+ let key_str = encode_key_to_xdotool(key);
254
+ ghostty::send_key(window_id, &key_str)
255
+ .await
256
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
257
+ }
258
+ }
259
+
260
+ Ok::<_, ErrorObjectOwned>(serde_json::json!({}))
261
+ })?;
262
+
263
+ // --- Terminal.getSize ---
264
+ module.register_async_method("Terminal.getSize", |params, _state, _| async move {
265
+ let p: TerminalGetSizeParams = params.parse()?;
266
+ let window_id = parse_window_id(&p.session_id)?;
267
+
268
+ let w = ghostty::find_window(window_id)
269
+ .await
270
+ .map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
271
+
272
+ serde_json::to_value(TerminalGetSizeResult {
273
+ cols: w.cols,
274
+ rows: w.rows,
275
+ })
276
+ .map_err(|e| proto_err(-32603, e.to_string()))
277
+ })?;
278
+
279
+ // --- Unsupported methods ---
280
+
281
+ module.register_async_method("Screen.getText", |_params, _state, _| async move {
282
+ Err::<serde_json::Value, _>(not_supported(
283
+ "Screen.getText — Ghostty does not expose a screen-dump IPC; \
284
+ use wrightty-server with ghostty-native support instead",
285
+ ))
286
+ })?;
287
+
288
+ module.register_async_method("Screen.getContents", |_params, _state, _| async move {
289
+ Err::<serde_json::Value, _>(not_supported("Screen.getContents"))
290
+ })?;
291
+
292
+ module.register_async_method("Screen.screenshot", |_params, _state, _| async move {
293
+ Err::<serde_json::Value, _>(not_supported("Screen.screenshot"))
294
+ })?;
295
+
296
+ module.register_async_method("Terminal.resize", |_params, _state, _| async move {
297
+ Err::<serde_json::Value, _>(not_supported(
298
+ "Terminal.resize — Ghostty window sizing is not exposed via IPC",
299
+ ))
300
+ })?;
301
+
302
+ module.register_async_method("Input.sendMouse", |_params, _state, _| async move {
303
+ Err::<serde_json::Value, _>(not_supported("Input.sendMouse"))
304
+ })?;
305
+
306
+ Ok(module)
307
+ }
@@ -0,0 +1,26 @@
1
+ [package]
2
+ name = "wrightty-bridge-kitty"
3
+ version.workspace = true
4
+ edition.workspace = true
5
+ license.workspace = true
6
+ authors.workspace = true
7
+ repository.workspace = true
8
+ homepage.workspace = true
9
+ description = "Bridge that translates wrightty protocol calls into kitty remote control commands"
10
+
11
+ [[bin]]
12
+ name = "wrightty-bridge-kitty"
13
+ path = "src/main.rs"
14
+
15
+ [dependencies]
16
+ wrightty-protocol = { version = "0.1.0", path = "../wrightty-protocol" }
17
+
18
+ jsonrpsee = { version = "0.24", features = ["server"] }
19
+ tokio = { version = "1", features = ["full"] }
20
+ clap = { version = "4", features = ["derive"] }
21
+ tracing = "0.1"
22
+ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
23
+ anyhow = "1"
24
+ serde = { version = "1", features = ["derive"] }
25
+ serde_json = "1"
26
+ thiserror = "1"
@@ -0,0 +1,269 @@
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
+
8
+ use serde::Deserialize;
9
+ use tokio::process::Command;
10
+
11
+ /// A kitty window as returned by `kitty @ ls`.
12
+ #[derive(Debug, Clone, Deserialize)]
13
+ pub 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)]
27
+ struct KittyOsWindow {
28
+ tabs: Vec<KittyTab>,
29
+ }
30
+
31
+ #[derive(Debug, Deserialize)]
32
+ struct KittyTab {
33
+ windows: Vec<KittyWindowRaw>,
34
+ }
35
+
36
+ #[derive(Debug, Deserialize)]
37
+ struct 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)]
49
+ struct KittyProcess {
50
+ pid: u32,
51
+ cwd: String,
52
+ }
53
+
54
+ #[derive(Debug, thiserror::Error)]
55
+ pub 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
+
66
+ fn 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.
91
+ pub 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.
116
+ pub 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`.
150
+ pub 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.
165
+ pub 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`.
180
+ pub 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`.
195
+ pub 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.
210
+ pub 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.
228
+ pub 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.
241
+ pub 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.
263
+ pub 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
+ }
@@ -0,0 +1,2 @@
1
+ pub mod kitty;
2
+ pub mod rpc;