tmux-ide 2.6.1 → 2.7.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 (83) hide show
  1. package/README.md +14 -9
  2. package/bin/cli.js +1013 -516
  3. package/bin/cli.ts +62 -5
  4. package/package.json +9 -6
  5. package/packages/daemon/dist/agent-explain.d.ts +8 -1
  6. package/packages/daemon/dist/agent-explain.js +19 -3
  7. package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
  8. package/packages/daemon/dist/lib/tui-binary.js +130 -0
  9. package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
  10. package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
  11. package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
  12. package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
  13. package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
  14. package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
  15. package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
  16. package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
  17. package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
  18. package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
  19. package/packages/daemon/src/agent-explain.ts +34 -6
  20. package/packages/daemon/src/lib/app-config.ts +93 -2
  21. package/packages/daemon/src/lib/tui-binary.ts +165 -0
  22. package/packages/daemon/src/tui/chrome/notify.ts +221 -34
  23. package/packages/daemon/src/tui/chrome/updater.ts +89 -7
  24. package/packages/daemon/src/tui/compiled.ts +11 -3
  25. package/packages/daemon/src/tui/detect/classify.ts +15 -0
  26. package/packages/daemon/src/tui/detect/manifest-loader.ts +2 -1
  27. package/packages/daemon/src/tui/detect/manifest.ts +15 -0
  28. package/packages/daemon/src/tui/detect/manifests.ts +161 -33
  29. package/packages/daemon/src/tui/detect/process-tree.ts +20 -0
  30. package/packages/daemon/src/tui/main.ts +13 -1
  31. package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
  32. package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
  33. package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
  34. package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
  35. package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
  36. package/packages/daemon/src/tui/mirror/blit.ts +186 -0
  37. package/packages/daemon/src/tui/mirror/control-client.ts +80 -9
  38. package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
  39. package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
  40. package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
  41. package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
  42. package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
  43. package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
  44. package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
  45. package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
  46. package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
  47. package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
  48. package/packages/daemon/src/tui/mirror/palette.ts +274 -0
  49. package/packages/daemon/src/tui/mirror/pane-mirror.ts +499 -19
  50. package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
  51. package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
  52. package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
  53. package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
  54. package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
  55. package/packages/daemon/src/tui/mirror/selection.ts +262 -0
  56. package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
  57. package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
  58. package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
  59. package/packages/daemon/src/tui/mirror/spans.ts +46 -0
  60. package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
  61. package/packages/daemon/src/tui/team/entry.ts +34 -7
  62. package/packages/daemon/src/tui/team/report.ts +11 -1
  63. package/packages/daemon/src/tui/team/sessions.ts +102 -13
  64. package/scripts/build-tui.mjs +11 -4
  65. package/scripts/perf-mirror.mjs +313 -0
  66. package/skill/SKILL.md +22 -0
  67. package/templates/AGENTS.md +14 -7
  68. package/templates/agent-team-monorepo.yml +8 -0
  69. package/templates/agent-team-nextjs.yml +8 -0
  70. package/templates/agent-team.yml +10 -0
  71. package/templates/convex.yml +2 -0
  72. package/templates/default.yml +11 -5
  73. package/templates/go.yml +4 -0
  74. package/templates/missions.yml +6 -0
  75. package/templates/nextjs.yml +4 -0
  76. package/templates/python.yml +4 -0
  77. package/templates/skills/backend.md +5 -12
  78. package/templates/skills/frontend.md +5 -12
  79. package/templates/skills/general-worker.md +5 -12
  80. package/templates/skills/researcher.md +7 -12
  81. package/templates/skills/reviewer.md +7 -16
  82. package/templates/vite.yml +4 -0
  83. package/packages/daemon/src/tui/mirror/viewer.tsx +0 -166
