tmux-ide 2.6.1 → 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.
Files changed (154) hide show
  1. package/README.md +36 -14
  2. package/bin/cli.js +4174 -1235
  3. package/bin/cli.ts +426 -72
  4. package/package.json +10 -6
  5. package/packages/contracts/src/__tests__/control.test.ts +154 -0
  6. package/packages/contracts/src/control.ts +217 -0
  7. package/packages/contracts/src/index.ts +1 -0
  8. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  9. package/packages/daemon/dist/agent-explain.js +19 -3
  10. package/packages/daemon/dist/control/client.d.ts +23 -0
  11. package/packages/daemon/dist/control/client.js +105 -0
  12. package/packages/daemon/dist/control/dispatch.d.ts +34 -0
  13. package/packages/daemon/dist/control/dispatch.js +83 -0
  14. package/packages/daemon/dist/control/fanout.d.ts +19 -0
  15. package/packages/daemon/dist/control/fanout.js +37 -0
  16. package/packages/daemon/dist/control/frames.d.ts +23 -0
  17. package/packages/daemon/dist/control/frames.js +37 -0
  18. package/packages/daemon/dist/control/lifecycle.d.ts +45 -0
  19. package/packages/daemon/dist/control/lifecycle.js +114 -0
  20. package/packages/daemon/dist/control/server.d.ts +16 -0
  21. package/packages/daemon/dist/control/server.js +214 -0
  22. package/packages/daemon/dist/control/verbs.d.ts +11 -0
  23. package/packages/daemon/dist/control/verbs.js +91 -0
  24. package/packages/daemon/dist/doctor.d.ts +18 -0
  25. package/packages/daemon/dist/doctor.js +105 -15
  26. package/packages/daemon/dist/lib/agent-discovery.d.ts +27 -2
  27. package/packages/daemon/dist/lib/agent-discovery.js +29 -14
  28. package/packages/daemon/dist/lib/app-config.d.ts +106 -0
  29. package/packages/daemon/dist/lib/app-config.js +104 -5
  30. package/packages/daemon/dist/lib/manifest-pack.d.ts +79 -0
  31. package/packages/daemon/dist/lib/manifest-pack.js +232 -0
  32. package/packages/daemon/dist/lib/state-home.d.ts +2 -0
  33. package/packages/daemon/dist/lib/state-home.js +12 -0
  34. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  35. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  36. package/packages/daemon/dist/lib/update-check.js +5 -0
  37. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Info.plist +34 -0
  38. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/MacOS/tmux-ide-notifier +0 -0
  39. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/PkgInfo +1 -0
  40. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/AppIcon.icns +0 -0
  41. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/Resources/Assets.car +0 -0
  42. package/packages/daemon/dist/native/TmuxIdeNotifier.app/Contents/_CodeSignature/CodeResources +139 -0
  43. package/packages/daemon/dist/restore.d.ts +35 -8
  44. package/packages/daemon/dist/restore.js +52 -15
  45. package/packages/daemon/dist/send.d.ts +33 -1
  46. package/packages/daemon/dist/send.js +32 -19
  47. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  48. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  49. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  50. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  51. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  52. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  53. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  54. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  55. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  56. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  57. package/packages/daemon/src/agent-explain.ts +34 -6
  58. package/packages/daemon/src/control/client.ts +128 -0
  59. package/packages/daemon/src/control/dispatch.ts +107 -0
  60. package/packages/daemon/src/control/fanout.ts +44 -0
  61. package/packages/daemon/src/control/frames.ts +40 -0
  62. package/packages/daemon/src/control/lifecycle.ts +151 -0
  63. package/packages/daemon/src/control/server.ts +237 -0
  64. package/packages/daemon/src/control/verbs.ts +118 -0
  65. package/packages/daemon/src/doctor.ts +113 -28
  66. package/packages/daemon/src/lib/agent-discovery.ts +53 -13
  67. package/packages/daemon/src/lib/app-config.ts +194 -5
  68. package/packages/daemon/src/lib/manifest-pack.ts +255 -0
  69. package/packages/daemon/src/lib/state-home.ts +13 -0
  70. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  71. package/packages/daemon/src/lib/update-check.ts +5 -0
  72. package/packages/daemon/src/restore.ts +53 -15
  73. package/packages/daemon/src/send.ts +55 -21
  74. package/packages/daemon/src/tui/chrome/events.ts +4 -4
  75. package/packages/daemon/src/tui/chrome/front-door.ts +39 -0
  76. package/packages/daemon/src/tui/chrome/notify-prefs.ts +58 -0
  77. package/packages/daemon/src/tui/chrome/notify-state.ts +76 -0
  78. package/packages/daemon/src/tui/chrome/notify.ts +737 -52
  79. package/packages/daemon/src/tui/chrome/updater.ts +318 -30
  80. package/packages/daemon/src/tui/compiled.ts +11 -3
  81. package/packages/daemon/src/tui/detect/classify.ts +49 -0
  82. package/packages/daemon/src/tui/detect/manifest-loader.ts +56 -6
  83. package/packages/daemon/src/tui/detect/manifest.ts +39 -3
  84. package/packages/daemon/src/tui/detect/manifests.ts +395 -33
  85. package/packages/daemon/src/tui/detect/process-tree.ts +33 -3
  86. package/packages/daemon/src/tui/detect/session-id.ts +503 -0
  87. package/packages/daemon/src/tui/integrations/opencode.ts +121 -0
  88. package/packages/daemon/src/tui/main.ts +13 -1
  89. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  90. package/packages/daemon/src/tui/mirror/agent-chip.ts +126 -0
  91. package/packages/daemon/src/tui/mirror/agent-lifecycle.ts +437 -0
  92. package/packages/daemon/src/tui/mirror/agent-rows.ts +155 -0
  93. package/packages/daemon/src/tui/mirror/app-state.ts +342 -0
  94. package/packages/daemon/src/tui/mirror/app.tsx +7048 -0
  95. package/packages/daemon/src/tui/mirror/attention.ts +110 -0
  96. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  97. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  98. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  99. package/packages/daemon/src/tui/mirror/dialog-stack.ts +367 -0
  100. package/packages/daemon/src/tui/mirror/diff-model.ts +387 -0
  101. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  102. package/packages/daemon/src/tui/mirror/file-tree.ts +322 -0
  103. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  104. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  105. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  106. package/packages/daemon/src/tui/mirror/host-terminal.ts +49 -0
  107. package/packages/daemon/src/tui/mirror/hosted.ts +205 -0
  108. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  109. package/packages/daemon/src/tui/mirror/layout-parse.ts +154 -0
  110. package/packages/daemon/src/tui/mirror/menu-model.ts +210 -0
  111. package/packages/daemon/src/tui/mirror/palette.ts +564 -0
  112. package/packages/daemon/src/tui/mirror/pane-mirror.ts +578 -20
  113. package/packages/daemon/src/tui/mirror/pane-surface.tsx +422 -0
  114. package/packages/daemon/src/tui/mirror/perf-tap.ts +186 -0
  115. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  116. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  117. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  118. package/packages/daemon/src/tui/mirror/selection.ts +376 -0
  119. package/packages/daemon/src/tui/mirror/session-mirror.ts +724 -0
  120. package/packages/daemon/src/tui/mirror/settings-model.ts +425 -0
  121. package/packages/daemon/src/tui/mirror/sidebar.tsx +218 -0
  122. package/packages/daemon/src/tui/mirror/size-truth.ts +130 -0
  123. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  124. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  125. package/packages/daemon/src/tui/mirror/theme.ts +45 -0
  126. package/packages/daemon/src/tui/team/entry.ts +34 -7
  127. package/packages/daemon/src/tui/team/fuzzy.ts +20 -0
  128. package/packages/daemon/src/tui/team/report.ts +11 -1
  129. package/packages/daemon/src/tui/team/sessions.ts +184 -17
  130. package/packages/daemon/src/tui/team/wait.ts +144 -0
  131. package/scripts/build-macos-notifier.mjs +160 -0
  132. package/scripts/build-tui.mjs +11 -4
  133. package/scripts/perf-mirror.mjs +313 -0
  134. package/scripts/postinstall.js +8 -1
  135. package/scripts/prepublish-check.mjs +37 -1
  136. package/scripts/publish-tap.sh +55 -0
  137. package/skill/SKILL.md +110 -2
  138. package/templates/AGENTS.md +14 -7
  139. package/templates/agent-team-monorepo.yml +8 -0
  140. package/templates/agent-team-nextjs.yml +8 -0
  141. package/templates/agent-team.yml +10 -0
  142. package/templates/convex.yml +2 -0
  143. package/templates/default.yml +11 -5
  144. package/templates/go.yml +4 -0
  145. package/templates/missions.yml +6 -0
  146. package/templates/nextjs.yml +4 -0
  147. package/templates/python.yml +4 -0
  148. package/templates/skills/backend.md +5 -12
  149. package/templates/skills/frontend.md +5 -12
  150. package/templates/skills/general-worker.md +5 -12
  151. package/templates/skills/researcher.md +7 -12
  152. package/templates/skills/reviewer.md +7 -16
  153. package/templates/vite.yml +4 -0
  154. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -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,105 @@
