@moejay/wrightty 0.0.0 → 0.1.1
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/dist/cli.d.ts +3 -0
- package/dist/cli.js +144 -0
- package/dist/client.d.ts +14 -0
- package/dist/client.js +83 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/terminal.d.ts +48 -0
- package/dist/terminal.js +210 -0
- package/dist/types.d.ts +90 -0
- package/dist/types.js +3 -0
- package/package.json +38 -15
- package/.github/workflows/ci.yml +0 -90
- package/.github/workflows/release.yml +0 -177
- package/Cargo.lock +0 -2662
- package/Cargo.toml +0 -38
- package/PROTOCOL.md +0 -1351
- package/README.md +0 -386
- package/agents/ceo/AGENTS.md +0 -24
- package/agents/ceo/HEARTBEAT.md +0 -72
- package/agents/ceo/SOUL.md +0 -33
- package/agents/ceo/TOOLS.md +0 -3
- package/agents/founding-engineer/AGENTS.md +0 -44
- package/crates/wrightty/Cargo.toml +0 -43
- package/crates/wrightty/src/client_cmds.rs +0 -366
- package/crates/wrightty/src/discover.rs +0 -78
- package/crates/wrightty/src/main.rs +0 -100
- package/crates/wrightty/src/server.rs +0 -100
- package/crates/wrightty/src/term.rs +0 -338
- package/crates/wrightty-bridge-ghostty/Cargo.toml +0 -27
- package/crates/wrightty-bridge-ghostty/src/ghostty.rs +0 -422
- package/crates/wrightty-bridge-ghostty/src/lib.rs +0 -2
- package/crates/wrightty-bridge-ghostty/src/main.rs +0 -146
- package/crates/wrightty-bridge-ghostty/src/rpc.rs +0 -307
- package/crates/wrightty-bridge-kitty/Cargo.toml +0 -26
- package/crates/wrightty-bridge-kitty/src/kitty.rs +0 -269
- package/crates/wrightty-bridge-kitty/src/lib.rs +0 -2
- package/crates/wrightty-bridge-kitty/src/main.rs +0 -124
- package/crates/wrightty-bridge-kitty/src/rpc.rs +0 -304
- package/crates/wrightty-bridge-tmux/Cargo.toml +0 -26
- package/crates/wrightty-bridge-tmux/src/lib.rs +0 -2
- package/crates/wrightty-bridge-tmux/src/main.rs +0 -119
- package/crates/wrightty-bridge-tmux/src/rpc.rs +0 -291
- package/crates/wrightty-bridge-tmux/src/tmux.rs +0 -215
- package/crates/wrightty-bridge-wezterm/Cargo.toml +0 -26
- package/crates/wrightty-bridge-wezterm/src/lib.rs +0 -2
- package/crates/wrightty-bridge-wezterm/src/main.rs +0 -119
- package/crates/wrightty-bridge-wezterm/src/rpc.rs +0 -339
- package/crates/wrightty-bridge-wezterm/src/wezterm.rs +0 -190
- package/crates/wrightty-bridge-zellij/Cargo.toml +0 -27
- package/crates/wrightty-bridge-zellij/src/lib.rs +0 -2
- package/crates/wrightty-bridge-zellij/src/main.rs +0 -125
- package/crates/wrightty-bridge-zellij/src/rpc.rs +0 -328
- package/crates/wrightty-bridge-zellij/src/zellij.rs +0 -199
- package/crates/wrightty-client/Cargo.toml +0 -16
- package/crates/wrightty-client/src/client.rs +0 -254
- package/crates/wrightty-client/src/lib.rs +0 -2
- package/crates/wrightty-core/Cargo.toml +0 -21
- package/crates/wrightty-core/src/input.rs +0 -212
- package/crates/wrightty-core/src/lib.rs +0 -4
- package/crates/wrightty-core/src/screen.rs +0 -325
- package/crates/wrightty-core/src/session.rs +0 -249
- package/crates/wrightty-core/src/session_manager.rs +0 -77
- package/crates/wrightty-protocol/Cargo.toml +0 -13
- package/crates/wrightty-protocol/src/error.rs +0 -8
- package/crates/wrightty-protocol/src/events.rs +0 -138
- package/crates/wrightty-protocol/src/lib.rs +0 -4
- package/crates/wrightty-protocol/src/methods.rs +0 -321
- package/crates/wrightty-protocol/src/types.rs +0 -201
- package/crates/wrightty-server/Cargo.toml +0 -23
- package/crates/wrightty-server/src/lib.rs +0 -2
- package/crates/wrightty-server/src/main.rs +0 -65
- package/crates/wrightty-server/src/rpc.rs +0 -455
- package/crates/wrightty-server/src/state.rs +0 -39
- package/examples/basic_command.py +0 -53
- package/examples/interactive_tui.py +0 -86
- package/examples/record_session.py +0 -96
- package/install.sh +0 -81
- package/sdks/node/package-lock.json +0 -85
- package/sdks/node/package.json +0 -44
- package/sdks/node/src/client.ts +0 -94
- package/sdks/node/src/index.ts +0 -19
- package/sdks/node/src/terminal.ts +0 -258
- package/sdks/node/src/types.ts +0 -105
- package/sdks/node/tsconfig.json +0 -17
- package/sdks/python/README.md +0 -96
- package/sdks/python/pyproject.toml +0 -42
- package/sdks/python/wrightty/__init__.py +0 -6
- package/sdks/python/wrightty/cli.py +0 -210
- package/sdks/python/wrightty/client.py +0 -136
- package/sdks/python/wrightty/mcp_server.py +0 -434
- package/sdks/python/wrightty/terminal.py +0 -333
- package/skills/wrightty/SKILL.md +0 -261
- package/src/lib.rs +0 -1
- package/tests/integration_test.rs +0 -618
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
//! jsonrpsee RPC module that maps wrightty protocol methods to tmux CLI commands.
|
|
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::tmux;
|
|
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(error::NOT_SUPPORTED, format!("{method} is not supported by the tmux bridge"))
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/// Encode wrightty KeyInput values into tmux key name strings.
|
|
21
|
-
///
|
|
22
|
-
/// tmux `send-keys` without `-l` interprets key names like `Enter`, `C-c`, `M-x`, `Up`, etc.
|
|
23
|
-
/// We use this for structured key events. For plain text we use `send-keys -l`.
|
|
24
|
-
fn encode_key_to_tmux(key: &KeyInput) -> String {
|
|
25
|
-
match key {
|
|
26
|
-
KeyInput::Shorthand(s) => shorthand_to_tmux(s),
|
|
27
|
-
KeyInput::Structured(event) => key_event_to_tmux(event),
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
fn shorthand_to_tmux(s: &str) -> String {
|
|
32
|
-
// Modifier combos like "Ctrl+c" -> "C-c", "Alt+x" -> "M-x"
|
|
33
|
-
if let Some((modifier, key)) = s.split_once('+') {
|
|
34
|
-
match modifier {
|
|
35
|
-
"Ctrl" => return format!("C-{}", key.to_lowercase()),
|
|
36
|
-
"Alt" => return format!("M-{key}"),
|
|
37
|
-
_ => {}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Named keys
|
|
42
|
-
match s {
|
|
43
|
-
"Enter" | "Return" => "Enter".to_string(),
|
|
44
|
-
"Tab" => "Tab".to_string(),
|
|
45
|
-
"Backspace" => "BSpace".to_string(),
|
|
46
|
-
"Delete" => "Delete".to_string(),
|
|
47
|
-
"Escape" | "Esc" => "Escape".to_string(),
|
|
48
|
-
"ArrowUp" | "Up" => "Up".to_string(),
|
|
49
|
-
"ArrowDown" | "Down" => "Down".to_string(),
|
|
50
|
-
"ArrowRight" | "Right" => "Right".to_string(),
|
|
51
|
-
"ArrowLeft" | "Left" => "Left".to_string(),
|
|
52
|
-
"Home" => "Home".to_string(),
|
|
53
|
-
"End" => "End".to_string(),
|
|
54
|
-
"PageUp" => "PPage".to_string(),
|
|
55
|
-
"PageDown" => "NPage".to_string(),
|
|
56
|
-
"Insert" => "Insert".to_string(),
|
|
57
|
-
_ => s.to_string(),
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
fn key_event_to_tmux(event: &KeyEvent) -> String {
|
|
62
|
-
let has_ctrl = event.modifiers.iter().any(|m| matches!(m, Modifier::Ctrl));
|
|
63
|
-
let has_alt = event.modifiers.iter().any(|m| matches!(m, Modifier::Alt));
|
|
64
|
-
|
|
65
|
-
let base = match &event.key {
|
|
66
|
-
KeyType::Char => event.char.as_deref().unwrap_or("").to_string(),
|
|
67
|
-
KeyType::Enter => "Enter".to_string(),
|
|
68
|
-
KeyType::Tab => "Tab".to_string(),
|
|
69
|
-
KeyType::Backspace => "BSpace".to_string(),
|
|
70
|
-
KeyType::Delete => "Delete".to_string(),
|
|
71
|
-
KeyType::Escape => "Escape".to_string(),
|
|
72
|
-
KeyType::ArrowUp => "Up".to_string(),
|
|
73
|
-
KeyType::ArrowDown => "Down".to_string(),
|
|
74
|
-
KeyType::ArrowRight => "Right".to_string(),
|
|
75
|
-
KeyType::ArrowLeft => "Left".to_string(),
|
|
76
|
-
KeyType::Home => "Home".to_string(),
|
|
77
|
-
KeyType::End => "End".to_string(),
|
|
78
|
-
KeyType::PageUp => "PPage".to_string(),
|
|
79
|
-
KeyType::PageDown => "NPage".to_string(),
|
|
80
|
-
KeyType::Insert => "Insert".to_string(),
|
|
81
|
-
KeyType::F => format!("F{}", event.n.unwrap_or(1)),
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
let mut result = base;
|
|
85
|
-
if has_ctrl {
|
|
86
|
-
result = format!("C-{result}");
|
|
87
|
-
}
|
|
88
|
-
if has_alt {
|
|
89
|
-
result = format!("M-{result}");
|
|
90
|
-
}
|
|
91
|
-
result
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
pub fn build_rpc_module() -> anyhow::Result<RpcModule<()>> {
|
|
95
|
-
let mut module = RpcModule::new(());
|
|
96
|
-
|
|
97
|
-
// --- Wrightty.getInfo ---
|
|
98
|
-
module.register_async_method("Wrightty.getInfo", |_params, _state, _| async move {
|
|
99
|
-
serde_json::to_value(GetInfoResult {
|
|
100
|
-
info: ServerInfo {
|
|
101
|
-
version: "0.1.0".to_string(),
|
|
102
|
-
implementation: "wrightty-bridge-tmux".to_string(),
|
|
103
|
-
capabilities: Capabilities {
|
|
104
|
-
screenshot: vec![ScreenshotFormat::Text],
|
|
105
|
-
max_sessions: 512,
|
|
106
|
-
supports_resize: true,
|
|
107
|
-
supports_scrollback: true,
|
|
108
|
-
supports_mouse: false,
|
|
109
|
-
supports_session_create: true,
|
|
110
|
-
supports_color_palette: false,
|
|
111
|
-
supports_raw_output: false,
|
|
112
|
-
supports_shell_integration: false,
|
|
113
|
-
events: vec![],
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
})
|
|
117
|
-
.map_err(|e| proto_err(-32603, e.to_string()))
|
|
118
|
-
})?;
|
|
119
|
-
|
|
120
|
-
// --- Session.create ---
|
|
121
|
-
module.register_async_method("Session.create", |_params, _state, _| async move {
|
|
122
|
-
let target = tmux::new_window(None)
|
|
123
|
-
.await
|
|
124
|
-
.map_err(|e| proto_err(error::SPAWN_FAILED, e.to_string()))?;
|
|
125
|
-
|
|
126
|
-
serde_json::to_value(SessionCreateResult { session_id: target })
|
|
127
|
-
.map_err(|e| proto_err(-32603, e.to_string()))
|
|
128
|
-
})?;
|
|
129
|
-
|
|
130
|
-
// --- Session.destroy ---
|
|
131
|
-
module.register_async_method("Session.destroy", |params, _state, _| async move {
|
|
132
|
-
let p: SessionDestroyParams = params.parse()?;
|
|
133
|
-
|
|
134
|
-
tmux::kill_pane(&p.session_id)
|
|
135
|
-
.await
|
|
136
|
-
.map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
|
|
137
|
-
|
|
138
|
-
serde_json::to_value(SessionDestroyResult { exit_code: None })
|
|
139
|
-
.map_err(|e| proto_err(-32603, e.to_string()))
|
|
140
|
-
})?;
|
|
141
|
-
|
|
142
|
-
// --- Session.list ---
|
|
143
|
-
module.register_async_method("Session.list", |_params, _state, _| async move {
|
|
144
|
-
let panes = tmux::list_panes()
|
|
145
|
-
.await
|
|
146
|
-
.map_err(|e| proto_err(-32603, e.to_string()))?;
|
|
147
|
-
|
|
148
|
-
let sessions: Vec<SessionInfo> = panes
|
|
149
|
-
.into_iter()
|
|
150
|
-
.map(|p| SessionInfo {
|
|
151
|
-
session_id: p.target,
|
|
152
|
-
title: if p.title.is_empty() {
|
|
153
|
-
format!("{}:{}.{}", p.session_name, p.window_index, p.pane_index)
|
|
154
|
-
} else {
|
|
155
|
-
p.title
|
|
156
|
-
},
|
|
157
|
-
cwd: None,
|
|
158
|
-
cols: p.cols,
|
|
159
|
-
rows: p.rows,
|
|
160
|
-
pid: if p.pid > 0 { Some(p.pid) } else { None },
|
|
161
|
-
running: true,
|
|
162
|
-
alternate_screen: false,
|
|
163
|
-
})
|
|
164
|
-
.collect();
|
|
165
|
-
|
|
166
|
-
serde_json::to_value(SessionListResult { sessions })
|
|
167
|
-
.map_err(|e| proto_err(-32603, e.to_string()))
|
|
168
|
-
})?;
|
|
169
|
-
|
|
170
|
-
// --- Session.getInfo ---
|
|
171
|
-
module.register_async_method("Session.getInfo", |params, _state, _| async move {
|
|
172
|
-
let p: SessionGetInfoParams = params.parse()?;
|
|
173
|
-
|
|
174
|
-
let pane = tmux::find_pane(&p.session_id)
|
|
175
|
-
.await
|
|
176
|
-
.map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
|
|
177
|
-
|
|
178
|
-
let info = SessionInfo {
|
|
179
|
-
session_id: pane.target,
|
|
180
|
-
title: if pane.title.is_empty() {
|
|
181
|
-
format!("{}:{}.{}", pane.session_name, pane.window_index, pane.pane_index)
|
|
182
|
-
} else {
|
|
183
|
-
pane.title
|
|
184
|
-
},
|
|
185
|
-
cwd: None,
|
|
186
|
-
cols: pane.cols,
|
|
187
|
-
rows: pane.rows,
|
|
188
|
-
pid: if pane.pid > 0 { Some(pane.pid) } else { None },
|
|
189
|
-
running: true,
|
|
190
|
-
alternate_screen: false,
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
serde_json::to_value(info).map_err(|e| proto_err(-32603, e.to_string()))
|
|
194
|
-
})?;
|
|
195
|
-
|
|
196
|
-
// --- Input.sendText ---
|
|
197
|
-
module.register_async_method("Input.sendText", |params, _state, _| async move {
|
|
198
|
-
let p: InputSendTextParams = params.parse()?;
|
|
199
|
-
|
|
200
|
-
tmux::send_text(&p.session_id, &p.text)
|
|
201
|
-
.await
|
|
202
|
-
.map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
|
|
203
|
-
|
|
204
|
-
Ok::<_, ErrorObjectOwned>(serde_json::json!({}))
|
|
205
|
-
})?;
|
|
206
|
-
|
|
207
|
-
// --- Input.sendKeys ---
|
|
208
|
-
module.register_async_method("Input.sendKeys", |params, _state, _| async move {
|
|
209
|
-
let p: InputSendKeysParams = params.parse()?;
|
|
210
|
-
|
|
211
|
-
for key in &p.keys {
|
|
212
|
-
let tmux_key = encode_key_to_tmux(key);
|
|
213
|
-
// Determine if this is literal text (single char shorthand) or a key name
|
|
214
|
-
let is_literal = matches!(key, KeyInput::Shorthand(s) if s.len() == 1 && !s.contains('+'));
|
|
215
|
-
if is_literal {
|
|
216
|
-
tmux::send_text(&p.session_id, &tmux_key)
|
|
217
|
-
.await
|
|
218
|
-
.map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
|
|
219
|
-
} else {
|
|
220
|
-
tmux::send_key(&p.session_id, &tmux_key)
|
|
221
|
-
.await
|
|
222
|
-
.map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
Ok::<_, ErrorObjectOwned>(serde_json::json!({}))
|
|
227
|
-
})?;
|
|
228
|
-
|
|
229
|
-
// --- Screen.getText ---
|
|
230
|
-
module.register_async_method("Screen.getText", |params, _state, _| async move {
|
|
231
|
-
let p: ScreenGetTextParams = params.parse()?;
|
|
232
|
-
|
|
233
|
-
let mut text = tmux::capture_pane(&p.session_id)
|
|
234
|
-
.await
|
|
235
|
-
.map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
|
|
236
|
-
|
|
237
|
-
if p.trim_trailing_whitespace {
|
|
238
|
-
text = text
|
|
239
|
-
.lines()
|
|
240
|
-
.map(|line| line.trim_end())
|
|
241
|
-
.collect::<Vec<_>>()
|
|
242
|
-
.join("\n");
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
serde_json::to_value(ScreenGetTextResult { text })
|
|
246
|
-
.map_err(|e| proto_err(-32603, e.to_string()))
|
|
247
|
-
})?;
|
|
248
|
-
|
|
249
|
-
// --- Terminal.getSize ---
|
|
250
|
-
module.register_async_method("Terminal.getSize", |params, _state, _| async move {
|
|
251
|
-
let p: TerminalGetSizeParams = params.parse()?;
|
|
252
|
-
|
|
253
|
-
let pane = tmux::find_pane(&p.session_id)
|
|
254
|
-
.await
|
|
255
|
-
.map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
|
|
256
|
-
|
|
257
|
-
serde_json::to_value(TerminalGetSizeResult {
|
|
258
|
-
cols: pane.cols,
|
|
259
|
-
rows: pane.rows,
|
|
260
|
-
})
|
|
261
|
-
.map_err(|e| proto_err(-32603, e.to_string()))
|
|
262
|
-
})?;
|
|
263
|
-
|
|
264
|
-
// --- Terminal.resize ---
|
|
265
|
-
module.register_async_method("Terminal.resize", |params, _state, _| async move {
|
|
266
|
-
let p: TerminalResizeParams = params.parse()?;
|
|
267
|
-
|
|
268
|
-
tmux::resize_pane(&p.session_id, p.cols, p.rows)
|
|
269
|
-
.await
|
|
270
|
-
.map_err(|e| proto_err(error::SESSION_NOT_FOUND, e.to_string()))?;
|
|
271
|
-
|
|
272
|
-
Ok::<_, ErrorObjectOwned>(serde_json::json!({}))
|
|
273
|
-
})?;
|
|
274
|
-
|
|
275
|
-
// --- Screen.getContents (not supported) ---
|
|
276
|
-
module.register_async_method("Screen.getContents", |_params, _state, _| async move {
|
|
277
|
-
Err::<serde_json::Value, _>(not_supported("Screen.getContents"))
|
|
278
|
-
})?;
|
|
279
|
-
|
|
280
|
-
// --- Screen.screenshot (not supported) ---
|
|
281
|
-
module.register_async_method("Screen.screenshot", |_params, _state, _| async move {
|
|
282
|
-
Err::<serde_json::Value, _>(not_supported("Screen.screenshot"))
|
|
283
|
-
})?;
|
|
284
|
-
|
|
285
|
-
// --- Input.sendMouse (not supported) ---
|
|
286
|
-
module.register_async_method("Input.sendMouse", |_params, _state, _| async move {
|
|
287
|
-
Err::<serde_json::Value, _>(not_supported("Input.sendMouse"))
|
|
288
|
-
})?;
|
|
289
|
-
|
|
290
|
-
Ok(module)
|
|
291
|
-
}
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
//! Functions that shell out to `tmux` CLI commands.
|
|
2
|
-
|
|
3
|
-
use tokio::process::Command;
|
|
4
|
-
|
|
5
|
-
#[derive(Debug, Clone)]
|
|
6
|
-
pub struct TmuxPane {
|
|
7
|
-
/// Full pane target in `<session>:<window>.<pane>` format, e.g. `main:0.1`
|
|
8
|
-
pub target: String,
|
|
9
|
-
pub session_name: String,
|
|
10
|
-
pub window_index: u32,
|
|
11
|
-
pub pane_index: u32,
|
|
12
|
-
pub cols: u16,
|
|
13
|
-
pub rows: u16,
|
|
14
|
-
pub title: String,
|
|
15
|
-
pub active: bool,
|
|
16
|
-
pub pid: u32,
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
#[derive(Debug, thiserror::Error)]
|
|
20
|
-
pub enum TmuxError {
|
|
21
|
-
#[error("tmux command failed: {0}")]
|
|
22
|
-
CommandFailed(String),
|
|
23
|
-
#[error("failed to parse tmux output: {0}")]
|
|
24
|
-
ParseError(String),
|
|
25
|
-
#[error("pane {0} not found")]
|
|
26
|
-
PaneNotFound(String),
|
|
27
|
-
#[error("io error: {0}")]
|
|
28
|
-
Io(#[from] std::io::Error),
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
fn tmux_cmd(args: &[&str]) -> Command {
|
|
32
|
-
let cmd_str = std::env::var("TMUX_CMD").unwrap_or_else(|_| "tmux".to_string());
|
|
33
|
-
let parts: Vec<&str> = cmd_str.split_whitespace().collect();
|
|
34
|
-
let (program, prefix_args) = parts.split_first().expect("TMUX_CMD must not be empty");
|
|
35
|
-
|
|
36
|
-
let mut cmd = Command::new(program);
|
|
37
|
-
for arg in prefix_args {
|
|
38
|
-
cmd.arg(arg);
|
|
39
|
-
}
|
|
40
|
-
for arg in args {
|
|
41
|
-
cmd.arg(arg);
|
|
42
|
-
}
|
|
43
|
-
cmd
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/// Check if tmux server is reachable.
|
|
47
|
-
pub async fn health_check() -> Result<(), TmuxError> {
|
|
48
|
-
let output = tmux_cmd(&["list-sessions"]).output().await?;
|
|
49
|
-
|
|
50
|
-
if !output.status.success() {
|
|
51
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
52
|
-
return Err(TmuxError::CommandFailed(format!("tmux not reachable: {stderr}")));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
Ok(())
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/// List all panes across all sessions.
|
|
59
|
-
pub async fn list_panes() -> Result<Vec<TmuxPane>, TmuxError> {
|
|
60
|
-
// Format: session_name:window_index.pane_index|cols|rows|title|active|pid
|
|
61
|
-
let format = "#{session_name}:#{window_index}.#{pane_index}|#{pane_width}|#{pane_height}|#{pane_title}|#{pane_active}|#{pane_pid}";
|
|
62
|
-
let output = tmux_cmd(&["list-panes", "-a", "-F", format]).output().await?;
|
|
63
|
-
|
|
64
|
-
if !output.status.success() {
|
|
65
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
66
|
-
return Err(TmuxError::CommandFailed(stderr.into_owned()));
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
70
|
-
let mut panes = Vec::new();
|
|
71
|
-
|
|
72
|
-
for line in stdout.lines() {
|
|
73
|
-
let line = line.trim();
|
|
74
|
-
if line.is_empty() {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
let parts: Vec<&str> = line.splitn(6, '|').collect();
|
|
78
|
-
if parts.len() < 6 {
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
let target = parts[0].to_string();
|
|
82
|
-
// Parse session:window.pane from target
|
|
83
|
-
let (session_name, window_pane) = target
|
|
84
|
-
.split_once(':')
|
|
85
|
-
.ok_or_else(|| TmuxError::ParseError(format!("bad target: {target}")))?;
|
|
86
|
-
let (window_str, pane_str) = window_pane
|
|
87
|
-
.split_once('.')
|
|
88
|
-
.ok_or_else(|| TmuxError::ParseError(format!("bad target: {target}")))?;
|
|
89
|
-
|
|
90
|
-
panes.push(TmuxPane {
|
|
91
|
-
target: target.clone(),
|
|
92
|
-
session_name: session_name.to_string(),
|
|
93
|
-
window_index: window_str.parse().unwrap_or(0),
|
|
94
|
-
pane_index: pane_str.parse().unwrap_or(0),
|
|
95
|
-
cols: parts[1].parse().unwrap_or(80),
|
|
96
|
-
rows: parts[2].parse().unwrap_or(24),
|
|
97
|
-
title: parts[3].to_string(),
|
|
98
|
-
active: parts[4] == "1",
|
|
99
|
-
pid: parts[5].trim().parse().unwrap_or(0),
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
Ok(panes)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/// Get visible screen text for a pane.
|
|
107
|
-
pub async fn capture_pane(target: &str) -> Result<String, TmuxError> {
|
|
108
|
-
let output = tmux_cmd(&["capture-pane", "-t", target, "-p"]).output().await?;
|
|
109
|
-
|
|
110
|
-
if !output.status.success() {
|
|
111
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
112
|
-
return Err(TmuxError::CommandFailed(stderr.into_owned()));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/// Get scrollback buffer for a pane.
|
|
119
|
-
pub async fn capture_scrollback(target: &str) -> Result<String, TmuxError> {
|
|
120
|
-
// -S - means start from the beginning of history; -E - means end at current line
|
|
121
|
-
let output = tmux_cmd(&["capture-pane", "-t", target, "-p", "-S", "-", "-E", "-"])
|
|
122
|
-
.output()
|
|
123
|
-
.await?;
|
|
124
|
-
|
|
125
|
-
if !output.status.success() {
|
|
126
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
127
|
-
return Err(TmuxError::CommandFailed(stderr.into_owned()));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/// Send literal text to a pane (no key name interpretation).
|
|
134
|
-
pub async fn send_text(target: &str, text: &str) -> Result<(), TmuxError> {
|
|
135
|
-
let output = tmux_cmd(&["send-keys", "-t", target, "-l", text]).output().await?;
|
|
136
|
-
|
|
137
|
-
if !output.status.success() {
|
|
138
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
139
|
-
return Err(TmuxError::CommandFailed(stderr.into_owned()));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
Ok(())
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/// Send a key sequence to a pane (tmux key name format, e.g. "Enter", "C-c").
|
|
146
|
-
pub async fn send_key(target: &str, key: &str) -> Result<(), TmuxError> {
|
|
147
|
-
let output = tmux_cmd(&["send-keys", "-t", target, key]).output().await?;
|
|
148
|
-
|
|
149
|
-
if !output.status.success() {
|
|
150
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
151
|
-
return Err(TmuxError::CommandFailed(stderr.into_owned()));
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
Ok(())
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/// Create a new window and return its pane target.
|
|
158
|
-
pub async fn new_window(session: Option<&str>) -> Result<String, TmuxError> {
|
|
159
|
-
let mut args = vec!["new-window", "-P", "-F",
|
|
160
|
-
"#{session_name}:#{window_index}.#{pane_index}"];
|
|
161
|
-
if let Some(s) = session {
|
|
162
|
-
args.push("-t");
|
|
163
|
-
args.push(s);
|
|
164
|
-
}
|
|
165
|
-
let output = tmux_cmd(&args).output().await?;
|
|
166
|
-
|
|
167
|
-
if !output.status.success() {
|
|
168
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
169
|
-
return Err(TmuxError::CommandFailed(stderr.into_owned()));
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
173
|
-
Ok(stdout.trim().to_string())
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/// Kill a pane.
|
|
177
|
-
pub async fn kill_pane(target: &str) -> Result<(), TmuxError> {
|
|
178
|
-
let output = tmux_cmd(&["kill-pane", "-t", target]).output().await?;
|
|
179
|
-
|
|
180
|
-
if !output.status.success() {
|
|
181
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
182
|
-
return Err(TmuxError::CommandFailed(stderr.into_owned()));
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
Ok(())
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/// Resize a pane to exact dimensions.
|
|
189
|
-
pub async fn resize_pane(target: &str, cols: u16, rows: u16) -> Result<(), TmuxError> {
|
|
190
|
-
let cols_str = cols.to_string();
|
|
191
|
-
let rows_str = rows.to_string();
|
|
192
|
-
let output = tmux_cmd(&[
|
|
193
|
-
"resize-pane", "-t", target,
|
|
194
|
-
"-x", &cols_str,
|
|
195
|
-
"-y", &rows_str,
|
|
196
|
-
])
|
|
197
|
-
.output()
|
|
198
|
-
.await?;
|
|
199
|
-
|
|
200
|
-
if !output.status.success() {
|
|
201
|
-
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
202
|
-
return Err(TmuxError::CommandFailed(stderr.into_owned()));
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
Ok(())
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/// Find a pane by its target string.
|
|
209
|
-
pub async fn find_pane(target: &str) -> Result<TmuxPane, TmuxError> {
|
|
210
|
-
let panes = list_panes().await?;
|
|
211
|
-
panes
|
|
212
|
-
.into_iter()
|
|
213
|
-
.find(|p| p.target == target)
|
|
214
|
-
.ok_or_else(|| TmuxError::PaneNotFound(target.to_string()))
|
|
215
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
[package]
|
|
2
|
-
name = "wrightty-bridge-wezterm"
|
|
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 wezterm cli commands"
|
|
10
|
-
|
|
11
|
-
[[bin]]
|
|
12
|
-
name = "wrightty-bridge-wezterm"
|
|
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"
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
use std::net::{SocketAddr, TcpListener};
|
|
2
|
-
use std::process;
|
|
3
|
-
use std::time::Duration;
|
|
4
|
-
|
|
5
|
-
use clap::Parser;
|
|
6
|
-
use jsonrpsee::server::Server;
|
|
7
|
-
use tracing_subscriber::EnvFilter;
|
|
8
|
-
|
|
9
|
-
use wrightty_bridge_wezterm::rpc::build_rpc_module;
|
|
10
|
-
use wrightty_bridge_wezterm::wezterm;
|
|
11
|
-
|
|
12
|
-
const PORT_RANGE_START: u16 = 9420;
|
|
13
|
-
const PORT_RANGE_END: u16 = 9440;
|
|
14
|
-
|
|
15
|
-
#[derive(Parser)]
|
|
16
|
-
#[command(
|
|
17
|
-
name = "wrightty-bridge-wezterm",
|
|
18
|
-
about = "Bridge that translates wrightty protocol calls into wezterm cli commands"
|
|
19
|
-
)]
|
|
20
|
-
struct Cli {
|
|
21
|
-
#[arg(long, default_value = "127.0.0.1")]
|
|
22
|
-
host: String,
|
|
23
|
-
|
|
24
|
-
/// Port to listen on. If not specified, auto-selects the next available port starting at 9420.
|
|
25
|
-
#[arg(long)]
|
|
26
|
-
port: Option<u16>,
|
|
27
|
-
|
|
28
|
-
/// Interval in seconds to check if WezTerm is still running. 0 to disable.
|
|
29
|
-
#[arg(long, default_value_t = 5)]
|
|
30
|
-
watchdog_interval: u64,
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
fn find_available_port(host: &str, start: u16, end: u16) -> Option<u16> {
|
|
34
|
-
for port in start..=end {
|
|
35
|
-
if TcpListener::bind(format!("{host}:{port}")).is_ok() {
|
|
36
|
-
return Some(port);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
None
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
#[tokio::main]
|
|
43
|
-
async fn main() -> anyhow::Result<()> {
|
|
44
|
-
tracing_subscriber::fmt()
|
|
45
|
-
.with_env_filter(
|
|
46
|
-
EnvFilter::from_default_env()
|
|
47
|
-
.add_directive("wrightty_bridge_wezterm=info".parse()?),
|
|
48
|
-
)
|
|
49
|
-
.init();
|
|
50
|
-
|
|
51
|
-
let cli = Cli::parse();
|
|
52
|
-
|
|
53
|
-
// --- Startup health check ---
|
|
54
|
-
tracing::info!("Checking WezTerm connectivity...");
|
|
55
|
-
match wezterm::health_check().await {
|
|
56
|
-
Ok(()) => tracing::info!("WezTerm is reachable"),
|
|
57
|
-
Err(e) => {
|
|
58
|
-
eprintln!("error: Cannot connect to WezTerm: {e}");
|
|
59
|
-
eprintln!();
|
|
60
|
-
eprintln!("Make sure WezTerm is running. If using flatpak, set:");
|
|
61
|
-
eprintln!(" WEZTERM_CMD=\"flatpak run --command=wezterm org.wezfurlong.wezterm\"");
|
|
62
|
-
process::exit(1);
|
|
63
|
-
},
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
let port = match cli.port {
|
|
67
|
-
Some(p) => p,
|
|
68
|
-
None => find_available_port(&cli.host, PORT_RANGE_START, PORT_RANGE_END)
|
|
69
|
-
.ok_or_else(|| anyhow::anyhow!("No available port in range {PORT_RANGE_START}-{PORT_RANGE_END}"))?,
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
let addr: SocketAddr = format!("{}:{}", cli.host, port).parse()?;
|
|
73
|
-
|
|
74
|
-
let module = build_rpc_module()?;
|
|
75
|
-
|
|
76
|
-
let server = Server::builder().build(addr).await?;
|
|
77
|
-
|
|
78
|
-
let handle = server.start(module);
|
|
79
|
-
|
|
80
|
-
tracing::info!("wrightty-bridge-wezterm listening on ws://{addr}");
|
|
81
|
-
println!("wrightty-bridge-wezterm listening on ws://{addr}");
|
|
82
|
-
|
|
83
|
-
// --- Watchdog: periodically check WezTerm is still alive ---
|
|
84
|
-
if cli.watchdog_interval > 0 {
|
|
85
|
-
let interval = Duration::from_secs(cli.watchdog_interval);
|
|
86
|
-
let server_handle = handle.clone();
|
|
87
|
-
tokio::spawn(async move {
|
|
88
|
-
let mut consecutive_failures = 0u32;
|
|
89
|
-
loop {
|
|
90
|
-
tokio::time::sleep(interval).await;
|
|
91
|
-
match wezterm::health_check().await {
|
|
92
|
-
Ok(()) => {
|
|
93
|
-
if consecutive_failures > 0 {
|
|
94
|
-
tracing::info!("WezTerm reconnected");
|
|
95
|
-
consecutive_failures = 0;
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
Err(e) => {
|
|
99
|
-
consecutive_failures += 1;
|
|
100
|
-
tracing::warn!(
|
|
101
|
-
"WezTerm health check failed ({consecutive_failures}): {e}"
|
|
102
|
-
);
|
|
103
|
-
if consecutive_failures >= 3 {
|
|
104
|
-
tracing::error!(
|
|
105
|
-
"WezTerm unreachable after {consecutive_failures} checks, shutting down"
|
|
106
|
-
);
|
|
107
|
-
server_handle.stop().unwrap();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
},
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
handle.stopped().await;
|
|
117
|
-
|
|
118
|
-
Ok(())
|
|
119
|
-
}
|