@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.
- package/.github/workflows/ci.yml +90 -0
- package/.github/workflows/release.yml +177 -0
- package/Cargo.lock +2662 -0
- package/Cargo.toml +38 -0
- package/PROTOCOL.md +1351 -0
- package/README.md +386 -0
- package/agents/ceo/AGENTS.md +24 -0
- package/agents/ceo/HEARTBEAT.md +72 -0
- package/agents/ceo/SOUL.md +33 -0
- package/agents/ceo/TOOLS.md +3 -0
- package/agents/founding-engineer/AGENTS.md +44 -0
- package/crates/wrightty/Cargo.toml +43 -0
- package/crates/wrightty/src/client_cmds.rs +366 -0
- package/crates/wrightty/src/discover.rs +78 -0
- package/crates/wrightty/src/main.rs +100 -0
- package/crates/wrightty/src/server.rs +100 -0
- package/crates/wrightty/src/term.rs +338 -0
- package/crates/wrightty-bridge-ghostty/Cargo.toml +27 -0
- package/crates/wrightty-bridge-ghostty/src/ghostty.rs +422 -0
- package/crates/wrightty-bridge-ghostty/src/lib.rs +2 -0
- package/crates/wrightty-bridge-ghostty/src/main.rs +146 -0
- package/crates/wrightty-bridge-ghostty/src/rpc.rs +307 -0
- package/crates/wrightty-bridge-kitty/Cargo.toml +26 -0
- package/crates/wrightty-bridge-kitty/src/kitty.rs +269 -0
- package/crates/wrightty-bridge-kitty/src/lib.rs +2 -0
- package/crates/wrightty-bridge-kitty/src/main.rs +124 -0
- package/crates/wrightty-bridge-kitty/src/rpc.rs +304 -0
- package/crates/wrightty-bridge-tmux/Cargo.toml +26 -0
- package/crates/wrightty-bridge-tmux/src/lib.rs +2 -0
- package/crates/wrightty-bridge-tmux/src/main.rs +119 -0
- package/crates/wrightty-bridge-tmux/src/rpc.rs +291 -0
- package/crates/wrightty-bridge-tmux/src/tmux.rs +215 -0
- package/crates/wrightty-bridge-wezterm/Cargo.toml +26 -0
- package/crates/wrightty-bridge-wezterm/src/lib.rs +2 -0
- package/crates/wrightty-bridge-wezterm/src/main.rs +119 -0
- package/crates/wrightty-bridge-wezterm/src/rpc.rs +339 -0
- package/crates/wrightty-bridge-wezterm/src/wezterm.rs +190 -0
- package/crates/wrightty-bridge-zellij/Cargo.toml +27 -0
- package/crates/wrightty-bridge-zellij/src/lib.rs +2 -0
- package/crates/wrightty-bridge-zellij/src/main.rs +125 -0
- package/crates/wrightty-bridge-zellij/src/rpc.rs +328 -0
- package/crates/wrightty-bridge-zellij/src/zellij.rs +199 -0
- package/crates/wrightty-client/Cargo.toml +16 -0
- package/crates/wrightty-client/src/client.rs +254 -0
- package/crates/wrightty-client/src/lib.rs +2 -0
- package/crates/wrightty-core/Cargo.toml +21 -0
- package/crates/wrightty-core/src/input.rs +212 -0
- package/crates/wrightty-core/src/lib.rs +4 -0
- package/crates/wrightty-core/src/screen.rs +325 -0
- package/crates/wrightty-core/src/session.rs +249 -0
- package/crates/wrightty-core/src/session_manager.rs +77 -0
- package/crates/wrightty-protocol/Cargo.toml +13 -0
- package/crates/wrightty-protocol/src/error.rs +8 -0
- package/crates/wrightty-protocol/src/events.rs +138 -0
- package/crates/wrightty-protocol/src/lib.rs +4 -0
- package/crates/wrightty-protocol/src/methods.rs +321 -0
- package/crates/wrightty-protocol/src/types.rs +201 -0
- package/crates/wrightty-server/Cargo.toml +23 -0
- package/crates/wrightty-server/src/lib.rs +2 -0
- package/crates/wrightty-server/src/main.rs +65 -0
- package/crates/wrightty-server/src/rpc.rs +455 -0
- package/crates/wrightty-server/src/state.rs +39 -0
- package/examples/basic_command.py +53 -0
- package/examples/interactive_tui.py +86 -0
- package/examples/record_session.py +96 -0
- package/install.sh +81 -0
- package/package.json +24 -0
- package/sdks/node/package-lock.json +85 -0
- package/sdks/node/package.json +44 -0
- package/sdks/node/src/client.ts +94 -0
- package/sdks/node/src/index.ts +19 -0
- package/sdks/node/src/terminal.ts +258 -0
- package/sdks/node/src/types.ts +105 -0
- package/sdks/node/tsconfig.json +17 -0
- package/sdks/python/README.md +96 -0
- package/sdks/python/pyproject.toml +42 -0
- package/sdks/python/wrightty/__init__.py +6 -0
- package/sdks/python/wrightty/cli.py +210 -0
- package/sdks/python/wrightty/client.py +136 -0
- package/sdks/python/wrightty/mcp_server.py +434 -0
- package/sdks/python/wrightty/terminal.py +333 -0
- package/skills/wrightty/SKILL.md +261 -0
- package/src/lib.rs +1 -0
- 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
|
+
}
|