1
+ /**
2
+ * InputCoalescer — the pure core of the input fast path (M21.5).
3
+ *
4
+ * Burst-typed LITERAL characters coalesce into one buffered run per pane and
5
+ * leave as a single `send-keys -H <hex…>` write per flush; named/ctrl keys
6
+ * (Enter, C-c, Up, …) are emitted immediately. THE ordering invariant: any
7
+ * buffered literal run flushes BEFORE a named key — or a literal for a
8
+ * DIFFERENT pane — is emitted, so bytes reach the pane in exactly the order
9
+ * the user produced them.
10
+ *
11
+ * Pure core, io shell: this class owns no timers and does no io. The caller
12
+ * injects `emit` (where flushed actions go — the control client's
13
+ * fire-and-forget write in production) and `schedule` (when a pending literal
14
+ * buffer should auto-flush — a microtask in production, a hand-cranked stub in
15
+ * tests). A microtask flush still happens inside the same macrotask that
16
+ * buffered the keystroke — strictly before the process can read more input —
17
+ * so coalescing adds no perceivable latency; it only merges keystrokes that
18
+ * arrived together.
19
+ *
20
+ * Flushes re-chunk the buffered run under {@link SEND_KEYS_CHUNK_BYTES} so a
21
+ * large paste routed through the same path never produces an oversized
22
+ * control-mode command.
23
+ */
24
+ import { chunkByBytes } from "./selection.ts";
25
+
26
+ /**
27
+ * Max UTF-8 payload bytes per `send-keys -H` write. Every byte travels as its
28
+ * own `xx` argv token, and each token hits tmux's yacc command parser — which
29
+ * is the REAL constraint, measured empirically against tmux 3.6b control mode
30
+ * (M21.5), not a line-length cap:
31
+ *
32
+ * - HARD LIMIT: a 16 KB payload (≈16k tokens) is REJECTED outright with
33
+ * `%error: yacc stack overflow` (yacc's ~10k-entry stack).
34
+ * - PARSE COST is superlinear in tokens: one command's reply takes ~3 ms at
35
+ * 1 KB, ~9 ms at 2 KB, ~56 ms at 4 KB, ~170 ms at 8 KB — big chunks STALL
36
+ * the tmux server (and every interleaved keystroke behind it).
37
+ * - END-TO-END, a 100 KB paste lands byte-perfect in ~246/155/180/233/255/
38
+ * 561/1483 ms at 64/128/256/512/1024/2048/4096-byte chunks — the optimum
39
+ * is ~128–256 B; the pre-M21.5 1024 was past the knee, and RAISING the
40
+ * chunk (the naive read of "fewer commands = faster") is strictly worse.
41
+ *
42
+ * 256 B is chosen: within noise of the throughput optimum, ~1 ms of server
43
+ * parse per command (imperceptible for input interleaving), 5× headroom from
44
+ * nothing, and 40× from the yacc cliff.
45
+ */
46
+ export const SEND_KEYS_CHUNK_BYTES = 256;
47
+
48
+ /** One flushed input action, ready to become a control-mode write. */
49
+ export type InputAction =
50
+ | { kind: "literal"; pane: string; text: string }
51
+ | { kind: "key"; pane: string; key: string };
52
+
53
+ export class InputCoalescer {
54
+ private pane = "";
55
+ private buf = "";
56
+ private scheduled = false;
57
+
58
+ constructor(
59
+ private readonly emit: (action: InputAction) => void,
60
+ private readonly schedule: (flush: () => void) => void,
61
+ private readonly maxChunkBytes: number = SEND_KEYS_CHUNK_BYTES,
62
+ ) {}
63
+
64
+ /** Buffer literal text for `pane`; a pending run for ANOTHER pane flushes
65
+ * first so cross-pane order is preserved. Schedules an auto-flush once per
66
+ * pending run. */
67
+ literal(pane: string, text: string): void {
68
+ if (!pane || !text) return;
69
+ if (this.buf.length > 0 && this.pane !== pane) this.flush();
70
+ this.pane = pane;
71
+ this.buf += text;
72
+ if (!this.scheduled) {
73
+ this.scheduled = true;
74
+ this.schedule(() => {
75
+ this.scheduled = false;
76
+ this.flush();
77
+ });
78
+ }
79
+ }
80
+
81
+ /** Emit a named tmux key (Enter, C-c, Up, …) — pending literals flush first
82
+ * (synchronously) so the key can never overtake buffered characters. */
83
+ key(pane: string, key: string): void {
84
+ if (!pane || !key) return;
85
+ this.flush();
86
+ this.emit({ kind: "key", pane, key });
87
+ }
88
+
89
+ /** Drain the pending literal run now (chunked under the byte cap). Also the
90
+ * ordering barrier callers place before reply-matched structural commands. */
91
+ flush(): void {
92
+ if (this.buf.length === 0) return;
93
+ const pane = this.pane;
94
+ const text = this.buf;
95
+ this.buf = "";
96
+ for (const chunk of chunkByBytes(text, this.maxChunkBytes)) {
97
+ this.emit({ kind: "literal", pane, text: chunk });
98
+ }
99
+ }
100
+
101
+ /** Buffered-but-unflushed character count (tests/introspection only). */
102
+ pending(): number {
103
+ return this.buf.length;
104
+ }
105
+ }
@@ -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
+ }