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,110 @@
1
+ /**
2
+ * In-app attention surfacing (M25.1) — the PURE half of "an agent you can't
3
+ * see needs you". The app's 3s fleet poll diffs the previous per-pane agent
4
+ * states against the fresh payload; blocked/done transitions for agents NOT on
5
+ * the current screen (another workspace, another window, or a non-Terminal
6
+ * tab) become a status-strip note plus a brief sidebar flash. The diff, the
7
+ * visibility test, and the note text live here so they unit-test without
8
+ * OpenTUI; app.tsx supplies the signals and timers.
9
+ *
10
+ * First sight is graced exactly like the chrome updater's notification path
11
+ * (`from: null` never notes) — an app boot must not re-announce every agent
12
+ * that was already blocked.
13
+ */
14
+ import type { AgentStatus } from "../detect/classify.ts";
15
+
16
+ /** The per-agent slice of the fleet payload the diff needs. */
17
+ export interface AttentionAgent {
18
+ paneId: string;
19
+ session: string;
20
+ kind: string;
21
+ state: AgentStatus;
22
+ }
23
+
24
+ /** One agent's state change between two fleet polls. */
25
+ export interface AttentionTransition {
26
+ paneId: string;
27
+ session: string;
28
+ kind: string;
29
+ from: AgentStatus | null;
30
+ to: AgentStatus;
31
+ }
32
+
33
+ /** What the app is currently showing — the visibility side of the test. */
34
+ export interface AttentionView {
35
+ /** The active surface tab (only `terminal` shows panes at all). */
36
+ tab: string;
37
+ /** Pane ids on screen right now (the mirrored window's panes). */
38
+ visiblePaneIds: readonly string[];
39
+ }
40
+
41
+ /** The two states worth interrupting the user over (mirrors the chrome
42
+ * updater's NOTIFY_STATES). */
43
+ const NOTE_STATES: ReadonlySet<AgentStatus> = new Set<AgentStatus>(["blocked", "done"]);
44
+
45
+ /**
46
+ * PURE — diff the previous per-pane states against this poll's agents.
47
+ * Returns the transitions plus the fresh state map to thread into the next
48
+ * poll (the input map is NOT mutated). A pane that vanished emits nothing and
49
+ * drops out of the state.
50
+ */
51
+ export function diffAttention(
52
+ prev: ReadonlyMap<string, AgentStatus>,
53
+ agents: readonly AttentionAgent[],
54
+ ): { transitions: AttentionTransition[]; next: Map<string, AgentStatus> } {
55
+ const next = new Map<string, AgentStatus>();
56
+ const transitions: AttentionTransition[] = [];
57
+ for (const a of agents) {
58
+ if (next.has(a.paneId)) continue; // deduped: a session can surface under two projects
59
+ const before = prev.has(a.paneId) ? prev.get(a.paneId)! : null;
60
+ next.set(a.paneId, a.state);
61
+ if (before === a.state) continue;
62
+ transitions.push({
63
+ paneId: a.paneId,
64
+ session: a.session,
65
+ kind: a.kind,
66
+ from: before,
67
+ to: a.state,
68
+ });
69
+ }
70
+ return { transitions, next };
71
+ }
72
+
73
+ /** PURE — is this pane on the app's screen right now? Only the Terminal tab
74
+ * shows panes; there, exactly the mirrored window's panes are visible. */
75
+ export function isPaneVisible(paneId: string, view: AttentionView): boolean {
76
+ return view.tab === "terminal" && view.visiblePaneIds.includes(paneId);
77
+ }
78
+
79
+ /**
80
+ * PURE — the transitions worth a note: blocked/done, not first-sight, and not
81
+ * currently visible (the user can see a visible pane flip themselves).
82
+ */
83
+ export function noteworthyTransitions(
84
+ transitions: readonly AttentionTransition[],
85
+ view: AttentionView,
86
+ ): AttentionTransition[] {
87
+ return transitions.filter(
88
+ (t) => t.from !== null && NOTE_STATES.has(t.to) && !isPaneVisible(t.paneId, view),
89
+ );
90
+ }
91
+
92
+ /** PURE — the status-strip note for one transition, e.g.
93
+ * `● claude blocked · zz-api — click agents`. Done reads `✓ … — click agents`. */
94
+ export function attentionNote(t: AttentionTransition): string {
95
+ const glyph = t.to === "blocked" ? "●" : "✓";
96
+ return `${glyph} ${t.kind} ${t.to} · ${t.session} — click agents`;
97
+ }
98
+
99
+ /** PURE — the note line for a whole poll's noteworthy set: the first (most
100
+ * recent diff order) transition's note, with a `(+N)` tail when several agents
101
+ * flipped in the same poll. Null for an empty set. */
102
+ export function attentionNoteLine(worthy: readonly AttentionTransition[]): string | null {
103
+ const first = worthy[0];
104
+ if (!first) return null;
105
+ const extra = worthy.length > 1 ? ` (+${worthy.length - 1})` : "";
106
+ return `${attentionNote(first)}${extra}`;
107
+ }
108
+
109
+ /** How long the sidebar attention flash lasts. */
110
+ export const ATTENTION_FLASH_MS = 2500;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pure cell → framebuffer writers for the framebuffer pane-blit path (M21.3).
3
+ *
4
+ * These map a mirror grid straight into an OpenTUI `OptimizedBuffer`'s typed
5
+ * arrays — no `StyledRun[]` intermediary, no per-run `RGBA` allocation, no Solid
6
+ * subtree. One packed cell is `char[i]` (a codepoint), four `fg`/`bg` channels
7
+ * (`r,g,b,a`, 0–255) at `i*4`, and one `attributes[i]` bitmask — the exact layout
8
+ * `OptimizedBuffer.buffers` exposes.
9
+ *
10
+ * Kept dependency-free (plain typed arrays) so the packing is unit-testable
11
+ * without the native render lib; {@link PaneMirror.blit} does the xterm walk and
12
+ * feeds these.
13
+ */
14
+
15
+ /** The spacer (second) half of a wide glyph: char `0`. The native renderer knows
16
+ * a cell is wide by MEASURING the lead cell's codepoint width and skips the
17
+ * spacer, so the spacer's char just needs to be empty — this is exactly what
18
+ * `OptimizedBuffer.setCell` writes for the trailing half (verified: a wide
19
+ * glyph → `char[i]=codepoint, char[i+1]=0`). NB the `0xC0000000`
20
+ * `CHAR_FLAG_CONTINUATION` seen in OptimizedBuffer is a DIFFERENT consumer (the
21
+ * JS `getSpanLines`/`getRealCharBytes` text reader, off our render path);
22
+ * writing it here blanks wide glyphs on screen (measured). */
23
+ export const CHAR_CONTINUATION = 0;
24
+
25
+ /** The space codepoint — the fill for blank / out-of-range cells. */
26
+ export const SPACE_CODE = 0x20;
27
+
28
+ /**
29
+ * INVERSE is rendered by SWAPPING fg/bg, not by the OpenTUI INVERSE attribute
30
+ * bit: a framebuffer cell carrying that bit composites (drawFrameBuffer preserves
31
+ * it) but does NOT flush as reverse-video — the direct `<text>` path does, the
32
+ * framebuffer path doesn't (measured). Swapping colors always renders (truecolor
33
+ * channels propagate cleanly), and covers app-driven reverse (vim status line),
34
+ * the cursor cell, and the drag selection uniformly. Swaps compose like the old
35
+ * XOR: two swaps on a cell (selection over the cursor) return it to normal.
36
+ */
37
+
38
+ /** The four packed arrays behind an `OptimizedBuffer` (or a plain-array stand-in
39
+ * for tests). `fg`/`bg` hold four `u16` channels (0–255) per cell at `i*4`. */
40
+ export interface CellArrays {
41
+ char: Uint32Array;
42
+ fg: Uint16Array;
43
+ bg: Uint16Array;
44
+ attributes: Uint32Array;
45
+ }
46
+
47
+ /**
48
+ * A cell whose grapheme is more than one codepoint (ZWJ/flag emoji, combining
49
+ * marks) — a single `char[i]` u32 can't hold it. The fast blit writes its base
50
+ * codepoint; the caller (which owns the native `OptimizedBuffer`) re-writes these
51
+ * few cells with the full string via `setCell`, keeping full parity for the rare
52
+ * case without an allocation on the common path.
53
+ */
54
+ export interface GraphemeOverride {
55
+ x: number;
56
+ y: number;
57
+ chars: string;
58
+ /** Packed `0xRRGGBB` or null (terminal default). */
59
+ fg: number | null;
60
+ bg: number | null;
61
+ attrs: number;
62
+ }
63
+
64
+ /**
65
+ * Write one styled cell at flat index `idx`. `fg`/`bg` are packed `0xRRGGBB` or
66
+ * `null` for the terminal default (→ the supplied `d*` channels); alpha is always
67
+ * opaque (panes never blend). No allocation — just eight typed-array stores.
68
+ */
69
+ export function writeCell(
70
+ buf: CellArrays,
71
+ idx: number,
72
+ codepoint: number,
73
+ fg: number | null,
74
+ bg: number | null,
75
+ attrs: number,
76
+ dfR: number,
77
+ dfG: number,
78
+ dfB: number,
79
+ dbR: number,
80
+ dbG: number,
81
+ dbB: number,
82
+ ): void {
83
+ buf.char[idx] = codepoint;
84
+ const o = idx * 4;
85
+ if (fg === null) {
86
+ buf.fg[o] = dfR;
87
+ buf.fg[o + 1] = dfG;
88
+ buf.fg[o + 2] = dfB;
89
+ } else {
90
+ buf.fg[o] = (fg >> 16) & 0xff;
91
+ buf.fg[o + 1] = (fg >> 8) & 0xff;
92
+ buf.fg[o + 2] = fg & 0xff;
93
+ }
94
+ buf.fg[o + 3] = 255;
95
+ if (bg === null) {
96
+ buf.bg[o] = dbR;
97
+ buf.bg[o + 1] = dbG;
98
+ buf.bg[o + 2] = dbB;
99
+ } else {
100
+ buf.bg[o] = (bg >> 16) & 0xff;
101
+ buf.bg[o + 1] = (bg >> 8) & 0xff;
102
+ buf.bg[o + 2] = bg & 0xff;
103
+ }
104
+ buf.bg[o + 3] = 255;
105
+ buf.attributes[idx] = attrs;
106
+ }
107
+
108
+ /**
109
+ * Mark cell `idx` as the continuation (spacer) half of the wide glyph at `idx-1`,
110
+ * inheriting the glyph's colors so a colored wide glyph's background spans both
111
+ * cells. The renderer emits nothing for a continuation cell but still paints its
112
+ * background.
113
+ */
114
+ export function writeContinuation(buf: CellArrays, idx: number): void {
115
+ buf.char[idx] = CHAR_CONTINUATION;
116
+ const o = idx * 4;
117
+ const p = (idx - 1) * 4;
118
+ buf.fg[o] = buf.fg[p]!;
119
+ buf.fg[o + 1] = buf.fg[p + 1]!;
120
+ buf.fg[o + 2] = buf.fg[p + 2]!;
121
+ buf.fg[o + 3] = buf.fg[p + 3]!;
122
+ buf.bg[o] = buf.bg[p]!;
123
+ buf.bg[o + 1] = buf.bg[p + 1]!;
124
+ buf.bg[o + 2] = buf.bg[p + 2]!;
125
+ buf.bg[o + 3] = buf.bg[p + 3]!;
126
+ buf.attributes[idx] = buf.attributes[idx - 1]!;
127
+ }
128
+
129
+ /**
130
+ * Swap fg/bg across cells `[from,to]` (inclusive, clamped to the row) of row `y`
131
+ * — the drag-selection reverse-video post-pass. A swap over the cursor cell (also
132
+ * swapped in the blit) returns it to normal, matching the run path's XOR.
133
+ */
134
+ export function swapCells(
135
+ buf: CellArrays,
136
+ width: number,
137
+ y: number,
138
+ from: number,
139
+ to: number,
140
+ ): void {
141
+ const lo = Math.max(0, from);
142
+ const hi = Math.min(width - 1, to);
143
+ const base = y * width;
144
+ for (let x = lo; x <= hi; x++) {
145
+ const o = (base + x) * 4;
146
+ const r = buf.fg[o]!;
147
+ const g = buf.fg[o + 1]!;
148
+ const b = buf.fg[o + 2]!;
149
+ const a = buf.fg[o + 3]!;
150
+ buf.fg[o] = buf.bg[o]!;
151
+ buf.fg[o + 1] = buf.bg[o + 1]!;
152
+ buf.fg[o + 2] = buf.bg[o + 2]!;
153
+ buf.fg[o + 3] = buf.bg[o + 3]!;
154
+ buf.bg[o] = r;
155
+ buf.bg[o + 1] = g;
156
+ buf.bg[o + 2] = b;
157
+ buf.bg[o + 3] = a;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Paint the background of cells `[from,to]` (inclusive, clamped) of row `y` to
163
+ * packed `bg` — the scrollback-search highlight post-pass (the twin of
164
+ * {@link invertCells}; distinct treatment so a match and the selection coexist).
165
+ */
166
+ export function paintBg(
167
+ buf: CellArrays,
168
+ width: number,
169
+ y: number,
170
+ from: number,
171
+ to: number,
172
+ bg: number,
173
+ ): void {
174
+ const lo = Math.max(0, from);
175
+ const hi = Math.min(width - 1, to);
176
+ const r = (bg >> 16) & 0xff;
177
+ const g = (bg >> 8) & 0xff;
178
+ const b = bg & 0xff;
179
+ for (let x = lo; x <= hi; x++) {
180
+ const o = (y * width + x) * 4;
181
+ buf.bg[o] = r;
182
+ buf.bg[o + 1] = g;
183
+ buf.bg[o + 2] = b;
184
+ buf.bg[o + 3] = 255;
185
+ }
186
+ }
@@ -12,14 +12,29 @@
12
12
  * intact; `parseControlLine`/`decodeControlBytes` do the pure protocol work.
13
13
  */
14
14
  import { spawn, type ChildProcess } from "node:child_process";
15
+ import { appendFileSync } from "node:fs";
15
16
  import { parseControlLine, textToHexKeys } from "./control.ts";
16
17
 
17
18
  interface PendingReply {
19
+ discard?: false;
18
20
  resolve: (lines: string[]) => void;
19
21
  reject: (err: Error) => void;
20
22
  lines: string[];
21
23
  }
22
24
 
25
+ /** A fire-and-forget command's FIFO placeholder: its reply block is consumed
26
+ * and dropped (errors counted). `lines` collects the error body only when
27
+ * TMUX_IDE_MIRROR_DEBUG asks for it. */
28
+ interface DiscardedReply {
29
+ discard: true;
30
+ lines?: string[];
31
+ }
32
+
33
+ type Pending = PendingReply | DiscardedReply;
34
+
35
+ /** The shared no-debug placeholder — fire-and-forget input allocates NOTHING. */
36
+ const DISCARDED: DiscardedReply = { discard: true };
37
+
23
38
  export interface ControlClientOptions {
24
39
  /** Session (or other tmux target) to attach the control client to. */
25
40
  attachTarget: string;
@@ -33,9 +48,10 @@ export interface ControlClientOptions {
33
48
 
34
49
  export class ControlModeClient {
35
50
  private proc: ChildProcess | null = null;
36
- private readonly pending: PendingReply[] = [];
51
+ private readonly pending: Pending[] = [];
37
52
  private inReply = false;
38
53
  private buffer = "";
54
+ private discardedErrors = 0;
39
55
  private readonly opts: ControlClientOptions;
40
56
 
41
57
  constructor(opts: ControlClientOptions) {
@@ -72,14 +88,38 @@ export class ControlModeClient {
72
88
  });
73
89
  }
74
90
 
75
- /** Type literal text into a pane (UTF-8, sent as hex bytes — quote-proof). */
76
- sendText(pane: string, text: string): Promise<string[]> {
77
- return this.command(`send-keys -t ${pane} -H ${textToHexKeys(text).join(" ")}`);
91
+ /**
92
+ * The INPUT FAST PATH (M21.5): write a command fire-and-forget. The bytes
93
+ * hit tmux's stdin exactly as immediately as `command()`'s do, but no
94
+ * Promise/resolver is allocated and nothing ever waits on the reply. Every
95
+ * control-mode command still produces exactly one `%begin/%end` block, so a
96
+ * placeholder is pushed onto the SAME pending FIFO — reply matching for
97
+ * reply-carrying commands (list-panes, capture-pane, …) stays aligned by
98
+ * construction. Errors are swallowed but counted ({@link inputErrorCount});
99
+ * with TMUX_IDE_MIRROR_DEBUG set the error body is appended to
100
+ * /tmp/zz-input-errors.log.
101
+ */
102
+ send(cmd: string): void {
103
+ const proc = this.proc;
104
+ if (!proc?.stdin?.writable) return;
105
+ this.pending.push(process.env.TMUX_IDE_MIRROR_DEBUG ? { discard: true, lines: [] } : DISCARDED);
106
+ proc.stdin.write(`${cmd}\n`);
107
+ }
108
+
109
+ /** Type literal text into a pane (UTF-8, sent as hex bytes — quote-proof).
110
+ * Fire-and-forget: input never queues behind a slow structural reply. */
111
+ sendText(pane: string, text: string): void {
112
+ this.send(`send-keys -t ${pane} -H ${textToHexKeys(text).join(" ")}`);
78
113
  }
79
114
 
80
- /** Send a named tmux key (Enter, Escape, Up, C-c, …) to a pane. */
81
- sendKey(pane: string, key: string): Promise<string[]> {
82
- return this.command(`send-keys -t ${pane} ${key}`);
115
+ /** Send a named tmux key (Enter, Escape, Up, C-c, …) to a pane. Fire-and-forget. */
116
+ sendKey(pane: string, key: string): void {
117
+ this.send(`send-keys -t ${pane} ${key}`);
118
+ }
119
+
120
+ /** How many fire-and-forget commands came back `%error` (debug/tests). */
121
+ get inputErrorCount(): number {
122
+ return this.discardedErrors;
83
123
  }
84
124
 
85
125
  dispose(): void {
@@ -97,6 +137,7 @@ export class ControlModeClient {
97
137
  }
98
138
 
99
139
  private feed(chunk: string): void {
140
+ const t0 = process.env.TMUX_IDE_ZZ_PERF ? performance.now() : 0;
100
141
  this.buffer += chunk;
101
142
  let nl: number;
102
143
  while ((nl = this.buffer.indexOf("\n")) !== -1) {
@@ -106,6 +147,16 @@ export class ControlModeClient {
106
147
  this.buffer = this.buffer.slice(nl + 1);
107
148
  this.handleLine(line);
108
149
  }
150
+ if (t0) {
151
+ try {
152
+ appendFileSync(
153
+ "/tmp/zz-feed.log",
154
+ `${chunk.length} ${(performance.now() - t0).toFixed(2)}\n`,
155
+ );
156
+ } catch {
157
+ /* perf tap */
158
+ }
159
+ }
109
160
  }
110
161
 
111
162
  private handleLine(line: string): void {
@@ -114,14 +165,34 @@ export class ControlModeClient {
114
165
  case "begin":
115
166
  this.inReply = true;
116
167
  break;
117
- case "reply-line":
118
- this.pending[0]?.lines.push(event.line);
168
+ case "reply-line": {
169
+ const head = this.pending[0];
170
+ // Discarded (fire-and-forget) replies skip body collection entirely —
171
+ // unless the debug placeholder brought its own lines buffer.
172
+ if (head) head.lines?.push(event.line);
119
173
  break;
174
+ }
120
175
  case "end":
121
176
  case "error": {
122
177
  this.inReply = false;
123
178
  const reply = this.pending.shift();
124
179
  if (!reply) break; // unsolicited block (e.g. greeting after a race)
180
+ if (reply.discard) {
181
+ if (event.kind === "error") {
182
+ this.discardedErrors++;
183
+ if (process.env.TMUX_IDE_MIRROR_DEBUG) {
184
+ try {
185
+ appendFileSync(
186
+ "/tmp/zz-input-errors.log",
187
+ `#${this.discardedErrors} ${reply.lines?.join(" | ") ?? ""}\n`,
188
+ );
189
+ } catch {
190
+ // debug tap only
191
+ }
192
+ }
193
+ }
194
+ break;
195
+ }
125
196
  if (event.kind === "error") {
126
197
  reply.reject(new Error(reply.lines.join("\n") || "tmux command failed"));
127
198
  } else {