@termfleet/terminal 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/README.md +31 -0
- package/dist/attach.d.ts +29 -0
- package/dist/attach.js +141 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +100 -0
- package/dist/internal/exec.d.ts +15 -0
- package/dist/internal/exec.js +192 -0
- package/dist/internal/process-tree.d.ts +1 -0
- package/dist/internal/process-tree.js +41 -0
- package/dist/tmux-stream.d.ts +31 -0
- package/dist/tmux-stream.js +187 -0
- package/dist/tmux.d.ts +128 -0
- package/dist/tmux.js +782 -0
- package/package.json +68 -0
package/dist/tmux.js
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { requireCommand, run, runAsync, runWithInputAsync, spawnInherited } from "./internal/exec.js";
|
|
5
|
+
import { snapshotDescendantPids } from "./internal/process-tree.js";
|
|
6
|
+
const TMUX_INSTALL_HINT = "Install it with: brew install tmux";
|
|
7
|
+
const PTY_ALLOCATION_HINT = [
|
|
8
|
+
"The tmux host could not create a pane because the OS could not allocate a pseudo-terminal.",
|
|
9
|
+
"This usually means local terminal/pty capacity is exhausted by existing terminal or tmux sessions.",
|
|
10
|
+
"No sessions were automatically closed. Inspect existing sessions and close only work that is safe to stop."
|
|
11
|
+
].join(" ");
|
|
12
|
+
// Session-scoped tmux user option that records which provider created a session.
|
|
13
|
+
const ownerOption = "@tf-owner";
|
|
14
|
+
// Optional dedicated tmux server (`tmux -L <socket>`). A `-L` server is fully
|
|
15
|
+
// separate from the user's DEFAULT server — the default can't see it and
|
|
16
|
+
// `kill-server` on it can't escape — so a provider (or a test) can run in
|
|
17
|
+
// complete isolation. `undefined` = the default server, i.e. production behavior
|
|
18
|
+
// unchanged. Always passed explicitly (never global/ambient state) so concurrent
|
|
19
|
+
// providers and tests can't race it.
|
|
20
|
+
function tmuxArgs(socket, args) {
|
|
21
|
+
return socket ? ["-L", socket, ...args] : args;
|
|
22
|
+
}
|
|
23
|
+
export function assertTmux() {
|
|
24
|
+
requireCommand("tmux", TMUX_INSTALL_HINT);
|
|
25
|
+
}
|
|
26
|
+
export function targetForSession(sessionName) {
|
|
27
|
+
if (!sessionName) {
|
|
28
|
+
throw new Error("A session name is required.");
|
|
29
|
+
}
|
|
30
|
+
return `${sessionName}:0`;
|
|
31
|
+
}
|
|
32
|
+
export function assertSession(name, socket) {
|
|
33
|
+
assertTmux();
|
|
34
|
+
if (!name) {
|
|
35
|
+
throw new Error("--name is required.");
|
|
36
|
+
}
|
|
37
|
+
run("tmux", tmuxArgs(socket, ["has-session", "-t", name]));
|
|
38
|
+
}
|
|
39
|
+
// Async: window creation runs on the provider event loop (createWindow →
|
|
40
|
+
// createWindowSession), so every tmux invocation here uses runAsync (spawn, not
|
|
41
|
+
// spawnSync) and the pane-ready poll below uses sleepAsync — see waitForPanesReady.
|
|
42
|
+
// Nothing in this function may block the loop.
|
|
43
|
+
export async function createSession({ cwd, env, name, owner, ownerOption: ownershipOption = ownerOption, panes, socket }) {
|
|
44
|
+
assertTmux();
|
|
45
|
+
if (!name) {
|
|
46
|
+
throw new Error("--name is required.");
|
|
47
|
+
}
|
|
48
|
+
if (!Number.isInteger(panes) || panes < 1) {
|
|
49
|
+
throw new Error("--panes must be a positive integer.");
|
|
50
|
+
}
|
|
51
|
+
// Global server options must be in place before the first pane spawns —
|
|
52
|
+
// history-limit is read at pane creation. On a fresh dedicated `-L` socket no
|
|
53
|
+
// server exists yet and `set-option -g` will NOT start one (only commands like
|
|
54
|
+
// new-session do), so chain the option-set and the create into ONE tmux
|
|
55
|
+
// invocation: the trailing new-session keeps alive the server its preceding
|
|
56
|
+
// set-options just configured. On an already-running server they are simply
|
|
57
|
+
// reapplied.
|
|
58
|
+
const sessionEnv = env ?? {};
|
|
59
|
+
const bootstrapDirectories = [];
|
|
60
|
+
const paneBootstrapArgs = (pane) => {
|
|
61
|
+
if (Object.keys(sessionEnv).length === 0)
|
|
62
|
+
return [];
|
|
63
|
+
const directory = mkdtempSync(join(tmpdir(), "tmux-session-env-"));
|
|
64
|
+
bootstrapDirectories.push(directory);
|
|
65
|
+
return writeSessionEnvironmentBootstrap(directory, pane, sessionEnv);
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
const startArgs = [...sessionServerOptionArgs(), "new-session", "-d", "-s", name];
|
|
69
|
+
if (cwd) {
|
|
70
|
+
startArgs.push("-c", cwd);
|
|
71
|
+
}
|
|
72
|
+
startArgs.push(...paneBootstrapArgs(0));
|
|
73
|
+
await runTmuxSessionCreateAsync(startArgs, { cwd, operation: "create tmux session", session: name, socket });
|
|
74
|
+
if (owner) {
|
|
75
|
+
await runAsync("tmux", tmuxArgs(socket, ["set-option", "-t", name, ownershipOption, owner]));
|
|
76
|
+
}
|
|
77
|
+
await declareSessionColorEnvironmentAsync(socket);
|
|
78
|
+
const target = targetForSession(name);
|
|
79
|
+
for (let i = 1; i < panes; i += 1) {
|
|
80
|
+
const splitArgs = ["split-window", "-t", target];
|
|
81
|
+
if (cwd) {
|
|
82
|
+
splitArgs.push("-c", cwd);
|
|
83
|
+
}
|
|
84
|
+
splitArgs.push(...paneBootstrapArgs(i));
|
|
85
|
+
await runTmuxSessionCreateAsync(splitArgs, { cwd, operation: "split tmux window", session: name, socket });
|
|
86
|
+
await runAsync("tmux", tmuxArgs(socket, ["select-layout", "-t", target, "tiled"]));
|
|
87
|
+
}
|
|
88
|
+
await waitForPanesReady({ expectedPanes: panes, socket, target });
|
|
89
|
+
for (const directory of bootstrapDirectories)
|
|
90
|
+
await waitForSessionBootstrapCleanup(directory);
|
|
91
|
+
return { name, panes };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
for (const directory of bootstrapDirectories)
|
|
95
|
+
rmSync(directory, { force: true, recursive: true });
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Names of the live tmux sessions stamped with exactly this owner. Used to
|
|
100
|
+
// reclaim a provider's own sessions without name-guessing: an unset `@tf-owner`
|
|
101
|
+
// reads as empty, so untagged (user/foreign) sessions never match, and exact
|
|
102
|
+
// equality means owner `host` never sweeps owner `host-2`'s sessions.
|
|
103
|
+
// The `@tf-owner` mark on one session (the value, or undefined if the session
|
|
104
|
+
// doesn't exist or is untagged). Lets a caller kill ONLY a session it owns —
|
|
105
|
+
// never a user's own same-named session on the shared default server.
|
|
106
|
+
export function sessionOwner(session, socket, ownershipOption = ownerOption) {
|
|
107
|
+
if (!session) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const value = run("tmux", tmuxArgs(socket, ["show-options", "-t", session, "-v", ownershipOption])).trim();
|
|
112
|
+
return value || undefined;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined; // no such session
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function sessionsOwnedBy(owner, socket, ownershipOption = ownerOption) {
|
|
119
|
+
assertTmux();
|
|
120
|
+
if (!owner) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
let output;
|
|
124
|
+
try {
|
|
125
|
+
output = run("tmux", tmuxArgs(socket, ["list-sessions", "-F", `#{session_name}\t#{${ownershipOption}}`]));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return []; // no server / no sessions
|
|
129
|
+
}
|
|
130
|
+
const owned = [];
|
|
131
|
+
for (const line of output.split("\n")) {
|
|
132
|
+
const [name, tag] = line.split("\t");
|
|
133
|
+
if (name && tag === owner) {
|
|
134
|
+
owned.push(name);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return owned;
|
|
138
|
+
}
|
|
139
|
+
// Kill a session and the processes it spawned. tmux kill-session SIGHUPs the
|
|
140
|
+
// pane's foreground group, but agents fork detached helpers (MCP servers, node
|
|
141
|
+
// children) that survive that signal, reparent to init, and pile up until the
|
|
142
|
+
// host is out of resources (issue #10 — the same mechanism that, on the iTerm
|
|
143
|
+
// driver, let `killall iTerm2` orphan dozens of live claude processes: closing
|
|
144
|
+
// the GUI window never touched these). Snapshot the live descendant tree BEFORE
|
|
145
|
+
// killing the session — once the pane's shell exits its children reparent and
|
|
146
|
+
// can no longer be found from the (gone) root pid — then SIGKILL whatever the
|
|
147
|
+
// snapshot held. Best-effort throughout; the shared home for every driver that
|
|
148
|
+
// backs a window with a tmux session (virtual-tmux, iTerm).
|
|
149
|
+
export function killSessionProcessTree({ session, socket }) {
|
|
150
|
+
// Scope the pid lookup to this session (not the global `-a` list) and run it
|
|
151
|
+
// only on an explicit close/reap/dispose (event-driven, not the per-cycle
|
|
152
|
+
// observe loop). The sync `ps` below (via snapshotDescendantPids) is likewise
|
|
153
|
+
// occasional, never on a hot path.
|
|
154
|
+
let rootPids = [];
|
|
155
|
+
try {
|
|
156
|
+
rootPids = run("tmux", tmuxArgs(socket, ["list-panes", "-t", session, "-F", "#{pane_pid}"]))
|
|
157
|
+
.split("\n")
|
|
158
|
+
.map((line) => Number(line.trim()))
|
|
159
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// tmux may already be down; still attempt the kill-session below.
|
|
163
|
+
}
|
|
164
|
+
// Capture ONLY this session's descendant pids — snapshotDescendantPids walks
|
|
165
|
+
// down from the pane pids. (The whole machine's process table is NOT a safe
|
|
166
|
+
// input: SIGKILLing every row would kill every process the user owns — every
|
|
167
|
+
// terminal, the tmux server, the console. That was the all-terminals-die bug.)
|
|
168
|
+
const descendantPids = rootPids.length > 0 ? snapshotDescendantPids(rootPids) : [];
|
|
169
|
+
try {
|
|
170
|
+
run("tmux", tmuxArgs(socket, ["kill-session", "-t", session]));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// session may already be gone
|
|
174
|
+
}
|
|
175
|
+
for (const pid of descendantPids) {
|
|
176
|
+
try {
|
|
177
|
+
process.kill(pid, "SIGKILL");
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// already exited
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Live tmux session names. Throws if there is no server / no sessions, which
|
|
185
|
+
// callers treat as "nothing to reconcile".
|
|
186
|
+
export function listTmuxSessionNames(socket) {
|
|
187
|
+
return run("tmux", tmuxArgs(socket, ["list-sessions", "-F", "#{session_name}"])).split("\n").map((line) => line.trim()).filter(Boolean);
|
|
188
|
+
}
|
|
189
|
+
// A session belongs to a provider's prefix iff it is the bare `prefix` or a
|
|
190
|
+
// `prefix-*` child. The exact `-` boundary is load-bearing: prefix `worker` must
|
|
191
|
+
// not claim `worker-pool-1`. The single home for that rule.
|
|
192
|
+
function matchesProviderPrefix(name, prefix) {
|
|
193
|
+
return name === prefix || name.startsWith(`${prefix}-`);
|
|
194
|
+
}
|
|
195
|
+
// `prefix-*` (and the bare `prefix`) sessions that a provider does not track.
|
|
196
|
+
// Pure so the orphan-reclaim decision is unit-tested without a live tmux server.
|
|
197
|
+
export function selectOrphanSessions({ allSessions, ownedSessions, prefix }) {
|
|
198
|
+
const owned = ownedSessions instanceof Set ? ownedSessions : new Set(ownedSessions);
|
|
199
|
+
return allSessions.filter((name) => matchesProviderPrefix(name, prefix) && !owned.has(name));
|
|
200
|
+
}
|
|
201
|
+
// Split the live `prefix-*` sessions into what a provider still owns and what it
|
|
202
|
+
// orphaned. `discovered`/`tracked`/`kept` are sorted for a stable summary;
|
|
203
|
+
// `orphans` follows allSessions order (the kill order). Pure — the classification
|
|
204
|
+
// is unit-tested without a live tmux server, mirroring selectOrphanSessions.
|
|
205
|
+
// `tracked` is every owned prefix session (some may be dead — not in `discovered`);
|
|
206
|
+
// `kept` is the owned sessions still live (`discovered` ∩ owned). Orphans are
|
|
207
|
+
// never owned, so they never appear in `kept`.
|
|
208
|
+
export function classifyPrefixSessions({ allSessions, ownedSessions, prefix }) {
|
|
209
|
+
const owned = ownedSessions instanceof Set ? ownedSessions : new Set(ownedSessions);
|
|
210
|
+
const discovered = allSessions.filter((name) => matchesProviderPrefix(name, prefix)).sort();
|
|
211
|
+
const tracked = [...owned].filter((name) => matchesProviderPrefix(name, prefix)).sort();
|
|
212
|
+
const kept = discovered.filter((name) => owned.has(name));
|
|
213
|
+
const orphans = selectOrphanSessions({ allSessions, ownedSessions: owned, prefix });
|
|
214
|
+
return { discovered, kept, orphans, tracked };
|
|
215
|
+
}
|
|
216
|
+
// The tmux server daemonizes with the environment of whichever process first
|
|
217
|
+
// touches it; a provider launched from a CI or agent shell carries NO_COLOR=1
|
|
218
|
+
// (plus npm's COLOR=0 and CODEX_CI=1), every pane inherits it, and programs
|
|
219
|
+
// render monochrome. Declare the color environment in the server's global
|
|
220
|
+
// environment before any pane spawns instead of inheriting that accident.
|
|
221
|
+
// (The first set-environment call also starts the server, so a fresh server
|
|
222
|
+
// is cleaned before its first session exists.)
|
|
223
|
+
async function declareSessionColorEnvironmentAsync(socket) {
|
|
224
|
+
for (const variable of ["NO_COLOR", "COLOR", "CODEX_CI"]) {
|
|
225
|
+
await runAsync("tmux", tmuxArgs(socket, ["set-environment", "-gu", variable]));
|
|
226
|
+
}
|
|
227
|
+
await runAsync("tmux", tmuxArgs(socket, ["set-environment", "-g", "COLORTERM", "truecolor"]));
|
|
228
|
+
}
|
|
229
|
+
// Server-wide options every managed session should carry, emitted as a chained-
|
|
230
|
+
// command prefix for the session-create invocation (see createSession for why
|
|
231
|
+
// they must share one tmux invocation). The iTerm/WezTerm/virtual-tmux browser
|
|
232
|
+
// mirrors run `tmux attach-session`, so they are full-screen tmux clients:
|
|
233
|
+
// native scrollback is bypassed and, with tmux's default `mouse off`, the wheel
|
|
234
|
+
// is unbound — nothing scrolls. `mouse on` binds the wheel to copy-mode in every
|
|
235
|
+
// client; `history-limit` (read at pane creation) gives every pane a deep
|
|
236
|
+
// scrollback. Each command ends with a standalone `;` so the trailing new-session
|
|
237
|
+
// runs in the same invocation — `run` uses spawnSync without a shell, so the `;`
|
|
238
|
+
// reaches tmux as a literal command separator, not a shell metacharacter.
|
|
239
|
+
function sessionServerOptionArgs() {
|
|
240
|
+
return [
|
|
241
|
+
"set-option", "-g", "history-limit", "50000", ";",
|
|
242
|
+
"set-option", "-g", "mouse", "on", ";"
|
|
243
|
+
];
|
|
244
|
+
}
|
|
245
|
+
const tmuxClientEnvironmentNames = [
|
|
246
|
+
"COLORTERM",
|
|
247
|
+
"DISPLAY",
|
|
248
|
+
"HOME",
|
|
249
|
+
"LANG",
|
|
250
|
+
"LANGUAGE",
|
|
251
|
+
"LC_ALL",
|
|
252
|
+
"LC_CTYPE",
|
|
253
|
+
"LOGNAME",
|
|
254
|
+
"PATH",
|
|
255
|
+
"SHELL",
|
|
256
|
+
"TERM",
|
|
257
|
+
"TERM_PROGRAM",
|
|
258
|
+
"TMPDIR",
|
|
259
|
+
"TMUX_TMPDIR",
|
|
260
|
+
"USER",
|
|
261
|
+
"WAYLAND_DISPLAY",
|
|
262
|
+
"XAUTHORITY",
|
|
263
|
+
"XDG_RUNTIME_DIR"
|
|
264
|
+
];
|
|
265
|
+
function tmuxClientEnvironment(source = process.env) {
|
|
266
|
+
const environment = {};
|
|
267
|
+
for (const name of tmuxClientEnvironmentNames) {
|
|
268
|
+
const value = source[name];
|
|
269
|
+
if (value !== undefined)
|
|
270
|
+
environment[name] = value;
|
|
271
|
+
}
|
|
272
|
+
return environment;
|
|
273
|
+
}
|
|
274
|
+
function writeSessionEnvironmentBootstrap(directory, pane, environment) {
|
|
275
|
+
const environmentPath = join(directory, `pane-${pane}.env`);
|
|
276
|
+
const wrapperPath = join(directory, `pane-${pane}.sh`);
|
|
277
|
+
const shell = process.env.SHELL ?? "/bin/sh";
|
|
278
|
+
const lines = Object.entries(environment).map(([name, value]) => {
|
|
279
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
280
|
+
throw new Error(`Invalid session environment variable name: ${JSON.stringify(name)}.`);
|
|
281
|
+
}
|
|
282
|
+
return `export ${name}=${shellQuote(value)}`;
|
|
283
|
+
});
|
|
284
|
+
writeFileSync(environmentPath, `${lines.join("\n")}\n`, { flag: "wx", mode: 0o600 });
|
|
285
|
+
writeFileSync(wrapperPath, [
|
|
286
|
+
"#!/bin/sh",
|
|
287
|
+
"set -a",
|
|
288
|
+
'. "$1"',
|
|
289
|
+
"set +a",
|
|
290
|
+
'rm -f -- "$1" "$0"',
|
|
291
|
+
'rmdir -- "$(dirname -- "$0")" 2>/dev/null || true',
|
|
292
|
+
'exec "$2" -l',
|
|
293
|
+
""
|
|
294
|
+
].join("\n"), { flag: "wx", mode: 0o700 });
|
|
295
|
+
return [wrapperPath, environmentPath, shell];
|
|
296
|
+
}
|
|
297
|
+
function shellQuote(value) {
|
|
298
|
+
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
299
|
+
}
|
|
300
|
+
async function waitForSessionBootstrapCleanup(directory, timeoutMs = 10_000) {
|
|
301
|
+
const startedAt = Date.now();
|
|
302
|
+
while (existsSync(directory) && Date.now() - startedAt < timeoutMs) {
|
|
303
|
+
await sleepAsync(10);
|
|
304
|
+
}
|
|
305
|
+
if (existsSync(directory)) {
|
|
306
|
+
throw new Error(`Timed out waiting for the tmux pane environment bootstrap to self-delete: ${directory}.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function runTmuxSessionCreateAsync(args, context) {
|
|
310
|
+
try {
|
|
311
|
+
return await runAsync("tmux", tmuxArgs(context.socket, args), { env: tmuxClientEnvironment(), inheritEnv: false });
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
315
|
+
if (isPtyAllocationFailure(message)) {
|
|
316
|
+
throw new Error([
|
|
317
|
+
`${context.operation} failed for session "${context.session}".`,
|
|
318
|
+
context.cwd ? `cwd: ${context.cwd}.` : undefined,
|
|
319
|
+
PTY_ALLOCATION_HINT,
|
|
320
|
+
`Original tmux error: ${message}`
|
|
321
|
+
].filter(Boolean).join(" "));
|
|
322
|
+
}
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function isPtyAllocationFailure(message) {
|
|
327
|
+
return /fork failed:\s*Device not configured/i.test(message)
|
|
328
|
+
|| /openpty:\s*Device not configured/i.test(message)
|
|
329
|
+
|| /create window failed/i.test(message) && /Device not configured/i.test(message);
|
|
330
|
+
}
|
|
331
|
+
// Async: this poll runs inside createSession, on the createWindow path (the
|
|
332
|
+
// provider event loop). Both the delay (sleepAsync, a real setTimeout) and the
|
|
333
|
+
// per-iteration status check (inspectPanesAsync, a non-blocking spawn) yield the
|
|
334
|
+
// loop between iterations — nothing here uses spawnSync or Atomics.wait, so
|
|
335
|
+
// concurrent window creates never stall socket heartbeats. See CLAUDE.md's
|
|
336
|
+
// event-loop invariant.
|
|
337
|
+
export async function waitForPanesReady({ expectedPanes, socket, target, timeoutMs = 3000 }) {
|
|
338
|
+
const startedAt = Date.now();
|
|
339
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
340
|
+
const panes = await inspectPanesAsync(target, socket);
|
|
341
|
+
const ready = panes.length === expectedPanes
|
|
342
|
+
&& panes.every((pane) => pane.dead === false && pane.id);
|
|
343
|
+
if (ready) {
|
|
344
|
+
return panes;
|
|
345
|
+
}
|
|
346
|
+
await sleepAsync(50);
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`Timed out waiting for ${expectedPanes} tmux panes to become ready in ${target}.`);
|
|
349
|
+
}
|
|
350
|
+
const inspectPanesSeparator = "|";
|
|
351
|
+
const inspectPanesFormat = [
|
|
352
|
+
"#{pane_index}",
|
|
353
|
+
"#{pane_id}",
|
|
354
|
+
"#{pane_dead}",
|
|
355
|
+
"#{pane_current_command}"
|
|
356
|
+
].join(inspectPanesSeparator);
|
|
357
|
+
function parseInspectPanesOutput(output) {
|
|
358
|
+
const trimmed = output.trim();
|
|
359
|
+
if (!trimmed) {
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
return trimmed.split("\n").map((line) => {
|
|
363
|
+
const [pane, id, dead, currentCommand] = line.split(inspectPanesSeparator);
|
|
364
|
+
if (pane === undefined || id === undefined || dead === undefined) {
|
|
365
|
+
throw new Error(`Could not parse tmux pane line: ${line}`);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
currentCommand: currentCommand ?? "",
|
|
369
|
+
dead: dead === "1",
|
|
370
|
+
id,
|
|
371
|
+
pane: Number(pane)
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
export function inspectPanes(target, socket) {
|
|
376
|
+
return parseInspectPanesOutput(run("tmux", tmuxArgs(socket, ["list-panes", "-t", target, "-F", inspectPanesFormat])));
|
|
377
|
+
}
|
|
378
|
+
// Async twin used by waitForPanesReady (the createWindow/createSession path) so
|
|
379
|
+
// the readiness poll never blocks the provider event loop with a synchronous
|
|
380
|
+
// spawn — see CLAUDE.md's event-loop invariant.
|
|
381
|
+
export async function inspectPanesAsync(target, socket) {
|
|
382
|
+
return parseInspectPanesOutput(await runAsync("tmux", tmuxArgs(socket, ["list-panes", "-t", target, "-F", inspectPanesFormat])));
|
|
383
|
+
}
|
|
384
|
+
const listPanesSeparator = "|";
|
|
385
|
+
const listPanesFormat = [
|
|
386
|
+
"#{session_name}",
|
|
387
|
+
"#{window_index}",
|
|
388
|
+
"#{pane_index}",
|
|
389
|
+
"#{pane_id}",
|
|
390
|
+
"#{pane_pid}",
|
|
391
|
+
"#{pane_current_path}",
|
|
392
|
+
"#{pane_active}",
|
|
393
|
+
"#{pane_title}"
|
|
394
|
+
].join(listPanesSeparator);
|
|
395
|
+
function parseListPanesOutput(output) {
|
|
396
|
+
const trimmed = output.trim();
|
|
397
|
+
if (!trimmed) {
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
return trimmed.split("\n").map((line) => {
|
|
401
|
+
const [session, window, pane, id, rootPid, cwd, active, title] = line.split(listPanesSeparator);
|
|
402
|
+
if (session === undefined || window === undefined || pane === undefined || id === undefined || rootPid === undefined || cwd === undefined || active === undefined) {
|
|
403
|
+
throw new Error(`Could not parse tmux pane line: ${line}`);
|
|
404
|
+
}
|
|
405
|
+
const parsedRootPid = Number(rootPid);
|
|
406
|
+
if (!Number.isInteger(parsedRootPid) || parsedRootPid < 1) {
|
|
407
|
+
throw new Error(`Could not parse tmux pane pid: ${line}`);
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
active: active === "1",
|
|
411
|
+
cwd,
|
|
412
|
+
id,
|
|
413
|
+
pane: Number(pane),
|
|
414
|
+
rootPid: parsedRootPid,
|
|
415
|
+
session,
|
|
416
|
+
title: title ?? "",
|
|
417
|
+
window: Number(window)
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
export function listPanes(socket) {
|
|
422
|
+
assertTmux();
|
|
423
|
+
return parseListPanesOutput(run("tmux", tmuxArgs(socket, ["list-panes", "-a", "-F", listPanesFormat])));
|
|
424
|
+
}
|
|
425
|
+
// Async twin used by the observe loop so the (frequent) pane enumeration never
|
|
426
|
+
// blocks the provider event loop.
|
|
427
|
+
export async function listPanesAsync(socket) {
|
|
428
|
+
assertTmux();
|
|
429
|
+
return parseListPanesOutput(await runAsync("tmux", tmuxArgs(socket, ["list-panes", "-a", "-F", listPanesFormat])));
|
|
430
|
+
}
|
|
431
|
+
const clientForTtySeparator = "|";
|
|
432
|
+
const clientForTtyFormat = [
|
|
433
|
+
"#{client_tty}",
|
|
434
|
+
"#{session_name}",
|
|
435
|
+
"#{window_index}",
|
|
436
|
+
"#{pane_index}",
|
|
437
|
+
"#{client_width}",
|
|
438
|
+
"#{client_height}"
|
|
439
|
+
].join(clientForTtySeparator);
|
|
440
|
+
function parseClientForTty(output, tty) {
|
|
441
|
+
if (!output) {
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
for (const line of output.split("\n")) {
|
|
445
|
+
const [clientTty, session, window, pane, widthText, heightText] = line.split(clientForTtySeparator);
|
|
446
|
+
if (clientTty === undefined || session === undefined || window === undefined || pane === undefined || widthText === undefined || heightText === undefined) {
|
|
447
|
+
throw new Error(`Could not parse tmux client line: ${line}`);
|
|
448
|
+
}
|
|
449
|
+
if (clientTty === tty) {
|
|
450
|
+
const width = Number(widthText);
|
|
451
|
+
const height = Number(heightText);
|
|
452
|
+
if (!Number.isInteger(width) || width < 1 || !Number.isInteger(height) || height < 1) {
|
|
453
|
+
throw new Error(`Could not parse tmux client size: ${line}`);
|
|
454
|
+
}
|
|
455
|
+
return { height, target: `${session}:${window}.${pane}`, width };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
export function clientForTty(tty, socket) {
|
|
461
|
+
assertTmux();
|
|
462
|
+
if (!tty) {
|
|
463
|
+
throw new Error("A client tty is required.");
|
|
464
|
+
}
|
|
465
|
+
return parseClientForTty(run("tmux", tmuxArgs(socket, ["list-clients", "-F", clientForTtyFormat])).trim(), tty);
|
|
466
|
+
}
|
|
467
|
+
// Async twin of clientForTty for the observe loop (the iTerm unmanaged path) — a
|
|
468
|
+
// per-window client lookup off the event loop instead of a synchronous fan-out.
|
|
469
|
+
export async function clientForTtyAsync(tty, socket) {
|
|
470
|
+
assertTmux();
|
|
471
|
+
if (!tty) {
|
|
472
|
+
throw new Error("A client tty is required.");
|
|
473
|
+
}
|
|
474
|
+
return parseClientForTty((await runAsync("tmux", tmuxArgs(socket, ["list-clients", "-F", clientForTtyFormat]))).trim(), tty);
|
|
475
|
+
}
|
|
476
|
+
// The reverse lookup: the ttys of every client attached to a session (0 or 1
|
|
477
|
+
// for a managed iTerm window). Lets the iTerm close path resolve a window by
|
|
478
|
+
// its attached tty instead of a recorded window id that may have gone stale.
|
|
479
|
+
// Returns [] when the session does not exist or nothing is attached — both are
|
|
480
|
+
// ordinary states on the close path, not errors.
|
|
481
|
+
export function clientTtysForSession(session, socket) {
|
|
482
|
+
assertTmux();
|
|
483
|
+
if (!session) {
|
|
484
|
+
throw new Error("A session name is required.");
|
|
485
|
+
}
|
|
486
|
+
let output;
|
|
487
|
+
try {
|
|
488
|
+
output = run("tmux", tmuxArgs(socket, ["list-clients", "-t", session, "-F", "#{client_tty}"])).trim();
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
return output.split("\n").filter((tty) => tty.length > 0);
|
|
494
|
+
}
|
|
495
|
+
export function getWindowSize({ name, socket, window = 0 }) {
|
|
496
|
+
assertSession(name, socket);
|
|
497
|
+
const separator = "|";
|
|
498
|
+
const output = run("tmux", tmuxArgs(socket, [
|
|
499
|
+
"display-message",
|
|
500
|
+
"-p",
|
|
501
|
+
"-t",
|
|
502
|
+
`${name}:${window}`,
|
|
503
|
+
`#{window_width}${separator}#{window_height}`
|
|
504
|
+
])).trim();
|
|
505
|
+
const [width, height] = output.split(separator).map(Number);
|
|
506
|
+
if (width === undefined || height === undefined || !Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
|
507
|
+
throw new Error(`Could not determine tmux window size for ${name}:${window}.`);
|
|
508
|
+
}
|
|
509
|
+
return { height, width };
|
|
510
|
+
}
|
|
511
|
+
// Every window-0 size in ONE async exec, keyed by session. The observe loop uses
|
|
512
|
+
// this to size windows off the event loop instead of a synchronous getWindowSize
|
|
513
|
+
// per window — that per-window fan-out blocked the provider loop under a large fleet
|
|
514
|
+
// (see CLAUDE.md: nothing sync on the observe path). Sessions absent here (transient /
|
|
515
|
+
// not-yet-created) just fall to the caller's default and self-correct next observe.
|
|
516
|
+
export async function listWindowSizesAsync(socket) {
|
|
517
|
+
assertTmux();
|
|
518
|
+
const separator = "|";
|
|
519
|
+
let output;
|
|
520
|
+
try {
|
|
521
|
+
output = (await runAsync("tmux", tmuxArgs(socket, [
|
|
522
|
+
"list-windows",
|
|
523
|
+
"-a",
|
|
524
|
+
"-F",
|
|
525
|
+
`#{session_name}${separator}#{window_index}${separator}#{window_width}${separator}#{window_height}`
|
|
526
|
+
]))).trim();
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
// No tmux server = no sessions = no sizes. A tmux server exits with its
|
|
530
|
+
// last session, so an idle provider (--count 0, nothing launched yet, or
|
|
531
|
+
// everything reaped) has no server to ask — that is the empty state, not
|
|
532
|
+
// an observe failure. Without this, every observe on an idle provider
|
|
533
|
+
// throws, the snapshot reports observation:failed forever, and the console
|
|
534
|
+
// paints a perfectly healthy machine as degraded. Genuine tmux breakage
|
|
535
|
+
// (binary missing, socket permission) still throws.
|
|
536
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
537
|
+
if (message.includes("no server running") || message.includes("error connecting to")) {
|
|
538
|
+
return new Map();
|
|
539
|
+
}
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
const sizes = new Map();
|
|
543
|
+
if (!output) {
|
|
544
|
+
return sizes;
|
|
545
|
+
}
|
|
546
|
+
for (const line of output.split("\n")) {
|
|
547
|
+
const [session, windowIndex, width, height] = line.split(separator);
|
|
548
|
+
// Match getWindowSize, which reads window 0; ignore extra windows of a session.
|
|
549
|
+
if (!session || windowIndex !== "0") {
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const parsedWidth = Number(width);
|
|
553
|
+
const parsedHeight = Number(height);
|
|
554
|
+
if (Number.isInteger(parsedWidth) && Number.isInteger(parsedHeight) && parsedWidth >= 1 && parsedHeight >= 1) {
|
|
555
|
+
sizes.set(session, { height: parsedHeight, width: parsedWidth });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return sizes;
|
|
559
|
+
}
|
|
560
|
+
// Out-of-band pane styling via window-active-style. tmux applies this when it
|
|
561
|
+
// draws, so every native client of the pane (a direct attach, the iTerm2 /
|
|
562
|
+
// WezTerm windows) renders it without writing into the cell grid — scrollback
|
|
563
|
+
// and the mirror's capture-pane output are untouched. Best-effort: a vanished
|
|
564
|
+
// session must not throw into the caller's effect timer.
|
|
565
|
+
export async function setPaneStyle(target, style, socket) {
|
|
566
|
+
assertTmux();
|
|
567
|
+
await runAsync("tmux", tmuxArgs(socket, ["set-option", "-p", "-t", target, "window-active-style", style]));
|
|
568
|
+
}
|
|
569
|
+
export async function clearPaneStyle(target, socket) {
|
|
570
|
+
assertTmux();
|
|
571
|
+
await runAsync("tmux", tmuxArgs(socket, ["set-option", "-p", "-u", "-t", target, "window-active-style"]));
|
|
572
|
+
}
|
|
573
|
+
export function capturePane({ lines, preserveEscapes = false, socket, target }) {
|
|
574
|
+
assertTmux();
|
|
575
|
+
if (!target) {
|
|
576
|
+
throw new Error("--target is required.");
|
|
577
|
+
}
|
|
578
|
+
return run("tmux", tmuxArgs(socket, capturePaneArgs({ lines, preserveEscapes, target })));
|
|
579
|
+
}
|
|
580
|
+
function capturePaneArgs({ lines, preserveEscapes, target }) {
|
|
581
|
+
const args = ["capture-pane", "-p", "-t", target];
|
|
582
|
+
if (preserveEscapes) {
|
|
583
|
+
args.splice(1, 0, "-e");
|
|
584
|
+
}
|
|
585
|
+
if (lines !== undefined) {
|
|
586
|
+
if (!Number.isInteger(lines) || lines < 1) {
|
|
587
|
+
throw new Error("--lines must be a positive integer.");
|
|
588
|
+
}
|
|
589
|
+
args.push("-S", `-${lines}`);
|
|
590
|
+
}
|
|
591
|
+
return args;
|
|
592
|
+
}
|
|
593
|
+
// Async twin used by the observe loop. The N per-pane captures can then run
|
|
594
|
+
// concurrently (Promise.all) instead of blocking the event loop in sequence.
|
|
595
|
+
export async function capturePaneAsync({ lines, preserveEscapes = false, socket, target }) {
|
|
596
|
+
assertTmux();
|
|
597
|
+
if (!target) {
|
|
598
|
+
throw new Error("--target is required.");
|
|
599
|
+
}
|
|
600
|
+
return runAsync("tmux", tmuxArgs(socket, capturePaneArgs({ lines, preserveEscapes, target })));
|
|
601
|
+
}
|
|
602
|
+
export async function sendInterruptAsync({ socket, target }) {
|
|
603
|
+
assertTmux();
|
|
604
|
+
if (!target) {
|
|
605
|
+
throw new Error("A pane target is required.");
|
|
606
|
+
}
|
|
607
|
+
await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Escape"]));
|
|
608
|
+
}
|
|
609
|
+
export async function sendInputAsync({ data, deadlineMs, socket, submitMode = "retry", target }) {
|
|
610
|
+
assertTmux();
|
|
611
|
+
if (!target) {
|
|
612
|
+
throw new Error("A pane target is required.");
|
|
613
|
+
}
|
|
614
|
+
if (typeof data !== "string") {
|
|
615
|
+
throw new Error("Input data must be a string.");
|
|
616
|
+
}
|
|
617
|
+
const submittedText = submittedTextFromInput(data);
|
|
618
|
+
if (submittedText !== undefined) {
|
|
619
|
+
// Newlines and tabs typed as keystrokes are landmines: each newline
|
|
620
|
+
// submits a partial line and a tab triggers shell completion. Deliver
|
|
621
|
+
// such text as a buffer paste so it arrives as literal content.
|
|
622
|
+
if (submittedText.includes("\n") || submittedText.includes("\t")) {
|
|
623
|
+
await sendSubmittedPasteAsync({ deadlineMs, socket, submitMode, target, text: submittedText });
|
|
624
|
+
return "submitted-paste";
|
|
625
|
+
}
|
|
626
|
+
await sendSubmittedLineAsync({ deadlineMs, socket, submitMode, target, text: submittedText });
|
|
627
|
+
return "submitted-line";
|
|
628
|
+
}
|
|
629
|
+
for (const token of tokenizeTerminalInput(data)) {
|
|
630
|
+
if ("literal" in token) {
|
|
631
|
+
await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "-l", "--", token.literal]), terminalInputRunOptions(deadlineMs));
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, token.key]), terminalInputRunOptions(deadlineMs));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return "keystream";
|
|
638
|
+
}
|
|
639
|
+
export function attachSession({ name, socket, window }) {
|
|
640
|
+
assertTmux();
|
|
641
|
+
if (!name) {
|
|
642
|
+
throw new Error("--name is required.");
|
|
643
|
+
}
|
|
644
|
+
const target = window === undefined ? name : `${name}:${window}`;
|
|
645
|
+
spawnInherited("tmux", tmuxArgs(socket, ["attach-session", "-t", target]));
|
|
646
|
+
}
|
|
647
|
+
const PASTE_START = "\u001b[200~";
|
|
648
|
+
const PASTE_END = "\u001b[201~";
|
|
649
|
+
const SUBMITTED_LINE_DELAYS_MS = [500, 1500, 3500];
|
|
650
|
+
async function sleepAsync(ms) {
|
|
651
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
652
|
+
}
|
|
653
|
+
// Submitted text is a payload the caller finished with Enter (a launch
|
|
654
|
+
// command or a chat message), as opposed to a raw keystroke stream. It must
|
|
655
|
+
// contain only pasteable characters: anything else (ESC sequences, control
|
|
656
|
+
// characters, DEL, a bare CR mid-body) is key forwarding and has to go
|
|
657
|
+
// through the tokenizer.
|
|
658
|
+
export function submittedTextFromInput(data) {
|
|
659
|
+
if (!data.endsWith("\n") && !data.endsWith("\r")) {
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
const text = data.replace(/(?:\r\n|\n|\r)$/, "");
|
|
663
|
+
for (const char of text) {
|
|
664
|
+
if (char === "\n" || char === "\t") {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (char === "\u007f" || char < " ") {
|
|
668
|
+
return undefined;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return text;
|
|
672
|
+
}
|
|
673
|
+
let pasteBufferCounter = 0;
|
|
674
|
+
async function sendSubmittedPasteAsync({ deadlineMs, socket, submitMode, target, text }) {
|
|
675
|
+
pasteBufferCounter += 1;
|
|
676
|
+
const buffer = `terminal-input-${process.pid}-${pasteBufferCounter}`;
|
|
677
|
+
await runWithInputAsync("tmux", tmuxArgs(socket, ["load-buffer", "-b", buffer, "-"]), text, terminalInputRunOptions(deadlineMs));
|
|
678
|
+
// -p wraps the paste in bracketed-paste markers when the application has
|
|
679
|
+
// requested them, so line editors insert the content literally instead of
|
|
680
|
+
// interpreting newlines and tabs as keystrokes.
|
|
681
|
+
if (submitMode === "single") {
|
|
682
|
+
await runAsync("tmux", tmuxArgs(socket, ["paste-buffer", "-d", "-p", "-b", buffer, "-t", target, ";", "send-keys", "-t", target, "C-m"]), terminalInputRunOptions(deadlineMs));
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
await runAsync("tmux", tmuxArgs(socket, ["paste-buffer", "-d", "-p", "-b", buffer, "-t", target]), terminalInputRunOptions(deadlineMs));
|
|
686
|
+
for (const delayMs of SUBMITTED_LINE_DELAYS_MS) {
|
|
687
|
+
await sleepAsync(delayMs);
|
|
688
|
+
await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async function sendSubmittedLineAsync({ deadlineMs, socket, submitMode, target, text }) {
|
|
692
|
+
if (submitMode === "single") {
|
|
693
|
+
await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "-l", "--", text, ";", "send-keys", "-t", target, "C-m"]), terminalInputRunOptions(deadlineMs));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "-l", "--", `${PASTE_START}${text}${PASTE_END}`]), terminalInputRunOptions(deadlineMs));
|
|
697
|
+
for (const delayMs of SUBMITTED_LINE_DELAYS_MS) {
|
|
698
|
+
await sleepAsync(delayMs);
|
|
699
|
+
await runAsync("tmux", tmuxArgs(socket, ["send-keys", "-t", target, "Enter"]), terminalInputRunOptions(deadlineMs));
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function terminalInputRunOptions(deadlineMs) {
|
|
703
|
+
if (deadlineMs === undefined)
|
|
704
|
+
return {};
|
|
705
|
+
const timeoutMs = deadlineMs - Date.now();
|
|
706
|
+
if (timeoutMs <= 0)
|
|
707
|
+
throw new Error("Terminal input expired before delivery.");
|
|
708
|
+
return { timeoutMs };
|
|
709
|
+
}
|
|
710
|
+
function tokenizeTerminalInput(data) {
|
|
711
|
+
const tokens = [];
|
|
712
|
+
for (let i = 0; i < data.length; i += 1) {
|
|
713
|
+
const char = data[i];
|
|
714
|
+
const next3 = data.slice(i, i + 3);
|
|
715
|
+
if (next3 === "\u001b[A") {
|
|
716
|
+
tokens.push({ key: "Up" });
|
|
717
|
+
i += 2;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (next3 === "\u001b[B") {
|
|
721
|
+
tokens.push({ key: "Down" });
|
|
722
|
+
i += 2;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
if (next3 === "\u001b[C") {
|
|
726
|
+
tokens.push({ key: "Right" });
|
|
727
|
+
i += 2;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (next3 === "\u001b[D") {
|
|
731
|
+
tokens.push({ key: "Left" });
|
|
732
|
+
i += 2;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (char === "\r" || char === "\n") {
|
|
736
|
+
tokens.push({ key: "Enter" });
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (char === "\u007f") {
|
|
740
|
+
tokens.push({ key: "BSpace" });
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (char === "\t") {
|
|
744
|
+
tokens.push({ key: "Tab" });
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (char === "\u0003") {
|
|
748
|
+
tokens.push({ key: "C-c" });
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (char === "\u0015") {
|
|
752
|
+
tokens.push({ key: "C-u" });
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (char === undefined) {
|
|
756
|
+
throw new Error("Unexpected end of input.");
|
|
757
|
+
}
|
|
758
|
+
const controlKey = controlKeyName(char);
|
|
759
|
+
if (controlKey) {
|
|
760
|
+
tokens.push({ key: controlKey });
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (char < " ") {
|
|
764
|
+
throw new Error(`Unsupported control input: ${JSON.stringify(char)}.`);
|
|
765
|
+
}
|
|
766
|
+
const previous = tokens.at(-1);
|
|
767
|
+
if (previous && "literal" in previous) {
|
|
768
|
+
previous.literal += char;
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
tokens.push({ literal: char });
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return tokens;
|
|
775
|
+
}
|
|
776
|
+
function controlKeyName(char) {
|
|
777
|
+
const code = char.charCodeAt(0);
|
|
778
|
+
if (code < 1 || code > 26) {
|
|
779
|
+
return undefined;
|
|
780
|
+
}
|
|
781
|
+
return `C-${String.fromCharCode(96 + code)}`;
|
|
782
|
+
}
|