@@ -0,0 +1,415 @@
1
+ /**
2
+ * PaneSurface — one framebuffer renderable per mirror pane (M21.3).
3
+ *
4
+ * The native-feel render core: instead of a triple-nested `<For>` (panes → rows →
5
+ * styled runs → `<text>`) rebuilt every tick, each pane is ONE
6
+ * `FrameBufferRenderable`. On a content/scroll/selection change it blits the
7
+ * pane's xterm-headless grid straight into its `OptimizedBuffer`'s packed typed
8
+ * arrays ({@link PaneMirror.blit}) — no `StyledRun[]`, no Solid subtree churn, no
9
+ * per-run `RGBA`. This kills the three measured render taxes in one move.
10
+ *
11
+ * The blit is GATED: renderSelf only re-walks the grid when a reactive prop
12
+ * actually changed (each setter flips `_needsWalk` + `requestRender`), so an idle
13
+ * pane costs one native framebuffer composite, not a grid walk. Selection tint and
14
+ * scrollback-search highlight are a post-pass over the blitted cells; the cursor
15
+ * inverse rides inside the blit. Chrome (borders, the scroll badge, the scrollbar)
16
+ * stays Solid JSX in the parent — only cell CONTENT lives here.
17
+ *
18
+ * MOUSE: no `onMouse` here. Per the app.tsx mouse-architecture header, the two
19
+ * region containers own hit-testing via `routeMouse`; this renderable is a
20
+ * handler-less content layer that bubbles (0.4.3 confirmed).
21
+ */
22
+ import {
23
+ FrameBufferRenderable,
24
+ RGBA,
25
+ type OptimizedBuffer,
26
+ type RenderContext,
27
+ type RenderableOptions,
28
+ } from "@opentui/core";
29
+ import { extend } from "@opentui/solid";
30
+ import { appendFileSync } from "node:fs";
31
+ import type { SessionMirror } from "./session-mirror.ts";
32
+ import type { CursorState } from "./pane-mirror.ts";
33
+ import { swapCells, paintBg, type GraphemeOverride } from "./blit.ts";
34
+ import { rowSelectionRange, type Cell } from "./selection.ts";
35
+ import type { SearchMatch } from "./search-model.ts";
36
+
37
+ /** The scrollback-search highlight payload for one pane: matches keyed by
38
+ * ABSOLUTE buffer line (mapped to a visible row via `baseY`), the query length,
39
+ * and which match index is the "current" (brighter) one. */
40
+ export interface PaneSearchHighlight {
41
+ matches: readonly SearchMatch[];
42
+ current: number;
43
+ len: number;
44
+ /** Visible row = `match.line − baseY` (pane depth − scroll offset). */
45
+ baseY: number;
46
+ }
47
+
48
+ export interface PaneSurfaceOptions extends RenderableOptions<FrameBufferRenderable> {
49
+ width: number;
50
+ height: number;
51
+ mirror: SessionMirror;
52
+ paneId: string;
53
+ /** Packed `0xRRGGBB` for the terminal default fg/bg (a cell whose color is null). */
54
+ defaultFg: number;
55
+ defaultBg: number;
56
+ /** Packed `0xRRGGBB` for a search match bg / the current-match bg. */
57
+ searchHl: number;
58
+ searchCur: number;
59
+ scrollOffset?: number;
60
+ paneFocused?: boolean;
61
+ /** Bumps (coalesced, once per state tick) when this pane's content changed. */
62
+ contentVersion?: number;
63
+ /** The drag selection on THIS pane (already surface/pane-filtered), or null. */
64
+ selRange?: { start: Cell; end: Cell } | null;
65
+ search?: PaneSearchHighlight | null;
66
+ }
67
+
68
+ const rgbaCache = new Map<number, RGBA>();
69
+ function packedRgba(packed: number): RGBA {
70
+ let c = rgbaCache.get(packed);
71
+ if (!c) {
72
+ c = RGBA.fromInts((packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff, 255);
73
+ rgbaCache.set(packed, c);
74
+ }
75
+ return c;
76
+ }
77
+
78
+ const PERF = !!process.env.TMUX_IDE_ZZ_PERF;
79
+ /** Quiet marker background for an unfocused pane's cursor cell (M21.6) — a muted
80
+ * slate that reads as "the cursor is here" without competing with the focused
81
+ * pane's real hardware cursor. */
82
+ const MARKER_BG = 0x3b4250;
83
+ /** Log the rows-blitted count per walk (M21.4 acceptance: flood repaints only
84
+ * dirty rows). Env-gated so it costs nothing in production. */
85
+ const ROW_TAP = !!process.env.TMUX_IDE_FB_ROWS;
86
+
87
+ /** Union of several row-index arrays into one deduped array (small arrays — a
88
+ * linear membership check is cheaper than a Set here). */
89
+ function unionRows(...groups: number[][]): number[] {
90
+ const out: number[] = [];
91
+ for (const g of groups) for (const r of g) if (!out.includes(r)) out.push(r);
92
+ return out;
93
+ }
94
+
95
+ class PaneSurfaceRenderable extends FrameBufferRenderable {
96
+ // The OpenTUI/Solid reconciler constructs `new PaneSurfaceRenderable(ctx, {id})`
97
+ // and applies EVERY other prop afterward via these setters — so the constructor
98
+ // must default the framebuffer size (like the reference renderable) and each
99
+ // field must have a safe default until its setter fires.
100
+ private _mirror: SessionMirror | null = null;
101
+ private _paneId = "";
102
+ private _defaultFg = 0xd4d4d8;
103
+ private _defaultBg = 0x101016;
104
+ private _defaultFgRgba: RGBA = packedRgba(0xd4d4d8);
105
+ private _defaultBgRgba: RGBA = packedRgba(0x101016);
106
+ private _searchHl = 0;
107
+ private _searchCur = 0;
108
+ private _scrollOffset = 0;
109
+ private _focusedPane = false;
110
+ private _contentVersion = -1;
111
+ private _sel: { start: Cell; end: Cell } | null = null;
112
+ private _search: PaneSearchHighlight | null = null;
113
+ private _needsWalk = true;
114
+ private readonly _graphemes: GraphemeOverride[] = [];
115
+ // ── Incremental walk state (M21.4) ─────────────────────────────────────────
116
+ /** Force a full repaint next walk (first frame, resize — the framebuffer is
117
+ * blank so the mirror's shadow must be refilled). */
118
+ private _forceFull = true;
119
+ private _lastScroll = -1;
120
+ private _lastFocused = false;
121
+ /** Rows the selection/search highlighted last walk — repainted this walk so a
122
+ * vacated highlight's fg/bg swap is cleared, not stranded. */
123
+ private _prevSelRows: number[] = [];
124
+ private _prevSearchRows: number[] = [];
125
+ /** Reused out-array for the rows the blit wrote (its content-dirty ∪ forced). */
126
+ private readonly _dirtyRows: number[] = [];
127
+ /** The row an unfocused cursor marker last painted on, so a move/clear repaints
128
+ * the vacated row (M21.6). */
129
+ private _lastMarkerRow = -1;
130
+
131
+ constructor(ctx: RenderContext, options: PaneSurfaceOptions) {
132
+ // Default 1×1 — the real size arrives as the width/height layout props (base
133
+ // Renderable setters) which drive onResize → framebuffer resize.
134
+ const width = typeof options.width === "number" ? Math.max(1, options.width) : 1;
135
+ const height = typeof options.height === "number" ? Math.max(1, options.height) : 1;
136
+ super(ctx, { ...options, width, height, respectAlpha: false });
137
+ }
138
+
139
+ // ── Constant props (delivered via setters post-construction, then stable). ──
140
+ set mirror(v: SessionMirror) {
141
+ this._mirror = v;
142
+ this._needsWalk = true;
143
+ }
144
+ set paneId(v: string) {
145
+ if (v === this._paneId) return;
146
+ this._paneId = v;
147
+ this.invalidate();
148
+ }
149
+ set defaultFg(v: number) {
150
+ this._defaultFg = v;
151
+ this._defaultFgRgba = packedRgba(v);
152
+ }
153
+ set defaultBg(v: number) {
154
+ this._defaultBg = v;
155
+ this._defaultBgRgba = packedRgba(v);
156
+ }
157
+ set searchHl(v: number) {
158
+ this._searchHl = v;
159
+ }
160
+ set searchCur(v: number) {
161
+ this._searchCur = v;
162
+ }
163
+
164
+ // ── Reactive props: a change flips _needsWalk so the next paint re-blits. ──
165
+ set scrollOffset(v: number) {
166
+ if (v === this._scrollOffset) return;
167
+ this._scrollOffset = v;
168
+ this.invalidate();
169
+ }
170
+ set paneFocused(v: boolean) {
171
+ if (v === this._focusedPane) return;
172
+ this._focusedPane = v;
173
+ this.invalidate();
174
+ }
175
+ set contentVersion(v: number) {
176
+ if (v === this._contentVersion) return;
177
+ this._contentVersion = v;
178
+ this.invalidate();
179
+ }
180
+ set selRange(v: { start: Cell; end: Cell } | null) {
181
+ // Objects arrive only when selection() actually changed (or cleared to null).
182
+ if (v === null && this._sel === null) return;
183
+ this._sel = v;
184
+ this.invalidate();
185
+ }
186
+ set search(v: PaneSearchHighlight | null) {
187
+ if (v === null && this._search === null) return;
188
+ this._search = v;
189
+ this.invalidate();
190
+ }
191
+
192
+ private invalidate(): void {
193
+ this._needsWalk = true;
194
+ this.requestRender();
195
+ }
196
+
197
+ protected override onResize(width: number, height: number): void {
198
+ super.onResize(width, height);
199
+ // The framebuffer was reallocated (blank) — the next paint must repaint it
200
+ // in full (and the mirror refills its shadow).
201
+ this._needsWalk = true;
202
+ this._forceFull = true;
203
+ }
204
+
205
+ protected override renderSelf(buffer: OptimizedBuffer): void {
206
+ if (!this.visible || this.isDestroyed) return;
207
+ if (this._needsWalk) {
208
+ this._needsWalk = false;
209
+ this.walk();
210
+ }
211
+ super.renderSelf(buffer);
212
+ }
213
+
214
+ /** The gated grid walk (M21.4 — incremental): the mirror repaints only the
215
+ * changed rows (content compare + scroll shift); we force-repaint the rows the
216
+ * selection/search touched (old ∪ new) so a vacated highlight's fg/bg swap is
217
+ * cleared, then re-apply the search + selection post-passes over the current
218
+ * highlighted rows. Timed to /tmp/zz-perf.log under TMUX_IDE_ZZ_PERF (the blit
219
+ * path's "snapshot ms/tick"); the rows-blitted count taps to a debug log. */
220
+ private walk(): void {
221
+ if (!this._mirror) return;
222
+ const t0 = PERF ? performance.now() : 0;
223
+ const fb = this.frameBuffer;
224
+ const buffers = fb.buffers;
225
+ const w = fb.width;
226
+ const h = fb.height;
227
+
228
+ // View-wide changes force a full repaint; content dirtiness is the mirror's job.
229
+ const full =
230
+ this._forceFull ||
231
+ this._scrollOffset !== this._lastScroll ||
232
+ this._focusedPane !== this._lastFocused;
233
+ this._forceFull = false;
234
+ this._lastScroll = this._scrollOffset;
235
+ this._lastFocused = this._focusedPane;
236
+
237
+ const newSelRows = this.selRows(h);
238
+ const newSearchRows = this.searchRows(h);
239
+ // The live cursor (M21.6): the focused pane drives the hardware cursor; an
240
+ // unfocused pane paints a quiet marker on its cursor cell. Read once.
241
+ const cur = this._mirror.cursorState(this._paneId);
242
+ const markerRow =
243
+ cur && !this._focusedPane && !cur.hidden && this._scrollOffset === 0 && cur.x < w && cur.y < h
244
+ ? cur.y
245
+ : -1;
246
+ // Rows that must repaint so their swap/highlight/marker is cleared then
247
+ // re-applied (old ∪ new selection/search + old ∪ new marker row).
248
+ const forceRows = full
249
+ ? null
250
+ : unionRows(
251
+ this._prevSelRows,
252
+ newSelRows,
253
+ this._prevSearchRows,
254
+ newSearchRows,
255
+ markerRow >= 0 ? [markerRow] : [],
256
+ this._lastMarkerRow >= 0 ? [this._lastMarkerRow] : [],
257
+ );
258
+
259
+ this._graphemes.length = 0;
260
+ this._dirtyRows.length = 0;
261
+ this._mirror.blitPane(
262
+ this._paneId,
263
+ buffers,
264
+ w,
265
+ h,
266
+ this._scrollOffset,
267
+ this._defaultFg,
268
+ this._defaultBg,
269
+ {
270
+ full,
271
+ forceRows,
272
+ dirtyRows: this._dirtyRows,
273
+ graphemes: this._graphemes,
274
+ },
275
+ );
276
+
277
+ // Multi-codepoint graphemes (ZWJ/flag emoji, combining marks) — the native
278
+ // setCell handles the full string + its width; rare, so the RGBA is fine.
279
+ for (let i = 0; i < this._graphemes.length; i++) {
280
+ const g = this._graphemes[i]!;
281
+ fb.setCell(
282
+ g.x,
283
+ g.y,
284
+ g.chars,
285
+ g.fg === null ? this._defaultFgRgba : packedRgba(g.fg),
286
+ g.bg === null ? this._defaultBgRgba : packedRgba(g.bg),
287
+ g.attrs,
288
+ );
289
+ }
290
+ // Post-passes re-apply over the current highlighted rows — all of which were
291
+ // just repainted (they're in forceRows, or full covered them).
292
+ const s = this._search;
293
+ if (s && s.len > 0) {
294
+ for (let i = 0; i < s.matches.length; i++) {
295
+ const m = s.matches[i]!;
296
+ const row = m.line - s.baseY;
297
+ if (row < 0 || row >= h) continue;
298
+ paintBg(
299
+ buffers,
300
+ w,
301
+ row,
302
+ m.col,
303
+ m.col + s.len - 1,
304
+ i === s.current ? this._searchCur : this._searchHl,
305
+ );
306
+ }
307
+ }
308
+ const sel = this._sel;
309
+ if (sel) {
310
+ for (let i = 0; i < newSelRows.length; i++) {
311
+ const y = newSelRows[i]!;
312
+ const r = rowSelectionRange(y, w, sel.start, sel.end);
313
+ if (r) swapCells(buffers, w, y, r.from, r.to);
314
+ }
315
+ }
316
+ // Unfocused quiet cursor marker — a muted block on the cursor cell (its row
317
+ // is in forceRows, so it was freshly repainted this walk).
318
+ if (markerRow >= 0 && cur) {
319
+ paintBg(buffers, w, markerRow, cur.x, cur.x, MARKER_BG);
320
+ }
321
+ this._lastMarkerRow = markerRow;
322
+ this._prevSelRows = newSelRows;
323
+ this._prevSearchRows = newSearchRows;
324
+
325
+ this.updateHardwareCursor(cur, w, h);
326
+
327
+ if (PERF) {
328
+ try {
329
+ appendFileSync("/tmp/zz-perf.log", `${(performance.now() - t0).toFixed(2)}\n`);
330
+ } catch {
331
+ /* perf tap only */
332
+ }
333
+ }
334
+ if (ROW_TAP) {
335
+ try {
336
+ appendFileSync(
337
+ "/tmp/zz-fb-rows.log",
338
+ `${this._paneId} ${this._dirtyRows.length}/${h}${full ? " full" : ""}\n`,
339
+ );
340
+ } catch {
341
+ /* debug tap only */
342
+ }
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Drive the REAL terminal cursor (M21.6). Only the focused, live pane owns the
348
+ * single hardware cursor: it positions it at the pane's cursor cell (absolute
349
+ * screen coords), honoring the app's DECTCEM hide and DECSCUSR shape/blink, so
350
+ * vim/claude/htop cursors behave natively. A focused pane that is scrolled up
351
+ * or whose app hid the cursor hides it; unfocused panes never touch it (they
352
+ * carry a painted marker instead).
353
+ */
354
+ private updateHardwareCursor(c: CursorState | null, w: number, h: number): void {
355
+ if (!this._focusedPane || !c) return;
356
+ const inBounds = c.x >= 0 && c.x < w && c.y >= 0 && c.y < h;
357
+ const live = this._scrollOffset === 0;
358
+ const visible = live && inBounds && !c.hidden;
359
+ // Absolute screen position of the pane's cursor cell. setCursorPosition is
360
+ // 1-based (matches OpenTUI's own editor: screenX + visualCol + 1).
361
+ const gx = this.x + Math.min(c.x, w - 1) + 1;
362
+ const gy = this.y + Math.min(c.y, h - 1) + 1;
363
+ this._ctx.setCursorPosition(gx, gy, visible);
364
+ if (visible) {
365
+ this._ctx.setCursorStyle({
366
+ style: c.style === "bar" ? "line" : c.style,
367
+ blinking: c.blink,
368
+ });
369
+ }
370
+ }
371
+
372
+ /** Visible rows covered by the current drag selection (clamped), or []. The
373
+ * range arrives ordered (app.tsx passes `orderCells`). */
374
+ private selRows(height: number): number[] {
375
+ const sel = this._sel;
376
+ if (!sel) return [];
377
+ const lo = Math.max(0, sel.start.row);
378
+ const hi = Math.min(height - 1, sel.end.row);
379
+ const out: number[] = [];
380
+ for (let y = lo; y <= hi; y++) out.push(y);
381
+ return out;
382
+ }
383
+
384
+ /** Distinct visible rows carrying a search match (clamped), or []. */
385
+ private searchRows(height: number): number[] {
386
+ const s = this._search;
387
+ if (!s || s.len === 0) return [];
388
+ const out: number[] = [];
389
+ for (let i = 0; i < s.matches.length; i++) {
390
+ const row = s.matches[i]!.line - s.baseY;
391
+ if (row >= 0 && row < height && !out.includes(row)) out.push(row);
392
+ }
393
+ return out;
394
+ }
395
+ }
396
+
397
+ declare module "@opentui/solid" {
398
+ interface OpenTUIComponents {
399
+ pane_surface: typeof PaneSurfaceRenderable;
400
+ }
401
+ }
402
+
403
+ let registered = false;
404
+ /** Register `<pane_surface>` in the OpenTUI component catalogue. Idempotent, and
405
+ * exported as a real value call so app.tsx can invoke it — a bare side-effect
406
+ * `import "./pane-surface"` gets dead-code-eliminated by the transpiler, which
407
+ * left the tag unregistered and the reconciler throwing "Unknown component
408
+ * type: pane_surface" at render (measured). A value call can't be elided. */
409
+ export function registerPaneSurface(): void {
410
+ if (registered) return;
411
+ registered = true;
412
+ extend({ pane_surface: PaneSurfaceRenderable });
413
+ }
414
+
415
+ export { PaneSurfaceRenderable };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Input-latency perf tap for the mirror pipeline — the third scenario tap
3
+ * alongside control-client's feed-parse tap (/tmp/zz-feed.log) and app.tsx's
4
+ * snapshot/tick tap (/tmp/zz-perf.log).
5
+ *
6
+ * It measures the three hops a keystroke takes through the mirror:
7
+ * t0 the app forwards the key to the focused pane (send-keys) → sent()
8
+ * t1 the first `%output` for that pane arrives back (the echo) → output()
9
+ * t2 the next paint tick consumes the dirty flag and re-snapshots → tick()
10
+ *
11
+ * The pure {@link InputTap} state machine carries the logic (one entry per
12
+ * pane in flight, keyed by pane id); the module-level `tap*` wrappers are the
13
+ * allocation-light io shell, strictly gated behind `TMUX_IDE_ZZ_PERF` and
14
+ * appending `paneId echoMs paintMs` lines to /tmp/zz-input.log.
15
+ *
16
+ * {@link percentile}/{@link summarize} are the pure reporting helpers the
17
+ * harness (scripts/perf-mirror.mjs) uses to turn the raw logs into p50/p95.
18
+ */
19
+ import { appendFileSync } from "node:fs";
20
+
21
+ const INPUT_LOG = "/tmp/zz-input.log";
22
+
23
+ /** One fully-observed keystroke round-trip. */
24
+ export interface InputSample {
25
+ paneId: string;
26
+ /** t1 − t0: keystroke dispatch → first echo byte back from the pane. */
27
+ echoMs: number;
28
+ /** t2 − t0: keystroke dispatch → the paint tick that re-snapshots the echo. */
29
+ paintMs: number;
30
+ }
31
+
32
+ /**
33
+ * PURE — the three-hop input-latency state machine.
34
+ *
35
+ * At most one keystroke is tracked per pane (a later `sent` for the same pane
36
+ * overwrites an unfinished one — the scenario types with gaps so each key
37
+ * round-trips before the next). `tick` drains every pane that has seen its
38
+ * echo, emitting a sample and clearing it; panes still awaiting an echo stay
39
+ * in flight. No timers, no clock reads of its own — the caller passes `now`.
40
+ */
41
+ export class InputTap {
42
+ private readonly inFlight = new Map<string, { t0: number; t1: number }>();
43
+
44
+ /** t0 — the app forwarded a key to `paneId`. */
45
+ sent(paneId: string, now: number): void {
46
+ if (!paneId) return;
47
+ this.inFlight.set(paneId, { t0: now, t1: 0 });
48
+ }
49
+
50
+ /** t1 — first `%output` for `paneId` after a `sent` (later bytes ignored). */
51
+ output(paneId: string, now: number): void {
52
+ const rec = this.inFlight.get(paneId);
53
+ if (rec && rec.t1 === 0) rec.t1 = now;
54
+ }
55
+
56
+ /** t2 — a paint tick; emit + clear every pane whose echo has landed. */
57
+ tick(now: number): InputSample[] {
58
+ if (this.inFlight.size === 0) return [];
59
+ const out: InputSample[] = [];
60
+ for (const [paneId, rec] of this.inFlight) {
61
+ if (rec.t1 === 0) continue;
62
+ out.push({ paneId, echoMs: rec.t1 - rec.t0, paintMs: now - rec.t0 });
63
+ this.inFlight.delete(paneId);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** In-flight pane count (test/introspection only). */
69
+ size(): number {
70
+ return this.inFlight.size;
71
+ }
72
+ }
73
+
74
+ // ── io shell: one process-wide tap, gated behind the env flag ───────────────
75
+
76
+ const singleton = new InputTap();
77
+
78
+ /** t0 hook — call at the app's key-forward site. No-op unless TMUX_IDE_ZZ_PERF. */
79
+ export function tapInputSent(paneId: string): void {
80
+ if (!process.env.TMUX_IDE_ZZ_PERF) return;
81
+ singleton.sent(paneId, performance.now());
82
+ }
83
+
84
+ /** t1 hook — call from SessionMirror's onOutput. No-op unless TMUX_IDE_ZZ_PERF. */
85
+ export function tapInputOutput(paneId: string): void {
86
+ if (!process.env.TMUX_IDE_ZZ_PERF) return;
87
+ singleton.output(paneId, performance.now());
88
+ }
89
+
90
+ /** t2 hook — call from the paint tick after setPanes. Flushes to /tmp/zz-input.log. */
91
+ export function tapInputTick(): void {
92
+ if (!process.env.TMUX_IDE_ZZ_PERF) return;
93
+ const samples = singleton.tick(performance.now());
94
+ for (const s of samples) {
95
+ try {
96
+ appendFileSync(INPUT_LOG, `${s.paneId} ${s.echoMs.toFixed(2)} ${s.paintMs.toFixed(2)}\n`);
97
+ } catch {
98
+ /* perf tap only */
99
+ }
100
+ }
101
+ }
102
+
103
+ // ── size re-pin tap (M22.8) ─────────────────────────────────────────────────
104
+ // Every canvas-area change (terminal resize, sidebar drag) funnels through the
105
+ // mirror's `resize()` — the ONE re-pin chokepoint. This tap records each re-pin
106
+ // so a live drag/resize burst can be asserted to collapse to the expected count
107
+ // (the 200ms size poll coalesces a burst to one re-pin per settled size). Gated
108
+ // behind TMUX_IDE_ZZ_PERF, appending `cols rows` lines to /tmp/zz-repin.log.
109
+
110
+ const REPIN_LOG = "/tmp/zz-repin.log";
111
+
112
+ /** Record one re-pin (a `refresh-client -C cols x rows`). No-op unless the flag. */
113
+ export function tapRepin(cols: number, rows: number): void {
114
+ if (!process.env.TMUX_IDE_ZZ_PERF) return;
115
+ try {
116
+ appendFileSync(REPIN_LOG, `${cols} ${rows}\n`);
117
+ } catch {
118
+ /* perf tap only */
119
+ }
120
+ }
121
+
122
+ // ── pure reporting helpers (used by scripts/perf-mirror.mjs) ────────────────
123
+
124
+ /** A percentile of an ASCENDING-sorted array, linearly interpolated. Empty → 0. */
125
+ export function percentile(sortedAsc: number[], p: number): number {
126
+ const n = sortedAsc.length;
127
+ if (n === 0) return 0;
128
+ if (n === 1) return sortedAsc[0]!;
129
+ const rank = (Math.max(0, Math.min(100, p)) / 100) * (n - 1);
130
+ const lo = Math.floor(rank);
131
+ const hi = Math.ceil(rank);
132
+ if (lo === hi) return sortedAsc[lo]!;
133
+ const frac = rank - lo;
134
+ return sortedAsc[lo]! * (1 - frac) + sortedAsc[hi]! * frac;
135
+ }
136
+
137
+ export interface Summary {
138
+ count: number;
139
+ p50: number;
140
+ p95: number;
141
+ min: number;
142
+ max: number;
143
+ mean: number;
144
+ }
145
+
146
+ /** Distribution summary of `values` (order-independent — sorts a copy). */
147
+ export function summarize(values: number[]): Summary {
148
+ const n = values.length;
149
+ if (n === 0) return { count: 0, p50: 0, p95: 0, min: 0, max: 0, mean: 0 };
150
+ const sorted = [...values].sort((a, b) => a - b);
151
+ const sum = sorted.reduce((acc, v) => acc + v, 0);
152
+ return {
153
+ count: n,
154
+ p50: percentile(sorted, 50),
155
+ p95: percentile(sorted, 95),
156
+ min: sorted[0]!,
157
+ max: sorted[n - 1]!,
158
+ mean: sum / n,
159
+ };
160
+ }
@@ -0,0 +1,85 @@
1
+ /** Pure geometry for M19.3 pane-border drag resize. The mirror renders each tmux
2
+ * pane as an absolute box at (left,top) sized (width,height); the 1-cell gaps
3
+ * between adjacent panes show the canvas background as a SEPARATOR. A "down"
4
+ * landing on a separator (a canvas-local cell inside no pane rect, flanked by
5
+ * two panes) starts a border drag; the router turns the drag delta into an
6
+ * ABSOLUTE new size and applies it via `resize-pane -t <a> -x|-y <cells>`
7
+ * (smoother than repeated -L/-R steps, and %layout-change resyncs the render).
8
+ * Keeping the hit-test + size math here (like spans/menu-model/diff-model) makes
9
+ * it unit-testable off the render loop. All coordinates are CANVAS-LOCAL: cx is
10
+ * `x - sidebarW`, cy is `y - TABBAR_H - HEADER_ROWS`. */
11
+
12
+ /** The minimal pane rectangle the hit test needs (LivePane is assignable). */
13
+ export interface PaneRect {
14
+ id: string;
15
+ left: number;
16
+ top: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ /** A resolved border between two adjacent panes. `axis` "x" = a VERTICAL
22
+ * separator column (drag left/right resizes widths); "y" = a HORIZONTAL
23
+ * separator row (drag up/down resizes heights). `aId` is the pane BEFORE the
24
+ * separator (to its left / above), `bId` the one after. `aSize`/`bSize` are the
25
+ * two panes' starting extents along the axis — the base the drag delta is added
26
+ * to (a) and the far bound (b) the clamp protects. */
27
+ export interface Separator {
28
+ axis: "x" | "y";
29
+ aId: string;
30
+ bId: string;
31
+ aSize: number;
32
+ bSize: number;
33
+ }
34
+
35
+ /** Does pane `p` contain canvas cell (cx,cy)? */
36
+ function inside(p: PaneRect, cx: number, cy: number): boolean {
37
+ return cx >= p.left && cx < p.left + p.width && cy >= p.top && cy < p.top + p.height;
38
+ }
39
+
40
+ /**
41
+ * PURE — resolve the pane separator under canvas cell (cx,cy), or null. A cell
42
+ * inside any pane rect is never a separator (that's a pane hit). A VERTICAL
43
+ * separator is the 1-cell gap column where one pane ends (`a.left+a.width`) and
44
+ * the next begins (`b.left`), both spanning row cy; a HORIZONTAL separator is the
45
+ * gap row between a pane ending at `a.top+a.height` and one beginning at `b.top`,
46
+ * both spanning column cx. Vertical is checked first (matches tmux's own
47
+ * left/right bias); the first matching pair wins.
48
+ */
49
+ export function separatorAt(panes: readonly PaneRect[], cx: number, cy: number): Separator | null {
50
+ for (const p of panes) if (inside(p, cx, cy)) return null;
51
+ // Vertical separator: `a` ends at column cx, `b` begins at cx+1, both cover cy.
52
+ for (const a of panes) {
53
+ if (a.left + a.width !== cx) continue;
54
+ if (cy < a.top || cy >= a.top + a.height) continue;
55
+ const b = panes.find((q) => q.left === cx + 1 && cy >= q.top && cy < q.top + q.height);
56
+ if (b) return { axis: "x", aId: a.id, bId: b.id, aSize: a.width, bSize: b.width };
57
+ }
58
+ // Horizontal separator: `a` ends at row cy, `b` begins at cy+1, both cover cx.
59
+ for (const a of panes) {
60
+ if (a.top + a.height !== cy) continue;
61
+ if (cx < a.left || cx >= a.left + a.width) continue;
62
+ const b = panes.find((q) => q.top === cy + 1 && cx >= q.left && cx < q.left + q.width);
63
+ if (b) return { axis: "y", aId: a.id, bId: b.id, aSize: a.height, bSize: b.height };
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * PURE — the absolute new extent for pane `a` given a drag `delta` (in cells)
70
+ * along the separator's axis. Clamped so neither pane drops below `MIN_PANE`:
71
+ * the two panes share `aSize + bSize` inner cells, so a's new size is bounded to
72
+ * `[MIN_PANE, aSize + bSize - MIN_PANE]`. tmux enforces its own minimum too, but
73
+ * clamping here keeps the emitted command sane and the math testable.
74
+ */
75
+ export const MIN_PANE = 2;
76
+ export function resizedSize(sep: Separator, delta: number): number {
77
+ const total = sep.aSize + sep.bSize;
78
+ return Math.max(MIN_PANE, Math.min(total - MIN_PANE, sep.aSize + delta));
79
+ }
80
+
81
+ /** PURE — the `resize-pane` command applying an absolute `size` to pane `aId`
82
+ * along `axis` (x = width, y = height). */
83
+ export function resizeCommand(sep: Separator, size: number): string {
84
+ return `resize-pane -t ${sep.aId} ${sep.axis === "x" ? "-x" : "-y"} ${size}`;
85
+ }