@idl3/claude-control 0.1.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/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/cli.js +68 -0
- package/bin/install-service.sh +107 -0
- package/bin/self-update.sh +43 -0
- package/bin/uninstall-service.sh +22 -0
- package/lib/answer.js +64 -0
- package/lib/auth.js +81 -0
- package/lib/config.js +118 -0
- package/lib/push.js +153 -0
- package/lib/resources.js +137 -0
- package/lib/sessions.js +529 -0
- package/lib/terminal.js +278 -0
- package/lib/tmux.js +462 -0
- package/lib/transcript.js +451 -0
- package/lib/tui.js +50 -0
- package/lib/uploads.js +42 -0
- package/lib/version.js +73 -0
- package/package.json +49 -0
- package/public/app.js +756 -0
- package/public/index.html +120 -0
- package/public/styles.css +848 -0
- package/server.js +910 -0
- package/web/README.md +66 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/bash-I8pq0VWm.js +1 -0
- package/web/dist/assets/core-BYJcZW10.js +3 -0
- package/web/dist/assets/css-DazXZka4.js +1 -0
- package/web/dist/assets/diff-DiTmLxSS.js +1 -0
- package/web/dist/assets/index-Bb7gXgl-.css +1 -0
- package/web/dist/assets/index-wrjqfzbL.js +77 -0
- package/web/dist/assets/javascript-BKRaQes9.js +1 -0
- package/web/dist/assets/json-DIYVocXf.js +1 -0
- package/web/dist/assets/markdown-BrP960CR.js +1 -0
- package/web/dist/assets/python-sE43i1Pi.js +1 -0
- package/web/dist/assets/typescript-C2FFdlUC.js +1 -0
- package/web/dist/assets/xml-BXBhIUeX.js +1 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +25 -0
- package/web/dist/sw.js +57 -0
package/lib/tmux.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tmux.js — tmux integration for claude-cockpit.
|
|
3
|
+
* ESM, Node >=20 built-ins only. Never shell out with user text.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFile as _execFile } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { access, stat } from 'node:fs/promises';
|
|
9
|
+
import { constants as fsConstants } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
const execFile = promisify(_execFile);
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Binary resolution — cached after first successful probe
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
let _resolvedBin = null;
|
|
17
|
+
|
|
18
|
+
const PROBE_PATHS = [
|
|
19
|
+
'/opt/homebrew/bin/tmux',
|
|
20
|
+
'/usr/local/bin/tmux',
|
|
21
|
+
'/usr/bin/tmux',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the tmux binary, honouring COCKPIT_TMUX, then absolute-path
|
|
26
|
+
* probes, then `command -v tmux` via a login shell (handles PATH correctly
|
|
27
|
+
* without triggering zsh aliases).
|
|
28
|
+
*
|
|
29
|
+
* @returns {Promise<string>} Absolute path to the tmux binary.
|
|
30
|
+
*/
|
|
31
|
+
export async function resolveTmuxBin() {
|
|
32
|
+
if (_resolvedBin) return _resolvedBin;
|
|
33
|
+
|
|
34
|
+
// 1. Explicit override
|
|
35
|
+
const envBin = process.env.COCKPIT_TMUX;
|
|
36
|
+
if (envBin) {
|
|
37
|
+
_resolvedBin = envBin;
|
|
38
|
+
return _resolvedBin;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 2. Fixed absolute paths
|
|
42
|
+
for (const p of PROBE_PATHS) {
|
|
43
|
+
try {
|
|
44
|
+
await access(p, fsConstants.X_OK);
|
|
45
|
+
_resolvedBin = p;
|
|
46
|
+
return _resolvedBin;
|
|
47
|
+
} catch {
|
|
48
|
+
// not present / not executable — try next
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 3. Login shell lookup (avoids zsh alias shadowing)
|
|
53
|
+
try {
|
|
54
|
+
const { stdout } = await execFile('/bin/sh', ['-lc', 'command -v tmux'], {
|
|
55
|
+
timeout: 5000,
|
|
56
|
+
});
|
|
57
|
+
const candidate = stdout.trim();
|
|
58
|
+
if (candidate) {
|
|
59
|
+
_resolvedBin = candidate;
|
|
60
|
+
return _resolvedBin;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// fall through
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error('tmux binary not found; set COCKPIT_TMUX or install tmux');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Socket path
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the absolute path of the tmux server socket this process talks to.
|
|
75
|
+
*
|
|
76
|
+
* claude-control runs tmux on the *default* socket (no `-S`). When we spawn a
|
|
77
|
+
* separate `tmux attach` (e.g. for the ttyd escape hatch) it would otherwise
|
|
78
|
+
* resolve its own default socket from *its* environment — which under launchd
|
|
79
|
+
* can differ from ours, yielding a silent empty terminal. Passing this path as
|
|
80
|
+
* `-S <socket>` pins the attach to the SAME server we discovered the target on.
|
|
81
|
+
*
|
|
82
|
+
* @returns {Promise<string>} absolute socket path (e.g. /tmp/tmux-501/default)
|
|
83
|
+
*/
|
|
84
|
+
export async function getSocketPath() {
|
|
85
|
+
const { stdout } = await runTmux(['display-message', '-p', '#{socket_path}']);
|
|
86
|
+
const socket = stdout.trim();
|
|
87
|
+
if (!socket) throw new Error('tmux did not report a socket path');
|
|
88
|
+
return socket;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Target validation
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** Pattern from CONTRACT: ^[A-Za-z0-9_.-]+:\d+(\.\d+)?$ */
|
|
96
|
+
const TARGET_RE = /^[A-Za-z0-9_.-]+:\d+(\.\d+)?$/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Returns true when `target` is a syntactically valid tmux target string.
|
|
100
|
+
* Does NOT verify the target is live — that requires a round-trip to tmux.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} target
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
export function isValidTarget(target) {
|
|
106
|
+
return typeof target === 'string' && TARGET_RE.test(target);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Internal helpers
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Run a tmux sub-command with an explicit args array.
|
|
115
|
+
* @param {string[]} args
|
|
116
|
+
* @param {{ timeout?: number }} [opts]
|
|
117
|
+
* @returns {Promise<{ stdout: string, stderr: string }>}
|
|
118
|
+
*/
|
|
119
|
+
async function runTmux(args, opts = {}) {
|
|
120
|
+
const bin = await resolveTmuxBin();
|
|
121
|
+
return execFile(bin, args, {
|
|
122
|
+
timeout: opts.timeout ?? 10_000,
|
|
123
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
124
|
+
// Force a UTF-8 locale: in the C/POSIX locale a launchd agent inherits, tmux
|
|
125
|
+
// sanitizes our \x1f field separator to '_', so list parsing yields nothing.
|
|
126
|
+
// A UTF-8 locale makes tmux emit \x1f literally. (Honor an existing locale.)
|
|
127
|
+
env: { ...process.env, LC_ALL: process.env.LC_ALL || 'en_US.UTF-8' },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Assert the target is valid, throwing a descriptive error if not.
|
|
133
|
+
* @param {string} target
|
|
134
|
+
*/
|
|
135
|
+
function assertTarget(target) {
|
|
136
|
+
if (!isValidTarget(target)) {
|
|
137
|
+
throw new Error(`Invalid tmux target: ${JSON.stringify(target)} — must match session:index[.pane]`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// List windows
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @typedef {Object} Window
|
|
147
|
+
* @property {string} sessionName
|
|
148
|
+
* @property {number} windowIndex
|
|
149
|
+
* @property {string} windowName
|
|
150
|
+
* @property {string} target "sessionName:windowIndex"
|
|
151
|
+
* @property {boolean} active
|
|
152
|
+
* @property {number} panePid
|
|
153
|
+
* @property {string} cwd
|
|
154
|
+
* @property {string} cmd
|
|
155
|
+
* @property {string} windowId tmux @-id; SHARED across grouped sessions
|
|
156
|
+
* @property {number} paneIndex
|
|
157
|
+
*/
|
|
158
|
+
|
|
159
|
+
const SEP = '\x1f';
|
|
160
|
+
|
|
161
|
+
const FORMAT = [
|
|
162
|
+
'#{session_name}',
|
|
163
|
+
'#{window_index}',
|
|
164
|
+
'#{window_name}',
|
|
165
|
+
'#{window_active}',
|
|
166
|
+
'#{pane_pid}',
|
|
167
|
+
'#{pane_current_path}',
|
|
168
|
+
'#{pane_current_command}',
|
|
169
|
+
'#{window_id}',
|
|
170
|
+
'#{pane_index}',
|
|
171
|
+
].join(SEP);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* List all tmux windows across all sessions.
|
|
175
|
+
* Resolves to [] when no tmux server is running.
|
|
176
|
+
*
|
|
177
|
+
* @returns {Promise<Window[]>}
|
|
178
|
+
*/
|
|
179
|
+
export async function listWindows() {
|
|
180
|
+
let stdout;
|
|
181
|
+
try {
|
|
182
|
+
({ stdout } = await runTmux(['list-windows', '-a', '-F', FORMAT]));
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// tmux exits 1 with "no server running" or "error connecting to server"
|
|
185
|
+
const msg = String(err?.message || '');
|
|
186
|
+
if (
|
|
187
|
+
msg.includes('no server running') ||
|
|
188
|
+
msg.includes('error connecting') ||
|
|
189
|
+
msg.includes('no sessions') ||
|
|
190
|
+
(err?.code === 1 && (!err.stderr || err.stderr.includes('no server')))
|
|
191
|
+
) {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
// Also handle the case where stderr contains the no-server message
|
|
195
|
+
if (err?.stderr && (
|
|
196
|
+
err.stderr.includes('no server running') ||
|
|
197
|
+
err.stderr.includes('error connecting')
|
|
198
|
+
)) {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const windows = [];
|
|
205
|
+
for (const line of stdout.split('\n')) {
|
|
206
|
+
const trimmed = line.trim();
|
|
207
|
+
if (!trimmed) continue;
|
|
208
|
+
|
|
209
|
+
const parts = trimmed.split(SEP);
|
|
210
|
+
if (parts.length < 7) continue;
|
|
211
|
+
|
|
212
|
+
const [sessionName, rawIndex, windowName, rawActive, rawPid, cwd, cmd, windowId, rawPane] = parts;
|
|
213
|
+
const windowIndex = Number(rawIndex);
|
|
214
|
+
const panePid = Number(rawPid);
|
|
215
|
+
|
|
216
|
+
windows.push({
|
|
217
|
+
sessionName,
|
|
218
|
+
windowIndex,
|
|
219
|
+
windowName,
|
|
220
|
+
target: `${sessionName}:${windowIndex}`,
|
|
221
|
+
active: rawActive === '1',
|
|
222
|
+
panePid,
|
|
223
|
+
cwd,
|
|
224
|
+
cmd,
|
|
225
|
+
windowId: windowId ?? `${sessionName}:${windowIndex}`,
|
|
226
|
+
paneIndex: Number(rawPane) || 0,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return windows;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Session-name helpers
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Sanitize a user-supplied session name for safe use as a tmux window name and
|
|
239
|
+
* inside a `send-keys` payload. Strips ASCII control characters and newlines
|
|
240
|
+
* (which could smuggle extra key events into the pane), collapses runs of
|
|
241
|
+
* whitespace, and caps length. tmux window names may legitimately contain
|
|
242
|
+
* spaces and punctuation, so those are kept — only dangerous bytes are removed.
|
|
243
|
+
* Returns '' when nothing usable remains (callers supply a default).
|
|
244
|
+
*
|
|
245
|
+
* @param {string} name
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
export function sanitizeName(name) {
|
|
249
|
+
return String(name ?? '')
|
|
250
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ') // control chars incl. \n \r \t ESC, and DEL
|
|
251
|
+
.replace(/\s+/g, ' ')
|
|
252
|
+
.trim()
|
|
253
|
+
.slice(0, 80);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Generate a sensible default session name: `session-<short-timestamp>` where
|
|
258
|
+
* the suffix is the tail of the base-36 epoch-ms — short, and monotonic enough
|
|
259
|
+
* to disambiguate rapid creations.
|
|
260
|
+
*
|
|
261
|
+
* @param {number} [now=Date.now()]
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
export function defaultSessionName(now = Date.now()) {
|
|
265
|
+
return `session-${now.toString(36).slice(-6)}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Wrap an already-sanitized name in single quotes for safe interpolation into a
|
|
270
|
+
* shell command that is typed into a pane via `send-keys` (e.g. appending
|
|
271
|
+
* `--name '<name>'` to a launch command). Single-quote-escaping is the only
|
|
272
|
+
* shell metacharacter that matters inside single quotes; sanitizeName has
|
|
273
|
+
* already removed newlines/control chars, so this fully neutralizes the value.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} name Output of sanitizeName (no control chars).
|
|
276
|
+
* @returns {string} e.g. "'my session'" or "'it'\''s'".
|
|
277
|
+
*/
|
|
278
|
+
export function shellQuoteName(name) {
|
|
279
|
+
return `'${String(name).replace(/'/g, `'\\''`)}'`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Create window
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Create a new tmux window running the DEFAULT shell (NOT a passed command),
|
|
288
|
+
* and return its "session:window" target. The launch command is sent
|
|
289
|
+
* separately via send-keys (see server.js) so the interactive shell loads its
|
|
290
|
+
* rc and resolves aliases (e.g. `yolo`) — passing the command as
|
|
291
|
+
* `new-window <cmd>` would exec it directly, bypassing alias resolution.
|
|
292
|
+
*
|
|
293
|
+
* If a tmux server/session already exists, the window is created in the first
|
|
294
|
+
* existing session. If no server is running, a detached "claude-control"
|
|
295
|
+
* session is created first and used.
|
|
296
|
+
*
|
|
297
|
+
* @param {{ cwd: string, name?: string }} opts
|
|
298
|
+
* @returns {Promise<string>} target "session:windowIndex"
|
|
299
|
+
*/
|
|
300
|
+
export async function createWindow({ cwd, name } = {}) {
|
|
301
|
+
if (typeof cwd !== 'string' || !cwd) {
|
|
302
|
+
throw new Error('createWindow: cwd is required');
|
|
303
|
+
}
|
|
304
|
+
// Validate the cwd exists and is a directory before handing it to tmux, so we
|
|
305
|
+
// surface a clear error instead of tmux's terse "can't find directory".
|
|
306
|
+
let st;
|
|
307
|
+
try {
|
|
308
|
+
st = await stat(cwd);
|
|
309
|
+
} catch {
|
|
310
|
+
throw new Error(`createWindow: cwd does not exist: ${cwd}`);
|
|
311
|
+
}
|
|
312
|
+
if (!st.isDirectory()) {
|
|
313
|
+
throw new Error(`createWindow: cwd is not a directory: ${cwd}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Re-sanitize defensively: the window name reaches tmux as an argv value, but
|
|
317
|
+
// callers may pass raw user text. An empty result means "let tmux auto-name".
|
|
318
|
+
const safeName = sanitizeName(name);
|
|
319
|
+
|
|
320
|
+
const windows = await listWindows();
|
|
321
|
+
|
|
322
|
+
// No tmux server/session yet — bootstrap a detached session in the cwd. The
|
|
323
|
+
// session's first window IS our target window, so no extra new-window needed.
|
|
324
|
+
if (windows.length === 0) {
|
|
325
|
+
const sessionName = 'claude-control';
|
|
326
|
+
const args = ['new-session', '-d', '-s', sessionName, '-c', cwd];
|
|
327
|
+
if (safeName) args.push('-n', safeName);
|
|
328
|
+
await runTmux(args);
|
|
329
|
+
// The fresh session opens at window index 0 (tmux's base-index may differ,
|
|
330
|
+
// but the first list entry is authoritative).
|
|
331
|
+
const after = await listWindows();
|
|
332
|
+
const win = after.find((w) => w.sessionName === sessionName);
|
|
333
|
+
const target = win ? win.target : `${sessionName}:0`;
|
|
334
|
+
if (!isValidTarget(target)) {
|
|
335
|
+
throw new Error(`createWindow: produced invalid target: ${target}`);
|
|
336
|
+
}
|
|
337
|
+
return target;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// A server exists — create the window in the first existing session and read
|
|
341
|
+
// back its "session:window" target via the -P/-F print format.
|
|
342
|
+
const targetSession = windows[0].sessionName;
|
|
343
|
+
const args = [
|
|
344
|
+
'new-window',
|
|
345
|
+
'-t', targetSession,
|
|
346
|
+
'-P',
|
|
347
|
+
'-F', '#{session_name}:#{window_index}',
|
|
348
|
+
'-c', cwd,
|
|
349
|
+
];
|
|
350
|
+
if (safeName) args.push('-n', safeName);
|
|
351
|
+
const { stdout } = await runTmux(args);
|
|
352
|
+
const target = stdout.trim();
|
|
353
|
+
if (!isValidTarget(target)) {
|
|
354
|
+
throw new Error(`createWindow: produced invalid target: ${JSON.stringify(target)}`);
|
|
355
|
+
}
|
|
356
|
+
return target;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Rename window
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Rename a tmux window so it shows the new label in the rail immediately. The
|
|
365
|
+
* name reaches tmux as an argv value (NOT typed into the pane), and the `--`
|
|
366
|
+
* terminator stops tmux from treating a leading `-` as a flag. Callers must
|
|
367
|
+
* sanitize the name first (sanitizeName strips control chars/newlines).
|
|
368
|
+
*
|
|
369
|
+
* @param {string} target e.g. "0:3"
|
|
370
|
+
* @param {string} name already-sanitized window name
|
|
371
|
+
* @returns {Promise<void>}
|
|
372
|
+
*/
|
|
373
|
+
export async function renameWindow(target, name) {
|
|
374
|
+
assertTarget(target);
|
|
375
|
+
await runTmux(['rename-window', '-t', target, '--', String(name)]);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Send text (literal, with Enter)
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Send literal text to a tmux pane and then press Enter.
|
|
384
|
+
* Uses `-l` so tmux does not interpret key names.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} target e.g. "0:3"
|
|
387
|
+
* @param {string} text
|
|
388
|
+
* @returns {Promise<void>}
|
|
389
|
+
*/
|
|
390
|
+
export async function sendText(target, text) {
|
|
391
|
+
assertTarget(target);
|
|
392
|
+
// Step 1: literal text (no key interpretation)
|
|
393
|
+
await runTmux(['send-keys', '-t', target, '-l', text]);
|
|
394
|
+
// Step 2: press Enter
|
|
395
|
+
await runTmux(['send-keys', '-t', target, 'Enter']);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Send raw key names (no -l)
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Send a sequence of key names (e.g. 'Down', 'Space', 'Enter') to a pane.
|
|
404
|
+
* Does NOT use `-l`, so tmux interprets these as key names.
|
|
405
|
+
*
|
|
406
|
+
* @param {string} target
|
|
407
|
+
* @param {string[]} keys e.g. ['Down', 'Down', 'Space', 'Enter']
|
|
408
|
+
* @returns {Promise<void>}
|
|
409
|
+
*/
|
|
410
|
+
export async function sendRawKeys(target, keys) {
|
|
411
|
+
assertTarget(target);
|
|
412
|
+
if (!Array.isArray(keys) || keys.length === 0) return;
|
|
413
|
+
await runTmux(['send-keys', '-t', target, ...keys]);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Send key names ONE AT A TIME with a delay between each. Needed for the
|
|
418
|
+
* AskUserQuestion picker, whose single-select number keys trigger an async
|
|
419
|
+
* tab-advance re-render — firing the next key too soon lands it on the wrong
|
|
420
|
+
* question.
|
|
421
|
+
*
|
|
422
|
+
* @param {string} target
|
|
423
|
+
* @param {string[]} keys
|
|
424
|
+
* @param {number} [delayMs=130]
|
|
425
|
+
* @returns {Promise<void>}
|
|
426
|
+
*/
|
|
427
|
+
export async function sendRawKeysSequenced(target, keys, delayMs = 130) {
|
|
428
|
+
assertTarget(target);
|
|
429
|
+
if (!Array.isArray(keys) || keys.length === 0) return;
|
|
430
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
431
|
+
await runTmux(['send-keys', '-t', target, keys[i]]);
|
|
432
|
+
if (i < keys.length - 1) {
|
|
433
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Capture pane
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Capture the visible content of a tmux pane.
|
|
444
|
+
* `-e` preserves ANSI escape sequences (server may strip before forwarding).
|
|
445
|
+
*
|
|
446
|
+
* @param {string} target
|
|
447
|
+
* @param {number} [lines=40] How many history lines above the visible area to include.
|
|
448
|
+
* @returns {Promise<string>}
|
|
449
|
+
*/
|
|
450
|
+
export async function capturePane(target, lines = 40) {
|
|
451
|
+
assertTarget(target);
|
|
452
|
+
const { stdout } = await runTmux([
|
|
453
|
+
'capture-pane',
|
|
454
|
+
'-t', target,
|
|
455
|
+
'-p', // print to stdout
|
|
456
|
+
// NOTE: no '-e' — the UI renders the capture as plain text (LivePane <pre>,
|
|
457
|
+
// AskModal peek), so ANSI escapes would show as literal garbage. Strip them
|
|
458
|
+
// at the source by capturing without escape sequences.
|
|
459
|
+
'-S', `-${lines}`, // start N lines above visible area
|
|
460
|
+
]);
|
|
461
|
+
return stdout;
|
|
462
|
+
}
|