tmux-ide 2.7.0 → 2.8.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 +22 -5
- package/bin/cli.js +3532 -1090
- package/bin/cli.ts +368 -71
- package/package.json +2 -1
- package/packages/contracts/src/__tests__/control.test.ts +154 -0
- package/packages/contracts/src/control.ts +217 -0
- package/packages/contracts/src/index.ts +1 -0
- package/packages/daemon/dist/control/client.d.ts +23 -0
- package/packages/daemon/dist/control/client.js +105 -0
- package/packages/daemon/dist/control/dispatch.d.ts +34 -0
- package/packages/daemon/dist/control/dispatch.js +83 -0
- package/packages/daemon/dist/control/fanout.d.ts +19 -0
- package/packages/daemon/dist/control/fanout.js +37 -0
- package/packages/daemon/dist/control/frames.d.ts +23 -0
- package/packages/daemon/dist/control/frames.js +37 -0
- package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
- package/packages/daemon/dist/control/lifecycle.js +114 -0
- package/packages/daemon/dist/control/server.d.ts +16 -0
- package/packages/daemon/dist/control/server.js +214 -0
- package/packages/daemon/dist/control/verbs.d.ts +11 -0
- package/packages/daemon/dist/control/verbs.js +91 -0
- package/packages/daemon/dist/doctor.d.ts +18 -0
- package/packages/daemon/dist/doctor.js +105 -15
- package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
- package/packages/daemon/dist/lib/agent-discovery.js +29 -14
- package/packages/daemon/dist/lib/app-config.d.ts +106 -0
- package/packages/daemon/dist/lib/app-config.js +104 -5
- package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
- package/packages/daemon/dist/lib/manifest-pack.js +232 -0
- package/packages/daemon/dist/lib/state-home.d.ts +2 -0
- package/packages/daemon/dist/lib/state-home.js +12 -0
- package/packages/daemon/dist/lib/update-check.js +5 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
- package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
- package/packages/daemon/dist/restore.d.ts +35 -8
- package/packages/daemon/dist/restore.js +52 -15
- package/packages/daemon/dist/send.d.ts +33 -1
- package/packages/daemon/dist/send.js +32 -19
- package/packages/daemon/src/control/client.ts +128 -0
- package/packages/daemon/src/control/dispatch.ts +107 -0
- package/packages/daemon/src/control/fanout.ts +44 -0
- package/packages/daemon/src/control/frames.ts +40 -0
- package/packages/daemon/src/control/lifecycle.ts +151 -0
- package/packages/daemon/src/control/server.ts +237 -0
- package/packages/daemon/src/control/verbs.ts +118 -0
- package/packages/daemon/src/doctor.ts +113 -28
- package/packages/daemon/src/lib/agent-discovery.ts +53 -13
- package/packages/daemon/src/lib/app-config.ts +103 -5
- package/packages/daemon/src/lib/manifest-pack.ts +255 -0
- package/packages/daemon/src/lib/state-home.ts +13 -0
- package/packages/daemon/src/lib/update-check.ts +5 -0
- package/packages/daemon/src/restore.ts +53 -15
- package/packages/daemon/src/send.ts +55 -21
- package/packages/daemon/src/tui/chrome/events.ts +4 -4
- package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
- package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
- package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
- package/packages/daemon/src/tui/chrome/notify.ts +582 -84
- package/packages/daemon/src/tui/chrome/updater.ts +268 -62
- package/packages/daemon/src/tui/detect/classify.ts +34 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +54 -5
- package/packages/daemon/src/tui/detect/manifest.ts +24 -3
- package/packages/daemon/src/tui/detect/manifests.ts +240 -6
- package/packages/daemon/src/tui/detect/process-tree.ts +13 -3
- package/packages/daemon/src/tui/detect/session-id.ts +503 -0
- package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +40 -11
- package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +27 -5
- package/packages/daemon/src/tui/mirror/app-state.ts +171 -8
- package/packages/daemon/src/tui/mirror/app.tsx +2182 -399
- package/packages/daemon/src/tui/mirror/attention.ts +110 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +17 -4
- package/packages/daemon/src/tui/mirror/diff-model.ts +279 -4
- package/packages/daemon/src/tui/mirror/file-tree.ts +231 -6
- package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
- package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
- package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +27 -4
- package/packages/daemon/src/tui/mirror/palette.ts +299 -9
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +82 -4
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +18 -11
- package/packages/daemon/src/tui/mirror/perf-tap.ts +29 -3
- package/packages/daemon/src/tui/mirror/selection.ts +122 -8
- package/packages/daemon/src/tui/mirror/session-mirror.ts +349 -68
- package/packages/daemon/src/tui/mirror/settings-model.ts +96 -16
- package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +53 -0
- package/packages/daemon/src/tui/mirror/theme.ts +45 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
- package/packages/daemon/src/tui/team/sessions.ts +85 -7
- package/packages/daemon/src/tui/team/wait.ts +144 -0
- package/scripts/build-macos-notifier.mjs +160 -0
- package/scripts/postinstall.js +8 -1
- package/scripts/prepublish-check.mjs +37 -1
- package/scripts/publish-tap.sh +55 -0
- package/skill/SKILL.md +88 -2
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure model for the Files tab's
|
|
3
|
-
* {@link ./diff-model.ts}, the io (async `fs.readdir
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Pure model for the Files tab's expandable file list (M18.4, uplifted M24.6).
|
|
3
|
+
* Like {@link ./diff-model.ts}, the io (async `fs.readdir`, git subprocesses,
|
|
4
|
+
* the `ignore` matcher fed from .gitignore) stays in app.tsx; the ORDERING, the
|
|
5
|
+
* flat-tree splice/prune math, the ignore/hidden FILTERING, the git-status
|
|
6
|
+
* DECORATION merge, the changed-file WALK for `[`/`]`, the `/` FILTER view and
|
|
7
|
+
* the expansion-preserving REBUILD all live here so they unit-test as tables.
|
|
6
8
|
*
|
|
7
9
|
* The list is a FLAT array of {@link FileNode}s carrying a `depth`; a directory
|
|
8
10
|
* "expands" by splicing its freshly-read children in right after it and
|
|
@@ -11,21 +13,69 @@
|
|
|
11
13
|
* a file manager does.
|
|
12
14
|
*/
|
|
13
15
|
|
|
16
|
+
/** Names that are NEVER listed, whatever the toggles say (the explorer
|
|
17
|
+
* widget's battle-tested list — build artifacts and VCS internals that would
|
|
18
|
+
* only bury the code). */
|
|
19
|
+
export const ALWAYS_IGNORE: ReadonlySet<string> = new Set([
|
|
20
|
+
"node_modules",
|
|
21
|
+
".git",
|
|
22
|
+
".svn",
|
|
23
|
+
".hg",
|
|
24
|
+
"dist",
|
|
25
|
+
"build",
|
|
26
|
+
"out",
|
|
27
|
+
".next",
|
|
28
|
+
".turbo",
|
|
29
|
+
".cache",
|
|
30
|
+
"__pycache__",
|
|
31
|
+
"coverage",
|
|
32
|
+
".nyc_output",
|
|
33
|
+
"target",
|
|
34
|
+
"vendor",
|
|
35
|
+
"bower_components",
|
|
36
|
+
]);
|
|
37
|
+
|
|
14
38
|
/** One row in the flat file list. `path` is absolute; `depth` is the indent
|
|
15
|
-
* level (0 = the context dir's immediate children).
|
|
39
|
+
* level (0 = the context dir's immediate children). `ignored` marks a
|
|
40
|
+
* gitignored entry — visible only when the I toggle shows them, dimmed. */
|
|
16
41
|
export interface FileNode {
|
|
17
42
|
name: string;
|
|
18
43
|
path: string;
|
|
19
44
|
isDir: boolean;
|
|
20
45
|
depth: number;
|
|
21
46
|
expanded: boolean;
|
|
47
|
+
ignored: boolean;
|
|
22
48
|
}
|
|
23
49
|
|
|
24
50
|
/** A raw `fs.readdir(..., { withFileTypes: true })` entry, reduced to what we
|
|
25
|
-
* need (kept minimal so callers can map Dirent → this trivially).
|
|
51
|
+
* need (kept minimal so callers can map Dirent → this trivially). `ignored`
|
|
52
|
+
* is stamped by the caller's gitignore matcher (io stays outside). */
|
|
26
53
|
export interface RawEntry {
|
|
27
54
|
name: string;
|
|
28
55
|
isDir: boolean;
|
|
56
|
+
ignored?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The two Files-surface visibility toggles (both default OFF = filtered). */
|
|
60
|
+
export interface ListFilter {
|
|
61
|
+
/** Show dotfiles (H). */
|
|
62
|
+
showHidden: boolean;
|
|
63
|
+
/** Show gitignored entries (I) — they render dimmed. */
|
|
64
|
+
showIgnored: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* PURE — drop the entries the current toggles hide: {@link ALWAYS_IGNORE}
|
|
69
|
+
* names always, dotfiles unless `showHidden`, gitignored entries (as stamped
|
|
70
|
+
* by the caller) unless `showIgnored`.
|
|
71
|
+
*/
|
|
72
|
+
export function filterEntries(entries: readonly RawEntry[], filter: ListFilter): RawEntry[] {
|
|
73
|
+
return entries.filter((e) => {
|
|
74
|
+
if (ALWAYS_IGNORE.has(e.name)) return false;
|
|
75
|
+
if (!filter.showHidden && e.name.startsWith(".")) return false;
|
|
76
|
+
if (!filter.showIgnored && e.ignored) return false;
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
29
79
|
}
|
|
30
80
|
|
|
31
81
|
/**
|
|
@@ -57,6 +107,7 @@ export function buildNodes(dir: string, entries: RawEntry[], depth: number): Fil
|
|
|
57
107
|
isDir: e.isDir,
|
|
58
108
|
depth,
|
|
59
109
|
expanded: false,
|
|
110
|
+
ignored: e.ignored ?? false,
|
|
60
111
|
}));
|
|
61
112
|
}
|
|
62
113
|
|
|
@@ -95,3 +146,177 @@ export function removeSubtreeAt(list: FileNode[], index: number): FileNode[] {
|
|
|
95
146
|
next[index] = { ...parent, expanded: false };
|
|
96
147
|
return next;
|
|
97
148
|
}
|
|
149
|
+
|
|
150
|
+
// ── M24.6 — decoration, changed walk, filter view, rebuild ──────────────────
|
|
151
|
+
|
|
152
|
+
/** PURE — `abs` relative to `root` ("" when equal or not under root). Both are
|
|
153
|
+
* plain string paths; a trailing slash on root is tolerated. */
|
|
154
|
+
export function relPath(root: string, abs: string): string {
|
|
155
|
+
const base = root.endsWith("/") ? root.slice(0, -1) : root;
|
|
156
|
+
if (abs === base) return "";
|
|
157
|
+
return abs.startsWith(base + "/") ? abs.slice(base.length + 1) : "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** PURE — every ancestor DIRECTORY of a repo-relative path, outermost first:
|
|
161
|
+
* `a/b/c.ts` → `["a", "a/b"]`. A top-level path has none. */
|
|
162
|
+
export function ancestorDirs(rel: string): string[] {
|
|
163
|
+
const out: string[] = [];
|
|
164
|
+
let idx = rel.indexOf("/");
|
|
165
|
+
while (idx !== -1) {
|
|
166
|
+
out.push(rel.slice(0, idx));
|
|
167
|
+
idx = rel.indexOf("/", idx + 1);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* PURE — the per-path git status map from parsed porcelain entries
|
|
174
|
+
* (repo-relative `path` + one-letter `status`), with the explorer widget's
|
|
175
|
+
* parent-dir propagation: every ancestor directory inherits the FIRST child
|
|
176
|
+
* status seen, so a collapsed dir still shows that something changed inside.
|
|
177
|
+
*/
|
|
178
|
+
export function statusMapFromEntries(
|
|
179
|
+
entries: readonly { path: string; status: string }[],
|
|
180
|
+
): Map<string, string> {
|
|
181
|
+
const map = new Map<string, string>();
|
|
182
|
+
for (const file of entries) {
|
|
183
|
+
map.set(file.path, file.status);
|
|
184
|
+
let parent = file.path;
|
|
185
|
+
while (parent.includes("/")) {
|
|
186
|
+
parent = parent.slice(0, parent.lastIndexOf("/"));
|
|
187
|
+
if (!map.has(parent)) map.set(parent, file.status);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return map;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** PURE — compare two repo-relative paths in TREE DISPLAY order: walk the
|
|
194
|
+
* segments; where they diverge, a directory component (more segments follow)
|
|
195
|
+
* sorts before a terminal file segment — matching {@link sortEntries}'
|
|
196
|
+
* dirs-first, case-insensitive ordering. */
|
|
197
|
+
export function treePathCompare(a: string, b: string): number {
|
|
198
|
+
const as = a.split("/");
|
|
199
|
+
const bs = b.split("/");
|
|
200
|
+
const n = Math.min(as.length, bs.length);
|
|
201
|
+
for (let i = 0; i < n; i++) {
|
|
202
|
+
const aDir = i < as.length - 1;
|
|
203
|
+
const bDir = i < bs.length - 1;
|
|
204
|
+
const av = as[i]!;
|
|
205
|
+
const bv = bs[i]!;
|
|
206
|
+
if (av === bv && aDir && bDir) continue;
|
|
207
|
+
// At the divergence point a directory component sorts before a file
|
|
208
|
+
// segment, regardless of name (dirs-first display order).
|
|
209
|
+
if (aDir !== bDir) return aDir ? -1 : 1;
|
|
210
|
+
const al = av.toLowerCase();
|
|
211
|
+
const bl = bv.toLowerCase();
|
|
212
|
+
if (al !== bl) return al < bl ? -1 : 1;
|
|
213
|
+
if (av !== bv) return av < bv ? -1 : 1;
|
|
214
|
+
}
|
|
215
|
+
return as.length - bs.length;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* PURE — the ordered changed-FILE walk for `[`/`]`: the porcelain entries'
|
|
220
|
+
* paths, deduped, minus anything the surface can never show (a DELETED file
|
|
221
|
+
* has no row at all, an {@link ALWAYS_IGNORE} segment or a dot segment while
|
|
222
|
+
* hidden files are off is filtered out — hopping to an unrevealable row would
|
|
223
|
+
* strand the selection and wedge the chain), sorted in tree display order.
|
|
224
|
+
*/
|
|
225
|
+
export function changedFileWalk(
|
|
226
|
+
entries: readonly { path: string; status?: string }[],
|
|
227
|
+
opts: { showHidden: boolean },
|
|
228
|
+
): string[] {
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
for (const e of entries) {
|
|
231
|
+
if (!e.path) continue;
|
|
232
|
+
if (e.status === "D") continue;
|
|
233
|
+
const segs = e.path.split("/");
|
|
234
|
+
if (segs.some((s) => ALWAYS_IGNORE.has(s))) continue;
|
|
235
|
+
if (!opts.showHidden && segs.some((s) => s.startsWith("."))) continue;
|
|
236
|
+
seen.add(e.path);
|
|
237
|
+
}
|
|
238
|
+
return [...seen].sort(treePathCompare);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* PURE — the next/previous changed path from `current` (repo-relative, or null
|
|
243
|
+
* when the selection is nowhere useful), wrapping around. `current` need not
|
|
244
|
+
* be IN the walk — the step lands on the nearest entry in walk order, so a hop
|
|
245
|
+
* from an unchanged file between two changed ones does the right thing.
|
|
246
|
+
*/
|
|
247
|
+
export function nextChangedPath(
|
|
248
|
+
walk: readonly string[],
|
|
249
|
+
current: string | null,
|
|
250
|
+
dir: 1 | -1,
|
|
251
|
+
): string | null {
|
|
252
|
+
if (walk.length === 0) return null;
|
|
253
|
+
if (current === null) return dir === 1 ? walk[0]! : walk[walk.length - 1]!;
|
|
254
|
+
if (dir === 1) {
|
|
255
|
+
for (const p of walk) if (treePathCompare(p, current) > 0) return p;
|
|
256
|
+
return walk[0]!;
|
|
257
|
+
}
|
|
258
|
+
for (let i = walk.length - 1; i >= 0; i--) {
|
|
259
|
+
if (treePathCompare(walk[i]!, current) < 0) return walk[i]!;
|
|
260
|
+
}
|
|
261
|
+
return walk[walk.length - 1]!;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** One visible row of the `/`-filtered tree: the node plus its index into the
|
|
265
|
+
* UNDERLYING flat list (so activation/expansion math still applies there). */
|
|
266
|
+
export interface FilteredRow {
|
|
267
|
+
node: FileNode;
|
|
268
|
+
index: number;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* PURE — the `/` filter view over the flat list: rows whose NAME contains the
|
|
273
|
+
* query case-insensitively, each carrying its underlying index. A null or
|
|
274
|
+
* empty query is "filter off" — every row, in order. The list itself is never
|
|
275
|
+
* touched, so expanded state trivially survives the filter.
|
|
276
|
+
*/
|
|
277
|
+
export function filterView(list: readonly FileNode[], query: string | null): FilteredRow[] {
|
|
278
|
+
if (!query) return list.map((node, index) => ({ node, index }));
|
|
279
|
+
const q = query.toLowerCase();
|
|
280
|
+
const out: FilteredRow[] = [];
|
|
281
|
+
for (let index = 0; index < list.length; index++) {
|
|
282
|
+
const node = list[index]!;
|
|
283
|
+
if (node.name.toLowerCase().includes(q)) out.push({ node, index });
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** PURE — the index of `path` in the flat list, or -1. */
|
|
289
|
+
export function indexOfPath(list: readonly FileNode[], path: string): number {
|
|
290
|
+
for (let i = 0; i < list.length; i++) if (list[i]!.path === path) return i;
|
|
291
|
+
return -1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* PURE — rebuild the whole flat tree from fresh directory listings while
|
|
296
|
+
* PRESERVING expansion: `listing` maps a directory's absolute path → its
|
|
297
|
+
* fresh (already filtered/annotated) entries, `expanded` is the set of dir
|
|
298
|
+
* paths that were expanded before the refresh. A dir stays expanded only when
|
|
299
|
+
* it survived the refresh AND its fresh listing was provided; a vanished or
|
|
300
|
+
* newly-hidden dir simply drops out. Keys must be the exact `FileNode.path`
|
|
301
|
+
* strings (and the root exactly as passed).
|
|
302
|
+
*/
|
|
303
|
+
export function rebuildTree(
|
|
304
|
+
rootDir: string,
|
|
305
|
+
listing: ReadonlyMap<string, readonly RawEntry[]>,
|
|
306
|
+
expanded: ReadonlySet<string>,
|
|
307
|
+
): FileNode[] {
|
|
308
|
+
const walk = (dir: string, depth: number): FileNode[] => {
|
|
309
|
+
const ents = listing.get(dir);
|
|
310
|
+
if (!ents) return [];
|
|
311
|
+
const out: FileNode[] = [];
|
|
312
|
+
for (const node of buildNodes(dir, [...ents], depth)) {
|
|
313
|
+
if (node.isDir && expanded.has(node.path) && listing.has(node.path)) {
|
|
314
|
+
out.push({ ...node, expanded: true }, ...walk(node.path, depth + 1));
|
|
315
|
+
} else {
|
|
316
|
+
out.push(node);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
};
|
|
321
|
+
return walk(rootDir, 0);
|
|
322
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-terminal modes owned by the unified app.
|
|
3
|
+
*
|
|
4
|
+
* OpenTUI renders each changed row as ANSI runs. With DECAWM (host autowrap)
|
|
5
|
+
* enabled, a wide/right-edge run can physically wrap into column 1 of the next
|
|
6
|
+
* row while OpenTUI's shadow records the intended cells. That strands pane text
|
|
7
|
+
* over the left sidebar until an outer full repaint. The app never relies on
|
|
8
|
+
* host autowrap—it positions runs absolutely—so disable it for the app's
|
|
9
|
+
* lifetime and restore it exactly once on every exit path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const HOST_AUTOWRAP_DISABLE = "\x1b[?7l";
|
|
13
|
+
export const HOST_AUTOWRAP_ENABLE = "\x1b[?7h";
|
|
14
|
+
|
|
15
|
+
export interface HostTerminalExitLifecycle {
|
|
16
|
+
onExit(listener: () => void): void;
|
|
17
|
+
offExit(listener: () => void): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HostAutowrapGuard {
|
|
21
|
+
restore(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Disable host autowrap immediately and arm an exit fallback. `restore` is
|
|
26
|
+
* idempotent and removes the fallback before writing the enable sequence, so a
|
|
27
|
+
* normal renderer teardown followed by process exit cannot double-write it.
|
|
28
|
+
*
|
|
29
|
+
* The caller supplies a synchronous writer: process `exit` listeners cannot
|
|
30
|
+
* rely on queued/asynchronous stdout writes being flushed.
|
|
31
|
+
*/
|
|
32
|
+
export function installHostAutowrapGuard(
|
|
33
|
+
write: (sequence: string) => void,
|
|
34
|
+
lifecycle: HostTerminalExitLifecycle,
|
|
35
|
+
): HostAutowrapGuard {
|
|
36
|
+
let restored = false;
|
|
37
|
+
|
|
38
|
+
const restore = () => {
|
|
39
|
+
if (restored) return;
|
|
40
|
+
restored = true;
|
|
41
|
+
lifecycle.offExit(restore);
|
|
42
|
+
write(HOST_AUTOWRAP_ENABLE);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
write(HOST_AUTOWRAP_DISABLE);
|
|
46
|
+
lifecycle.onExit(restore);
|
|
47
|
+
|
|
48
|
+
return { restore };
|
|
49
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The detachable cockpit (M23.2) — tmux keeps the app itself alive.
|
|
3
|
+
*
|
|
4
|
+
* `tmux-ide app --detachable` (alias `--hosted`) doesn't run the app in the
|
|
5
|
+
* invoking terminal: it ensures an internal `_tmux-ide-app` session exists
|
|
6
|
+
* running the app full-screen, then attaches the terminal to it. ^q under
|
|
7
|
+
* hosting DETACHES the client (the app keeps running); re-invocation from any
|
|
8
|
+
* terminal — including a phone over ssh — reattaches the SAME cockpit with
|
|
9
|
+
* scroll positions, dialogs, and workspace context intact.
|
|
10
|
+
*
|
|
11
|
+
* These are the PURE pieces of that contract: the entry decision, the tmux
|
|
12
|
+
* argv builders, and the shell quoting for the host pane's command line. The
|
|
13
|
+
* io (spawning tmux, resolving bun vs the compiled binary) stays in the CLI;
|
|
14
|
+
* the app side only reads {@link HOSTED_ENV} to flip ^q from quit to detach.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The internal host session. `_`-prefixed so every fleet surface (team --json,
|
|
18
|
+
* sidebar, home) and the snapshot/restore path already filter it out. */
|
|
19
|
+
export const APP_HOST_SESSION = "_tmux-ide-app";
|
|
20
|
+
|
|
21
|
+
/** The env marker the launcher sets on the hosted app process. The app flips
|
|
22
|
+
* ^q from "quit" to "detach the client" when it sees `=1`; the CLI treats it
|
|
23
|
+
* as a recursion guard (a hosted app never re-hosts). */
|
|
24
|
+
export const HOSTED_ENV = "TMUX_IDE_HOSTED";
|
|
25
|
+
|
|
26
|
+
/** Everything the entry decision reads — flags, config, and the guard. */
|
|
27
|
+
export interface HostedEntryInput {
|
|
28
|
+
/** `--detachable` (the primary flag). */
|
|
29
|
+
flagDetachable: boolean;
|
|
30
|
+
/** `--hosted` (the alias). */
|
|
31
|
+
flagHosted: boolean;
|
|
32
|
+
/** `app.detachable` from the typed config — makes bare `tmux-ide app` (and
|
|
33
|
+
* the frontDoor entry) hosted without the flag. */
|
|
34
|
+
configDetachable: boolean;
|
|
35
|
+
/** Whether WE are already the hosted app ({@link HOSTED_ENV} set). */
|
|
36
|
+
hostedEnv: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* PURE — should this `tmux-ide app` invocation run hosted? Flags and config
|
|
41
|
+
* both opt in; the env marker vetoes everything (the app inside the host
|
|
42
|
+
* session must launch plain, or it would try to attach to itself).
|
|
43
|
+
*/
|
|
44
|
+
export function wantsHostedApp(input: HostedEntryInput): boolean {
|
|
45
|
+
if (input.hostedEnv) return false;
|
|
46
|
+
return input.flagDetachable || input.flagHosted || input.configDetachable;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PURE — POSIX single-quote a word for a tmux `new-session` shell command
|
|
51
|
+
* (tmux hands the string to `sh -c`). Single quotes pass everything literally;
|
|
52
|
+
* an embedded `'` closes, escapes, and reopens.
|
|
53
|
+
*/
|
|
54
|
+
export function shellQuote(word: string): string {
|
|
55
|
+
return `'${word.replaceAll("'", `'\\''`)}'`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* PURE — the env vars the host pane's app process needs, assembled for the
|
|
60
|
+
* command line rather than tmux's session environment: the tmux server may
|
|
61
|
+
* have been started elsewhere with a different environment, so nothing can be
|
|
62
|
+
* assumed to inherit. PATH rides along for the same reason (the `bun` launch
|
|
63
|
+
* mode resolves the binary by name).
|
|
64
|
+
*/
|
|
65
|
+
export function hostedEnvVars(base: {
|
|
66
|
+
/** The user's real invocation dir (in-app prompts default here). */
|
|
67
|
+
cwd: string;
|
|
68
|
+
/** The node-runnable CLI path (`TMUX_IDE_CLI`) for in-app subprocesses. */
|
|
69
|
+
cli: string;
|
|
70
|
+
/** The invoking shell's PATH. */
|
|
71
|
+
path?: string;
|
|
72
|
+
/** `TMUX_IDE_HOME` / `TMUX_IDE_CONFIG` / `TMUX_IDE_TUI_BIN` pass-throughs
|
|
73
|
+
* (set in test rigs; must reach the hosted app or it reads real state). */
|
|
74
|
+
home?: string;
|
|
75
|
+
config?: string;
|
|
76
|
+
tuiBin?: string;
|
|
77
|
+
}): Record<string, string> {
|
|
78
|
+
const env: Record<string, string> = {
|
|
79
|
+
[HOSTED_ENV]: "1",
|
|
80
|
+
TMUX_IDE_CWD: base.cwd,
|
|
81
|
+
TMUX_IDE_CLI: base.cli,
|
|
82
|
+
};
|
|
83
|
+
if (base.path) env.PATH = base.path;
|
|
84
|
+
if (base.home) env.TMUX_IDE_HOME = base.home;
|
|
85
|
+
if (base.config) env.TMUX_IDE_CONFIG = base.config;
|
|
86
|
+
if (base.tuiBin) env.TMUX_IDE_TUI_BIN = base.tuiBin;
|
|
87
|
+
return env;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* PURE — the shell command the host pane runs: `exec env K=V… bin args…`,
|
|
92
|
+
* every value quoted. `exec` replaces the pane's shell with the app so a quit
|
|
93
|
+
* (the palette verb) ends the pane — and with it the single-window session.
|
|
94
|
+
*/
|
|
95
|
+
export function hostedCommandLine(
|
|
96
|
+
bin: string,
|
|
97
|
+
argv: readonly string[],
|
|
98
|
+
env: Record<string, string>,
|
|
99
|
+
): string {
|
|
100
|
+
const assigns = Object.entries(env).map(([k, v]) => `${k}=${shellQuote(v)}`);
|
|
101
|
+
return ["exec", "env", ...assigns, shellQuote(bin), ...argv.map(shellQuote)].join(" ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** PURE — exact-match existence probe (`=` prefix: `has-session -t` would
|
|
105
|
+
* otherwise PREFIX-match, e.g. a user session named `_tmux-ide-app-notes`). */
|
|
106
|
+
export function hostExistsArgv(): string[] {
|
|
107
|
+
return ["has-session", "-t", `=${APP_HOST_SESSION}`];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** PURE — create the detached host session running the app command line. */
|
|
111
|
+
export function hostCreateArgv(opts: { cwd: string; commandLine: string }): string[] {
|
|
112
|
+
return ["new-session", "-d", "-s", APP_HOST_SESSION, "-c", opts.cwd, opts.commandLine];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The client events that re-assert `window-size latest` on the host (M25.5).
|
|
117
|
+
* Each hook's effect was MEASURED on tmux 3.7b in an isolated two-client rig
|
|
118
|
+
* (220x60 local + 120x40 ssh-sim):
|
|
119
|
+
*
|
|
120
|
+
* - `client-attached` / `client-focus-in` / `client-session-changed`: tmux
|
|
121
|
+
* 3.7b already re-adopts the event client's size natively on all of these
|
|
122
|
+
* (attach ~8ms, focus-in ~17ms, switch-client ~17ms — window-resized hook
|
|
123
|
+
* timestamps; detach of the latest client also re-adopts natively, ~13ms).
|
|
124
|
+
* The hooks are NOT what makes those paths work; they are the SELF-HEAL for
|
|
125
|
+
* the one measured way the host gets permanently stuck: any `resize-window`
|
|
126
|
+
* against it (a stray tool or user command) flips `window-size` to manual —
|
|
127
|
+
* after which NO client event re-adopts, ever (measured: a fresh 220x60
|
|
128
|
+
* attach left a manually-80x24 host at 80x24). Re-asserting the option is
|
|
129
|
+
* the heal (measured: instant re-adopt) — and it is safe to fire
|
|
130
|
+
* redundantly: setting `window-size latest` when it is already latest just
|
|
131
|
+
* recomputes the same size. Fire counts are bounded and linear (measured:
|
|
132
|
+
* 10 rapid focus alternations → exactly 20 focus-in fires; 10 attach cycles
|
|
133
|
+
* → 10 attached + 10 session-changed fires — attach fires both — each a
|
|
134
|
+
* single in-server set-option, no storm).
|
|
135
|
+
*
|
|
136
|
+
* NO `client-detached` hook: measured on 3.7b it never fires as a SESSION
|
|
137
|
+
* hook (the detaching client has already left the session when hooks are
|
|
138
|
+
* resolved — 0 fires across 10 detach cycles while the same rig's global
|
|
139
|
+
* hook logged every one), and the native detach re-adopt covers the path.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately NOT `resize-window -a`: per the tmux manual, EVERY
|
|
142
|
+
* resize-window form — -a included — "will automatically set window-size to
|
|
143
|
+
* manual", i.e. it would cause the exact stuck state it is meant to fix. And
|
|
144
|
+
* no `run-shell`: these are tmux-native commands executed in-server
|
|
145
|
+
* (run-shell hooks serialize the server — prior measurement).
|
|
146
|
+
*/
|
|
147
|
+
export const HOST_RESIZE_HOOKS = [
|
|
148
|
+
"client-attached",
|
|
149
|
+
"client-focus-in",
|
|
150
|
+
"client-session-changed",
|
|
151
|
+
] as const;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* PURE — the post-create/ensure session setup: status OFF so the app owns
|
|
155
|
+
* every row (under `status on` the host steals the bottom row and the app
|
|
156
|
+
* renders one short), and `window-size latest` pinned explicitly so the most
|
|
157
|
+
* recently active client dictates the size — a smaller second client
|
|
158
|
+
* letterboxes the larger one (tmux's own dot-fill), which is the documented
|
|
159
|
+
* behavior.
|
|
160
|
+
*
|
|
161
|
+
* M25.5 additions (each measured on 3.7b — see {@link HOST_RESIZE_HOOKS}):
|
|
162
|
+
*
|
|
163
|
+
* - `focus-events on` (server option — the ONE non-session-scoped line, and
|
|
164
|
+
* the load-bearing one): it defaults OFF, so real terminals are never asked
|
|
165
|
+
* for focus reporting and `client-focus-in` never fires. With it on, coming
|
|
166
|
+
* BACK to a terminal that stayed attached re-adopts that client's size on
|
|
167
|
+
* the focus event alone (measured: focus-in → client-active →
|
|
168
|
+
* window-resized in ~17ms) — no keystroke needed. This is the user's exact
|
|
169
|
+
* "reopen it on my computer locally" moment when the local client never
|
|
170
|
+
* detached. Side effect is the widely-recommended one (panes that request
|
|
171
|
+
* focus, e.g. editors, start receiving it).
|
|
172
|
+
* - the {@link HOST_RESIZE_HOOKS} self-heal hooks, session-scoped to the host
|
|
173
|
+
* (zero effect on user sessions).
|
|
174
|
+
*
|
|
175
|
+
* The whole list is idempotent — the CLI applies it on EVERY ensure, not just
|
|
176
|
+
* create, so upgrading tmux-ide fixes an already-running cockpit on its next
|
|
177
|
+
* `tmux-ide app` (and un-sticks a manually-resized host).
|
|
178
|
+
*
|
|
179
|
+
* No `=` exact-match prefix here: tmux (measured on 3.7b) rejects it on
|
|
180
|
+
* `set-option` session targets ("no such session") even though has-session
|
|
181
|
+
* and attach accept it. Plain names are safe in THIS builder only because
|
|
182
|
+
* setup runs right after the exists-probe/create — the exact session exists,
|
|
183
|
+
* and tmux prefers an exact match over a prefix match when one does.
|
|
184
|
+
*/
|
|
185
|
+
export function hostSetupArgvs(): string[][] {
|
|
186
|
+
const heal = `set-option -w -t ${APP_HOST_SESSION}: window-size latest`;
|
|
187
|
+
return [
|
|
188
|
+
["set-option", "-t", APP_HOST_SESSION, "status", "off"],
|
|
189
|
+
["set-option", "-w", "-t", `${APP_HOST_SESSION}:`, "window-size", "latest"],
|
|
190
|
+
["set-option", "-s", "focus-events", "on"],
|
|
191
|
+
...HOST_RESIZE_HOOKS.map((hook) => ["set-hook", "-t", APP_HOST_SESSION, hook, heal]),
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* PURE — how the invoking terminal reaches the cockpit: inside tmux the
|
|
197
|
+
* client is already attached to a server, so `switch-client` moves it (a
|
|
198
|
+
* nested `attach` would complain and double-render); a plain terminal
|
|
199
|
+
* attaches. Both exact-match the host name.
|
|
200
|
+
*/
|
|
201
|
+
export function hostAttachArgv(insideTmux: boolean): string[] {
|
|
202
|
+
return insideTmux
|
|
203
|
+
? ["switch-client", "-t", `=${APP_HOST_SESSION}`]
|
|
204
|
+
: ["attach-session", "-t", `=${APP_HOST_SESSION}`];
|
|
205
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PURE — tmux layout-string and control-notification parsing (M23.5).
|
|
3
|
+
*
|
|
4
|
+
* `%layout-change` arrives sub-millisecond after the server applies a layout
|
|
5
|
+
* and ALWAYS precedes the first new-size `%output` (measured on tmux 3.7b:
|
|
6
|
+
* the follow-up output can trail by as little as 0.2ms). The mirror therefore
|
|
7
|
+
* derives pane geometry from the notification PAYLOAD itself instead of a
|
|
8
|
+
* debounced `list-panes` round-trip — these parsers are that push path.
|
|
9
|
+
*
|
|
10
|
+
* The layout grammar mirrors tmux's `layout_parse.c`: a 4-hex-digit checksum,
|
|
11
|
+
* a comma, then a cell. A cell is `WxH,X,Y` followed by either `,<paneId>`
|
|
12
|
+
* (a leaf; the numeric pane id sans `%`), `{…}` (horizontal split) or `[…]`
|
|
13
|
+
* (vertical split) with comma-separated child cells. The ROOT cell's WxH is
|
|
14
|
+
* the authoritative window size. Parse the VISIBLE layout — the THIRD field
|
|
15
|
+
* of `%layout-change @win <layout> <visible-layout> <flags>` — because zoom
|
|
16
|
+
* collapses it to the single zoomed pane (`*Z` in flags = zoomed); the second
|
|
17
|
+
* field keeps reporting the saved multi-pane layout.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is unit-tested against layout strings captured from a real
|
|
20
|
+
* tmux 3.7b server (splits, zoom, storms) — no tmux at test time.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One visible pane rectangle, in window cells. `id` is `%`-prefixed. */
|
|
24
|
+
export interface LayoutLeaf {
|
|
25
|
+
id: string;
|
|
26
|
+
left: number;
|
|
27
|
+
top: number;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A parsed (visible) layout: the window size + the leaves in layout order. */
|
|
33
|
+
export interface ParsedLayout {
|
|
34
|
+
/** The root cell's WxH — the authoritative window size. */
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
leaves: LayoutLeaf[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse a tmux layout string (`csum,WxH,X,Y…`). Null on any malformed input
|
|
41
|
+
* (the caller falls back to the slow list-panes path — never throw here). */
|
|
42
|
+
export function parseLayout(layout: string): ParsedLayout | null {
|
|
43
|
+
if (!/^[0-9a-fA-F]{4},/.test(layout)) return null;
|
|
44
|
+
const s = layout.slice(5);
|
|
45
|
+
const leaves: LayoutLeaf[] = [];
|
|
46
|
+
const root = parseCell(s, 0, leaves);
|
|
47
|
+
if (!root || root.pos !== s.length) return null;
|
|
48
|
+
return { width: root.width, height: root.height, leaves };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Recursive-descent cell parse from `pos`; appends leaves in layout order. */
|
|
52
|
+
function parseCell(
|
|
53
|
+
s: string,
|
|
54
|
+
pos: number,
|
|
55
|
+
leaves: LayoutLeaf[],
|
|
56
|
+
): { width: number; height: number; pos: number } | null {
|
|
57
|
+
const dims = readDims(s, pos);
|
|
58
|
+
if (!dims) return null;
|
|
59
|
+
const { width, height, left, top } = dims;
|
|
60
|
+
pos = dims.pos;
|
|
61
|
+
const ch = s[pos];
|
|
62
|
+
if (ch === ",") {
|
|
63
|
+
// Leaf: the numeric pane id.
|
|
64
|
+
const id = readInt(s, pos + 1);
|
|
65
|
+
if (!id) return null;
|
|
66
|
+
leaves.push({ id: `%${id.value}`, left, top, width, height });
|
|
67
|
+
return { width, height, pos: id.pos };
|
|
68
|
+
}
|
|
69
|
+
if (ch === "{" || ch === "[") {
|
|
70
|
+
const close = ch === "{" ? "}" : "]";
|
|
71
|
+
pos++;
|
|
72
|
+
for (;;) {
|
|
73
|
+
const child = parseCell(s, pos, leaves);
|
|
74
|
+
if (!child) return null;
|
|
75
|
+
pos = child.pos;
|
|
76
|
+
if (s[pos] === ",") {
|
|
77
|
+
pos++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (s[pos] === close) return { width, height, pos: pos + 1 };
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// A bare root leaf ends the string (`…,0,0,445`): ch is undefined only when
|
|
85
|
+
// the leaf id was consumed above, so anything else here is malformed.
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read `WxH,X,Y` at `pos`. */
|
|
90
|
+
function readDims(
|
|
91
|
+
s: string,
|
|
92
|
+
pos: number,
|
|
93
|
+
): { width: number; height: number; left: number; top: number; pos: number } | null {
|
|
94
|
+
const w = readInt(s, pos);
|
|
95
|
+
if (!w || s[w.pos] !== "x") return null;
|
|
96
|
+
const h = readInt(s, w.pos + 1);
|
|
97
|
+
if (!h || s[h.pos] !== ",") return null;
|
|
98
|
+
const x = readInt(s, h.pos + 1);
|
|
99
|
+
if (!x || s[x.pos] !== ",") return null;
|
|
100
|
+
const y = readInt(s, x.pos + 1);
|
|
101
|
+
if (!y) return null;
|
|
102
|
+
return { width: w.value, height: h.value, left: x.value, top: y.value, pos: y.pos };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Read a decimal integer at `pos` (at least one digit). */
|
|
106
|
+
function readInt(s: string, pos: number): { value: number; pos: number } | null {
|
|
107
|
+
let end = pos;
|
|
108
|
+
while (end < s.length && s.charCodeAt(end) >= 0x30 && s.charCodeAt(end) <= 0x39) end++;
|
|
109
|
+
if (end === pos) return null;
|
|
110
|
+
return { value: Number(s.slice(pos, end)), pos: end };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A parsed `%layout-change` notification body. */
|
|
114
|
+
export interface LayoutChange {
|
|
115
|
+
windowId: string;
|
|
116
|
+
/** The saved (full) layout — kept for debugging; geometry uses `visible`. */
|
|
117
|
+
layout: string;
|
|
118
|
+
/** The VISIBLE layout — collapses to the single zoomed pane under zoom. */
|
|
119
|
+
visible: string;
|
|
120
|
+
/** `Z` present in the flags field (`*Z`). */
|
|
121
|
+
zoomed: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Parse the body after `%layout-change ` (tmux 3.7b:
|
|
125
|
+
* `@387 <layout> <visible-layout> *Z`). Null when the shape is off. */
|
|
126
|
+
export function parseLayoutChange(rest: string): LayoutChange | null {
|
|
127
|
+
const parts = rest.trim().split(/\s+/);
|
|
128
|
+
const [windowId = "", layout = "", visible = "", flags = ""] = parts;
|
|
129
|
+
if (parts.length < 3 || !windowId.startsWith("@")) return null;
|
|
130
|
+
return { windowId, layout, visible, zoomed: flags.includes("Z") };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse the body after `%window-pane-changed ` (`@387 %443`). */
|
|
134
|
+
export function parseWindowPaneChanged(rest: string): { windowId: string; paneId: string } | null {
|
|
135
|
+
const [windowId = "", paneId = ""] = rest.trim().split(/\s+/);
|
|
136
|
+
if (!windowId.startsWith("@") || !paneId.startsWith("%")) return null;
|
|
137
|
+
return { windowId, paneId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Parse the body after `%session-window-changed ` (`$353 @388`). */
|
|
141
|
+
export function parseSessionWindowChanged(rest: string): { windowId: string } | null {
|
|
142
|
+
const [, windowId = ""] = rest.trim().split(/\s+/);
|
|
143
|
+
if (!windowId.startsWith("@")) return null;
|
|
144
|
+
return { windowId };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Parse the body after `%subscription-changed ` for the mirror's `mouse`
|
|
148
|
+
* subscription (tmux 3.7b: `mouse $353 @387 0 %445 : 1`). Null for other
|
|
149
|
+
* subscription names or an off shape. */
|
|
150
|
+
export function parseMouseSubscription(rest: string): { paneId: string; on: boolean } | null {
|
|
151
|
+
const m = /^mouse\s+\$\S+\s+@\S+\s+\S+\s+(%\S+)\s*:\s*(.*)$/.exec(rest.trim());
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
return { paneId: m[1]!, on: m[2]!.trim() === "1" };
|
|
154
|
+
}
|