@luan.sh/pi-libtui 0.3.8

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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,1302 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type Component,
5
+ Container,
6
+ Spacer,
7
+ type TUI,
8
+ TuiAltScreen,
9
+ type TuiInputListenerResult,
10
+ } from "@earendil-works/pi-tui";
11
+ import { tuiTheme } from "../../src/color/theme.ts";
12
+ import { ComponentStack } from "../../src/component-stack.ts";
13
+ import { parseUnifiedDiff, UnifiedDiffView } from "../../src/diff/index.ts";
14
+ import {
15
+ installAllMotionTracking,
16
+ installMouseBridge,
17
+ isPiMultiplexedEnvironment,
18
+ } from "../../src/host/mouse-bridge.ts";
19
+ import { getMouseRegistryState } from "../../src/mouse/registry.ts";
20
+ import {
21
+ ensureMouseRegistry,
22
+ FULLSCREEN_LAYOUT_CAPABILITY_KEY,
23
+ getFullscreenLayoutCapability,
24
+ resolveFullscreenLayout,
25
+ type TuiMouseEvent,
26
+ } from "../../src/mouse.ts";
27
+ import { ensureSelectionRegistry, type NativeSelectionCompleted } from "../../src/selection.ts";
28
+ import { ToolDisclosureAction } from "../../src/tool/disclosure-action.ts";
29
+ import { ToolOutput } from "../../src/tool/output.ts";
30
+ import { ToolViewRegion } from "../../src/tool/view-region.ts";
31
+
32
+ type TestHandler = (event: TuiMouseEvent) => boolean;
33
+
34
+ class TestComponent implements Component {
35
+ readonly events: TuiMouseEvent[] = [];
36
+ private readonly handler: TestHandler;
37
+
38
+ constructor(handler: TestHandler = () => false) {
39
+ this.handler = handler;
40
+ }
41
+
42
+ render(): string[] {
43
+ return ["test"];
44
+ }
45
+
46
+ invalidate(): void {}
47
+
48
+ onMouse(event: TuiMouseEvent): boolean {
49
+ this.events.push(event);
50
+ return this.handler(event);
51
+ }
52
+ }
53
+
54
+ class LinesComponent implements Component {
55
+ constructor(private readonly lines: string[]) {}
56
+
57
+ render(): string[] {
58
+ return this.lines;
59
+ }
60
+
61
+ invalidate(): void {}
62
+ }
63
+
64
+ class RenderCountingComponent implements Component {
65
+ renders = 0;
66
+
67
+ render(): string[] {
68
+ this.renders += 1;
69
+ return ["expensive transcript"];
70
+ }
71
+
72
+ invalidate(): void {}
73
+ }
74
+
75
+ class RenderCountingMouseComponent extends TestComponent {
76
+ renders = 0;
77
+
78
+ override render(): string[] {
79
+ this.renders += 1;
80
+ return ["target"];
81
+ }
82
+ }
83
+
84
+ interface TestBox {
85
+ component: Component;
86
+ rect: { x: number; y: number; width: number; height: number };
87
+ clip: { x: number; y: number; width: number; height: number };
88
+ children: TestBox[];
89
+ scrollView?: object;
90
+ scrollContentLines?: readonly string[];
91
+ }
92
+
93
+ interface TestTui {
94
+ mode: "fullscreen" | "regular";
95
+ currentLayout?: { root: TestBox; primaryScrollView?: object };
96
+ handleViewportInput(data: string): TuiInputListenerResult;
97
+ requestRender(): void;
98
+ shouldDeferViewportInputToOverlay?(): boolean;
99
+ getTopmostVisibleOverlay?(): object;
100
+ resolveOverlayLayout?(
101
+ options: object | undefined,
102
+ height: number,
103
+ width: number,
104
+ rows: number,
105
+ ): {
106
+ width: number;
107
+ row: number;
108
+ col: number;
109
+ maxHeight?: number;
110
+ };
111
+ nativeInputs: string[];
112
+ renders: number;
113
+ selectionPressActive?: boolean;
114
+ selectionAnchor?: object;
115
+ selectionFocus?: object;
116
+ selectionGranularity?: string;
117
+ mouseEnabled?: boolean;
118
+ copySelectionToClipboard?(): void;
119
+ terminal: { rows: number; columns: number; writes: string[]; write(data: string): void };
120
+ }
121
+
122
+ type TestTuiPrototype = Pick<TestTui, "handleViewportInput" | "requestRender">;
123
+
124
+ // type-boundary: Object.setPrototypeOf supplies the two TestTui methods immediately after construction.
125
+ type IncompleteTestTui = unknown;
126
+
127
+ // type-boundary: TestTui implements the renderer members exercised by installMouseBridge.
128
+ type TestTuiBoundary = unknown;
129
+
130
+ function box(component: Component, children: TestBox[] = [], x = 0, y = 0, width = 20, height = 10): TestBox {
131
+ return {
132
+ component,
133
+ rect: { x, y, width, height },
134
+ clip: { x, y, width, height },
135
+ children,
136
+ };
137
+ }
138
+
139
+ function createPrototype(): TestTuiPrototype {
140
+ return {
141
+ handleViewportInput(this: TestTui, data) {
142
+ this.nativeInputs.push(data);
143
+ return { data: `native:${data}` };
144
+ },
145
+ requestRender(this: TestTui) {
146
+ this.renders += 1;
147
+ },
148
+ };
149
+ }
150
+
151
+ function createTui(root?: TestBox, prototype: TestTuiPrototype = createPrototype()): TestTui {
152
+ const incomplete: IncompleteTestTui = {
153
+ mode: "fullscreen",
154
+ currentLayout: root ? { root } : undefined,
155
+ nativeInputs: [],
156
+ renders: 0,
157
+ mouseEnabled: true,
158
+ terminal: {
159
+ rows: 24,
160
+ columns: 80,
161
+ writes: [] as string[],
162
+ write(data: string) {
163
+ this.writes.push(data);
164
+ },
165
+ },
166
+ };
167
+ const tui = incomplete as TestTui;
168
+ Object.setPrototypeOf(tui, prototype);
169
+ return tui;
170
+ }
171
+
172
+ function createTranscriptTui(): {
173
+ tui: TestTui;
174
+ scrollView: { scrollTop: number; viewportHeight: number; scrollTo(row: number): void };
175
+ } {
176
+ const scrollView = {
177
+ scrollTop: 2,
178
+ viewportHeight: 2,
179
+ scrollTo(row: number) {
180
+ this.scrollTop = row;
181
+ },
182
+ };
183
+ const content = new LinesComponent(["zero", "one", "two", "three"]);
184
+ const contentBox = box(content, [], 0, -2, 20, 4);
185
+ const primaryBox = box(new LinesComponent([]), [contentBox], 0, 0, 20, 2);
186
+ primaryBox.scrollView = scrollView;
187
+ primaryBox.scrollContentLines = ["zero", "one", "two", "three"];
188
+ const root = box(new LinesComponent([]), [primaryBox], 0, 0, 20, 2);
189
+ const tui = createTui(root);
190
+ tui.currentLayout = { root, primaryScrollView: scrollView };
191
+ return { tui, scrollView };
192
+ }
193
+
194
+ function input(tui: TestTui, data: string): TuiInputListenerResult {
195
+ return tui.handleViewportInput(data);
196
+ }
197
+
198
+ function registry() {
199
+ return ensureMouseRegistry(Object.create(null) as typeof globalThis);
200
+ }
201
+
202
+ function asTui(tui: TestTui): TUI {
203
+ const boundary: TestTuiBoundary = tui;
204
+ return boundary as TUI;
205
+ }
206
+
207
+ const theme = {
208
+ name: "bridge",
209
+ getColorMode: () => "truecolor",
210
+ getFgAnsi: () => "\x1b[39m",
211
+ getBgAnsi: () => "\x1b[49m",
212
+ bold: (text: string) => text,
213
+ } as never as Theme;
214
+
215
+ function viewRegion(
216
+ options: { initialMode?: "preview" | "full"; component?: Component; onModeChange?(mode: string): void } = {},
217
+ ): ToolViewRegion {
218
+ const component = options.component ?? new LinesComponent(["body"]);
219
+ return new ToolViewRegion({
220
+ theme,
221
+ initialMode: options.initialMode,
222
+ requestRender() {},
223
+ onModeChange: options.onModeChange,
224
+ modes: [
225
+ { id: "preview", component: new LinesComponent([]), nextHint: "expand", activate: "full" },
226
+ { id: "full", component, nextHint: "collapse", activate: "preview" },
227
+ ],
228
+ });
229
+ }
230
+
231
+ function disclosureLayout(action: ToolDisclosureAction, region: ToolViewRegion): TestBox {
232
+ const stack = new ComponentStack([action, region]);
233
+ const lines = stack.render(20);
234
+ const children = stack.getSpans().map((span) => box(span.component, [], 0, span.row, span.width, span.height));
235
+ return box(stack, children, 0, 0, 20, lines.length);
236
+ }
237
+
238
+ function disclosureComponents(): { action: ToolDisclosureAction; region: ToolViewRegion } {
239
+ const output = new ToolOutput({
240
+ theme,
241
+ viewport: { maxRows: 3, selection: "head-tail" },
242
+ });
243
+ output.replace({ text: "first\nsecond\nthird\nfourth", revision: 1 });
244
+ const region = new ToolViewRegion({
245
+ theme,
246
+ requestRender() {},
247
+ modes: [
248
+ { id: "preview", component: output, activationRow: "omission", activate: "full" },
249
+ { id: "full", component: output, activate: "preview" },
250
+ ],
251
+ });
252
+ return {
253
+ region,
254
+ action: new ToolDisclosureAction(theme, new LinesComponent(["action"]), region, () => {}),
255
+ };
256
+ }
257
+
258
+ function diffDisclosureComponents(): {
259
+ action: ToolDisclosureAction;
260
+ region: ToolViewRegion;
261
+ view: UnifiedDiffView;
262
+ } {
263
+ const view = new UnifiedDiffView({
264
+ theme: tuiTheme(theme),
265
+ model: parseUnifiedDiff(
266
+ ["--- a/x", "+++ b/x", "@@ -1,5 +1,5 @@", " one", " two", " three", " four", " five"].join("\n"),
267
+ ),
268
+ viewport: { maxRows: 3, selection: "head-tail" },
269
+ });
270
+ const region = new ToolViewRegion({
271
+ theme,
272
+ requestRender() {},
273
+ modes: [
274
+ { id: "preview", component: view, activationRow: "omission", activate: "full" },
275
+ { id: "full", component: view, activate: "preview" },
276
+ ],
277
+ });
278
+ return {
279
+ region,
280
+ view,
281
+ action: new ToolDisclosureAction(theme, new LinesComponent(["action"]), region, () => {}),
282
+ };
283
+ }
284
+
285
+ describe("mouse bridge", () => {
286
+ test("registers ComponentStack rows as independent hover targets", () => {
287
+ const rows = [new TestComponent(() => true), new TestComponent(() => true), new TestComponent(() => true)];
288
+ const stack = new ComponentStack(rows);
289
+ stack.render(20);
290
+ const tui = createTui(box(stack, [], 0, 0, 20, rows.length));
291
+ const dispose = installMouseBridge(asTui(tui), registry());
292
+
293
+ expect(input(tui, "\x1b[<35;1;1M")).toEqual({ consume: true });
294
+ expect(input(tui, "\x1b[<35;1;2M")).toEqual({ consume: true });
295
+ expect(input(tui, "\x1b[<35;1;3M")).toEqual({ consume: true });
296
+ expect(tui.renders).toBe(3);
297
+ expect(rows.map((row) => row.events.map((event) => event.type))).toEqual([
298
+ ["enter", "leave"],
299
+ ["enter", "leave"],
300
+ ["enter"],
301
+ ]);
302
+ dispose();
303
+ });
304
+
305
+ test("keeps view-mode clicks selection-safe and routes keys after viewport handlers", () => {
306
+ let expandedChanges = 0;
307
+ const region = viewRegion({ onModeChange: () => expandedChanges++ });
308
+ region.render(20);
309
+ const prototype = createPrototype();
310
+ prototype.handleViewportInput = function (this: TestTui, data) {
311
+ this.nativeInputs.push(data);
312
+ if (data.endsWith("M") && data.startsWith("\x1b[<0;")) this.selectionPressActive = true;
313
+ if (data.endsWith("m")) this.selectionPressActive = false;
314
+ return { data: `native:${data}` };
315
+ };
316
+ const tui = createTui(box(region, [], 0, 0, 20, 2), prototype);
317
+ const mouseRegistry = registry();
318
+ let modalKeys = 0;
319
+ let overlayPresses = 0;
320
+ mouseRegistry.registerOverlayRegion({
321
+ id: "copy-mode-cursor",
322
+ priority: 1_000,
323
+ getRect: () => ({ x: 0, y: 0, width: 20, height: 2 }),
324
+ onMouse(event) {
325
+ if (event.type === "press") overlayPresses++;
326
+ return false;
327
+ },
328
+ });
329
+ mouseRegistry.registerViewportInputHandler({
330
+ id: "copy-mode",
331
+ priority: 1_000,
332
+ handle(data) {
333
+ if (data !== "modal") return undefined;
334
+ modalKeys++;
335
+ return { consume: true };
336
+ },
337
+ });
338
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
339
+
340
+ expect(input(tui, "\x1b[<0;2;1M")).toEqual({ data: "native:\x1b[<0;2;1M" });
341
+ expect(input(tui, "\x1b[<0;2;1m")).toEqual({ data: "native:\x1b[<0;2;1m" });
342
+ expect(region.getMode()).toBe("full");
343
+ expect(overlayPresses).toBe(1);
344
+ expect(input(tui, "modal")).toEqual({ consume: true, data: "modal" });
345
+ expect(modalKeys).toBe(1);
346
+ expect(region.getMode()).toBe("full");
347
+ // Enter only activates a folded region. Expanded content leaves the key
348
+ // available to the native viewport handler, just like an expanded primary
349
+ // click leaves the region open.
350
+ expect(input(tui, "\r")).toEqual({ data: "native:\r" });
351
+ expect(region.getMode()).toBe("full");
352
+
353
+ input(tui, "\x1b[<0;2;1M");
354
+ input(tui, "\x1b[<32;6;1M");
355
+ input(tui, "\x1b[<0;6;1m");
356
+ expect(region.getMode()).toBe("full");
357
+ expect(expandedChanges).toBe(1);
358
+ expect(overlayPresses).toBe(2);
359
+ expect(tui.nativeInputs.filter((data) => data.startsWith("\x1b[<"))).toHaveLength(5);
360
+
361
+ tui.currentLayout = undefined;
362
+ expect(input(tui, "\r")).toEqual({ data: "native:\r" });
363
+ dispose();
364
+ });
365
+
366
+ test("passes unsupported mouse packets directly to native input", () => {
367
+ const tui = createTui(box(new TestComponent(() => false)));
368
+ const mouseRegistry = registry();
369
+ const viewportInputs: string[] = [];
370
+ mouseRegistry.registerViewportInputHandler({
371
+ id: "keyboard-owner",
372
+ handle(data) {
373
+ viewportInputs.push(data);
374
+ return { consume: true };
375
+ },
376
+ });
377
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
378
+ const unsupportedSgrWheel = "\x1b[<66;2;1M";
379
+ const unsupportedX10Press = "\x1b[M !!";
380
+
381
+ expect(input(tui, unsupportedSgrWheel)).toEqual({ data: `native:${unsupportedSgrWheel}` });
382
+ expect(input(tui, unsupportedX10Press)).toEqual({ data: `native:${unsupportedX10Press}` });
383
+ expect(viewportInputs).toEqual([]);
384
+ dispose();
385
+ });
386
+
387
+ test("routes omission, disclosure header, and expanded body through current layout boxes", () => {
388
+ const { action, region } = disclosureComponents();
389
+ const prototype = createPrototype();
390
+ prototype.handleViewportInput = function (this: TestTui, data) {
391
+ this.nativeInputs.push(data);
392
+ if (data.endsWith("M") && data.startsWith("\x1b[<0;")) this.selectionPressActive = true;
393
+ if (data.endsWith("m")) this.selectionPressActive = false;
394
+ return { data: `native:${data}` };
395
+ };
396
+ const tui = createTui(disclosureLayout(action, region), prototype);
397
+ const dispose = installMouseBridge(asTui(tui), registry());
398
+
399
+ // The current preview puts its activation control on the middle omission row:
400
+ // action y=0, preview rows y=1..3, omission y=2 (terminal coordinates are +1).
401
+ expect(input(tui, "\x1b[<0;2;3M")).toEqual({ data: "native:\x1b[<0;2;3M" });
402
+ expect(input(tui, "\x1b[<0;2;3m")).toEqual({ data: "native:\x1b[<0;2;3m" });
403
+ expect(region.getMode()).toBe("full");
404
+
405
+ // A stale release after the header press cannot dispatch through the old action box.
406
+ tui.currentLayout = { root: disclosureLayout(action, region) };
407
+ expect(input(tui, "\x1b[<0;2;1M")).toEqual({ data: "native:\x1b[<0;2;1M" });
408
+ tui.currentLayout = { root: box(new LinesComponent(["replacement"]), [], 0, 0, 20, 1) };
409
+ expect(input(tui, "\x1b[<0;2;1m")).toEqual({ data: "native:\x1b[<0;2;1m" });
410
+ expect(region.getMode()).toBe("full");
411
+
412
+ // With the real action row present, an expanded primary header click folds.
413
+ tui.currentLayout = { root: disclosureLayout(action, region) };
414
+ expect(input(tui, "\x1b[<0;2;1M")).toEqual({ data: "native:\x1b[<0;2;1M" });
415
+ expect(input(tui, "\x1b[<0;2;1m")).toEqual({ data: "native:\x1b[<0;2;1m" });
416
+ expect(region.getMode()).toBe("preview");
417
+
418
+ // Re-open through the omission row, then leave an expanded body primary gesture to native selection.
419
+ tui.currentLayout = { root: disclosureLayout(action, region) };
420
+ input(tui, "\x1b[<0;2;3M");
421
+ input(tui, "\x1b[<0;2;3m");
422
+ expect(region.getMode()).toBe("full");
423
+ tui.currentLayout = { root: disclosureLayout(action, region) };
424
+ expect(input(tui, "\x1b[<0;2;2M")).toEqual({ data: "native:\x1b[<0;2;2M" });
425
+ tui.currentLayout = { root: box(new LinesComponent(["replacement"]), [], 0, 0, 20, 1) };
426
+ expect(input(tui, "\x1b[<0;2;2m")).toEqual({ data: "native:\x1b[<0;2;2m" });
427
+ expect(region.getMode()).toBe("full");
428
+
429
+ tui.currentLayout = { root: disclosureLayout(action, region) };
430
+ expect(input(tui, "\x1b[<2;2;1M")).toEqual({ consume: true });
431
+ expect(input(tui, "\x1b[<2;2;1m")).toEqual({ consume: true });
432
+ expect(region.getMode()).toBe("preview");
433
+ dispose();
434
+ });
435
+
436
+ test("routes diff omission hover across its gutter and body", () => {
437
+ const { action, region, view } = diffDisclosureComponents();
438
+ const root = disclosureLayout(action, region);
439
+ const tui = createTui(root);
440
+ const dispose = installMouseBridge(asTui(tui), registry());
441
+ const colors = tuiTheme(theme);
442
+ const omissionRow = view.getOmissionRow();
443
+ expect(omissionRow).toBeDefined();
444
+ const collapsed = region.render(20);
445
+ expect(collapsed[omissionRow!]).toContain(colors.bgAnsi("diff.hunk"));
446
+ expect(collapsed[omissionRow!]).toContain(colors.bgAnsi("diff.hunkGutter"));
447
+
448
+ // The action occupies screen row 0, so the omission is one-based at row + 2.
449
+ const screenRow = omissionRow! + 2;
450
+ expect(input(tui, `\x1b[<35;1;${screenRow}M`)).toEqual({ consume: true });
451
+ const gutterHovered = region.render(20)[omissionRow!];
452
+ expect(gutterHovered).toContain(colors.bgAnsi("diff.hunkHover"));
453
+ expect(gutterHovered).toContain(colors.bgAnsi("diff.hunkGutterHover"));
454
+
455
+ // Move outside the transcript, then back over the last body cell of the same row.
456
+ input(tui, `\x1b[<35;21;${screenRow}M`);
457
+ expect(input(tui, `\x1b[<35;20;${screenRow}M`)).toEqual({ consume: true });
458
+ const bodyHovered = region.render(20)[omissionRow!];
459
+ expect(bodyHovered).toContain(colors.bgAnsi("diff.hunkHover"));
460
+ expect(bodyHovered).toContain(colors.bgAnsi("diff.hunkGutterHover"));
461
+
462
+ input(tui, `\x1b[<35;21;${screenRow}M`);
463
+ expect(region.render(20)[omissionRow!]).toEqual(collapsed[omissionRow!]);
464
+ region.dispose();
465
+ dispose();
466
+ });
467
+
468
+ test("typing after clicking a disclosure returns Enter to the editor", () => {
469
+ const region = viewRegion();
470
+ const action = new ToolDisclosureAction(theme, new LinesComponent(["activity"]), region, () => {});
471
+ const tui = createTui(disclosureLayout(action, region));
472
+ const dispose = installMouseBridge(asTui(tui), registry());
473
+ input(tui, "\x1b[<0;2;1M");
474
+ input(tui, "\x1b[<0;2;1m");
475
+ expect(region.getMode()).toBe("full");
476
+ tui.currentLayout = { root: disclosureLayout(action, region) };
477
+ expect(input(tui, "\r")).toEqual({ consume: true, data: "\r" });
478
+ expect(region.getMode()).toBe("preview");
479
+ tui.currentLayout = { root: disclosureLayout(action, region) };
480
+ expect(input(tui, "/reload")).toEqual({ data: "native:/reload" });
481
+ expect(region.isViewportFocused()).toBe(false);
482
+ expect(input(tui, "\r")).toEqual({ data: "native:\r" });
483
+ expect(region.getMode()).toBe("preview");
484
+ action.dispose();
485
+ dispose();
486
+ });
487
+
488
+ test("clears transcript keyboard focus for overlays, Escape, and clipped targets", () => {
489
+ const disclosure = viewRegion();
490
+ disclosure.render(20);
491
+ const prototype = createPrototype();
492
+ prototype.handleViewportInput = function (this: TestTui, data) {
493
+ this.nativeInputs.push(data);
494
+ if (data.endsWith("M") && data.startsWith("\x1b[<0;")) this.selectionPressActive = true;
495
+ if (data.endsWith("m")) this.selectionPressActive = false;
496
+ return { data: `native:${data}` };
497
+ };
498
+ const root = box(disclosure, [], 0, 0, 20, 2);
499
+ const tui = createTui(root, prototype);
500
+ const dispose = installMouseBridge(asTui(tui), registry());
501
+ const clickRegion = () => {
502
+ input(tui, "\x1b[<0;2;1M");
503
+ input(tui, "\x1b[<0;2;1m");
504
+ };
505
+
506
+ clickRegion();
507
+ expect(disclosure.getMode()).toBe("full");
508
+ tui.shouldDeferViewportInputToOverlay = () => true;
509
+ expect(input(tui, "\r")).toEqual({ data: "native:\r" });
510
+ expect(disclosure.getMode()).toBe("full");
511
+ tui.shouldDeferViewportInputToOverlay = () => false;
512
+ expect(input(tui, "\r")).toEqual({ data: "native:\r" });
513
+
514
+ clickRegion();
515
+ // A primary click in expanded content must not collapse the region.
516
+ expect(disclosure.getMode()).toBe("full");
517
+ expect(input(tui, "\x1b")).toEqual({ data: "native:\x1b" });
518
+ expect(input(tui, "\r")).toEqual({ data: "native:\r" });
519
+
520
+ // Secondary body clicks fold without claiming primary selection gestures.
521
+ expect(input(tui, "\x1b[<2;2;1M")).toEqual({ consume: true });
522
+ expect(input(tui, "\x1b[<2;2;1m")).toEqual({ consume: true });
523
+ expect(disclosure.getMode()).toBe("preview");
524
+ root.clip.height = 0;
525
+ expect(input(tui, "\r")).toEqual({ data: "native:\r" });
526
+ expect(disclosure.getMode()).toBe("preview");
527
+ dispose();
528
+ });
529
+
530
+ test("dispatches innermost first and delegates when no component handles", () => {
531
+ const order: string[] = [];
532
+ const child = new TestComponent(() => {
533
+ order.push("child");
534
+ return false;
535
+ });
536
+ const parent = new TestComponent(() => {
537
+ order.push("parent");
538
+ return true;
539
+ });
540
+ const tui = createTui(box(parent, [box(child, [], 2, 2, 5, 3)]));
541
+ const dispose = installMouseBridge(asTui(tui), registry());
542
+
543
+ expect(input(tui, "\x1b[<0;4;4M")).toEqual({ consume: true });
544
+ expect(order).toEqual(["child", "parent"]);
545
+ expect(parent.events[0]).toMatchObject({ type: "press", row: 3, col: 3, button: 0 });
546
+
547
+ expect(input(tui, "ordinary-key")).toEqual({ data: "native:ordinary-key" });
548
+ expect(tui.nativeInputs).toEqual(["ordinary-key"]);
549
+ dispose();
550
+ });
551
+
552
+ test("dispatches viewport keyboard handlers after mouse parsing and before Pi keybindings", () => {
553
+ const tui = createTui();
554
+ const mouseRegistry = registry();
555
+ const handled: string[] = [];
556
+ mouseRegistry.registerViewportInputHandler({
557
+ id: "transform",
558
+ priority: 10,
559
+ handle(data) {
560
+ handled.push(data);
561
+ return data === "a" ? { data: "b" } : data === "consume" ? { consume: true } : undefined;
562
+ },
563
+ });
564
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
565
+
566
+ expect(input(tui, "a")).toEqual({ data: "native:b" });
567
+ expect(input(tui, "consume")).toEqual({ consume: true, data: "consume" });
568
+ input(tui, "\x1b[I");
569
+ input(tui, "\x1b[O");
570
+ input(tui, "\x1b[<0;2;1M");
571
+ expect(handled).toEqual(["a", "consume"]);
572
+ expect(tui.nativeInputs).toEqual(["b", "\x1b[I", "\x1b[O", "\x1b[<0;2;1M"]);
573
+ dispose();
574
+ });
575
+
576
+ test("respects clips and isolates a throwing callback", () => {
577
+ const throwing = new TestComponent(() => {
578
+ throw new Error("broken component");
579
+ });
580
+ const parent = new TestComponent(() => false);
581
+ const clipped = box(throwing, [], 2, 2, 5, 3);
582
+ clipped.clip = { x: 10, y: 10, width: 1, height: 1 };
583
+ const tui = createTui(box(parent, [clipped]));
584
+ const dispose = installMouseBridge(asTui(tui), registry());
585
+
586
+ expect(input(tui, "\x1b[<0;4;4M")).toEqual({ data: "native:\x1b[<0;4;4M" });
587
+ expect(throwing.events).toHaveLength(0);
588
+ expect(parent.events).toHaveLength(1);
589
+
590
+ clipped.clip = clipped.rect;
591
+ expect(() => input(tui, "\x1b[<0;4;4M")).not.toThrow();
592
+ expect(tui.nativeInputs).toHaveLength(2);
593
+ dispose();
594
+ });
595
+
596
+ test("captures drag and release after a handled press", () => {
597
+ const component = new TestComponent(() => true);
598
+ const tui = createTui(box(component, [], 0, 0, 5, 2));
599
+ const dispose = installMouseBridge(asTui(tui), registry());
600
+
601
+ expect(input(tui, "\x1b[<0;2;1M")).toEqual({ consume: true });
602
+ expect(input(tui, "\x1b[<32;20;10M")).toEqual({ consume: true });
603
+ expect(input(tui, "\x1b[<0;20;10m")).toEqual({ consume: true });
604
+ expect(component.events.map((event) => event.type)).toEqual(["press", "drag", "release"]);
605
+ expect(component.events[1]).toMatchObject({ row: 9, col: 19 });
606
+ expect(tui.nativeInputs).toEqual([]);
607
+ dispose();
608
+ });
609
+
610
+ test("derives hit boxes for Container children absent from currentLayout", () => {
611
+ const target = new TestComponent(() => true);
612
+ const widgetContainer = new Container();
613
+ widgetContainer.addChild(new Spacer(1));
614
+ widgetContainer.addChild(new LinesComponent([]));
615
+ widgetContainer.addChild(target);
616
+ const tui = createTui(box(widgetContainer, [], 0, 17, 20, 2));
617
+ const dispose = installMouseBridge(asTui(tui), registry());
618
+
619
+ expect(input(tui, "\x1b[<0;3;19M")).toEqual({ consume: true });
620
+ expect(target.events[0]).toMatchObject({ type: "press", row: 0, col: 2, screenRow: 18 });
621
+ expect(tui.nativeInputs).toEqual([]);
622
+ dispose();
623
+ });
624
+
625
+ test("measures a flattened mouse target only once per dispatch", () => {
626
+ const target = new RenderCountingMouseComponent(() => true);
627
+ const document = new Container();
628
+ document.addChild(target);
629
+ const tui = createTui(box(document, [], 0, 0, 20, 1));
630
+ const dispose = installMouseBridge(asTui(tui), registry());
631
+
632
+ expect(input(tui, "\x1b[<64;2;1M")).toEqual({ consume: true });
633
+ expect(target.renders).toBe(1);
634
+ dispose();
635
+ });
636
+
637
+ test("does not render flattened containers with no mouse descendants on hover", () => {
638
+ const transcript = new RenderCountingComponent();
639
+ const document = new Container();
640
+ document.addChild(transcript);
641
+ const tui = createTui(box(document, [], 0, 0, 80, 20));
642
+ const dispose = installMouseBridge(asTui(tui), registry());
643
+
644
+ expect(input(tui, "\x1b[<35;2;1M")).toEqual({ data: "native:\x1b[<35;2;1M" });
645
+ expect(transcript.renders).toBe(0);
646
+ dispose();
647
+ });
648
+
649
+ test("uses cached tool geometry without rerendering transcript children during interaction", () => {
650
+ const transcript = new RenderCountingComponent();
651
+ const surface = new ToolViewRegion({
652
+ theme,
653
+ requestRender() {},
654
+ modes: [
655
+ { id: "preview", component: transcript, nextHint: "expand", activate: "full" },
656
+ { id: "full", component: transcript, nextHint: "collapse", activate: "preview" },
657
+ ],
658
+ });
659
+ const wrapper = new ComponentStack([surface]);
660
+ const lines = wrapper.render(20);
661
+ expect(transcript.renders).toBe(1);
662
+ const tui = createTui(box(wrapper, [], 0, 0, 20, lines.length));
663
+ const dispose = installMouseBridge(asTui(tui), registry());
664
+
665
+ input(tui, "\x1b[<35;2;2M");
666
+ input(tui, "\x1b[<0;2;2M");
667
+ input(tui, "\x1b[<0;2;2m");
668
+ input(tui, "\r");
669
+
670
+ expect(transcript.renders).toBe(1);
671
+ dispose();
672
+ });
673
+
674
+ test("dispatches a non-consuming overlay observer once for a selectable body press", () => {
675
+ const transcript = new LinesComponent(["select me"]);
676
+ const tui = createTui(box(transcript, [], 0, 0, 20, 2));
677
+ const mouseRegistry = registry();
678
+ let presses = 0;
679
+ mouseRegistry.registerOverlayRegion({
680
+ id: "copy-mode-cursor",
681
+ getRect: () => ({ x: 0, y: 0, width: 20, height: 2 }),
682
+ onMouse(event) {
683
+ if (event.type === "press") presses++;
684
+ return false;
685
+ },
686
+ });
687
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
688
+
689
+ expect(input(tui, "\x1b[<0;2;2M")).toEqual({ data: "native:\x1b[<0;2;2M" });
690
+ expect(presses).toBe(1);
691
+ expect(tui.nativeInputs).toEqual(["\x1b[<0;2;2M"]);
692
+ dispose();
693
+ });
694
+
695
+ test("resolves captured component geometry from the current layout", () => {
696
+ const component = new TestComponent(() => true);
697
+ const root = box(component, [], 0, 0, 5, 2);
698
+ const tui = createTui(root);
699
+ const dispose = installMouseBridge(asTui(tui), registry());
700
+
701
+ input(tui, "\x1b[<0;2;1M");
702
+ root.rect.y = 5;
703
+ root.clip.y = 5;
704
+ input(tui, "\x1b[<32;2;7M");
705
+
706
+ expect(component.events[1]).toMatchObject({ type: "drag", row: 1, col: 1 });
707
+ dispose();
708
+ });
709
+
710
+ test("releases capture and hover on focus-out", () => {
711
+ const component = new TestComponent(() => true);
712
+ const tui = createTui(box(component));
713
+ const dispose = installMouseBridge(asTui(tui), registry());
714
+
715
+ input(tui, "\x1b[<35;2;1M");
716
+ input(tui, "\x1b[<0;2;1M");
717
+ const rendersBeforeFocusOut = tui.renders;
718
+ expect(input(tui, "\x1b[O")).toEqual({ data: "native:\x1b[O" });
719
+
720
+ expect(component.events.map((event) => event.type)).toEqual(["enter", "press", "release", "leave"]);
721
+ expect(tui.renders).toBeGreaterThan(rendersBeforeFocusOut);
722
+ dispose();
723
+ });
724
+
725
+ test("cancels a selection-safe text click on focus-out", () => {
726
+ const region = viewRegion();
727
+ region.render(20);
728
+ const events: string[] = [];
729
+ const onMouse = region.onMouse.bind(region);
730
+ region.onMouse = (event) => {
731
+ events.push(event.type);
732
+ return onMouse(event);
733
+ };
734
+ const tui = createTui(box(region, [], 0, 0, 20, 2));
735
+ const dispose = installMouseBridge(asTui(tui), registry());
736
+
737
+ input(tui, "\x1b[<0;2;1M");
738
+ const rendersBeforeFocusOut = tui.renders;
739
+ input(tui, "\x1b[O");
740
+
741
+ expect(events).toEqual(["press", "release"]);
742
+ expect(region.getMode()).toBe("preview");
743
+ expect(tui.renders).toBeGreaterThan(rendersBeforeFocusOut);
744
+ dispose();
745
+ });
746
+
747
+ test("leaves a hovered component when motion exits its clip", () => {
748
+ const component = new TestComponent(() => true);
749
+ const target = box(component, [], 0, 0, 10, 2);
750
+ target.clip.width = 3;
751
+ const tui = createTui(target);
752
+ const dispose = installMouseBridge(asTui(tui), registry());
753
+
754
+ input(tui, "\x1b[<35;2;1M");
755
+ input(tui, "\x1b[<35;6;1M");
756
+
757
+ expect(component.events.map((event) => event.type)).toEqual(["enter", "leave"]);
758
+ dispose();
759
+ });
760
+
761
+ test("does not steal a drag after Pi starts native selection", () => {
762
+ const component = new TestComponent(() => true);
763
+ const tui = createTui(box(component));
764
+ const dispose = installMouseBridge(asTui(tui), registry());
765
+ tui.selectionPressActive = true;
766
+
767
+ expect(input(tui, "\x1b[<32;2;1M")).toEqual({ data: "native:\x1b[<32;2;1M" });
768
+ expect(component.events).toEqual([]);
769
+ dispose();
770
+ });
771
+
772
+ test("publishes normalized native selection metadata after unhandled release", () => {
773
+ const scrollView = { scrollTop: 5 };
774
+ const root = box(new LinesComponent([]), [], 0, 2, 40, 10);
775
+ root.scrollView = scrollView;
776
+ root.scrollContentLines = [
777
+ "cdef",
778
+ "middle",
779
+ "uvwxy",
780
+ "x".repeat(100),
781
+ "four",
782
+ "five",
783
+ "abcdef",
784
+ "\x1b[31mmiddle\x1b[0m",
785
+ "uvwxyz",
786
+ ];
787
+ const prototype = createPrototype();
788
+ prototype.handleViewportInput = function (this: TestTui, data) {
789
+ this.nativeInputs.push(data);
790
+ if (data.startsWith("\x1b[<0;") && data.endsWith("m")) this.selectionPressActive = false;
791
+ return { data: `native:${data}` };
792
+ };
793
+ const tui = createTui(root, prototype);
794
+ tui.selectionPressActive = true;
795
+ tui.selectionAnchor = { row: 8, col: 4, scrollView };
796
+ tui.selectionFocus = { row: 6, col: 2, scrollView };
797
+ const mouseRegistry = registry();
798
+ const selections: object[] = [];
799
+ const removeSelection = ensureSelectionRegistry().onSelectionCompleted((selection) => {
800
+ expect(tui.nativeInputs).toHaveLength(1);
801
+ selections.push(selection);
802
+ });
803
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
804
+
805
+ expect(input(tui, "\x1b[<0;5;6m")).toEqual({ data: "native:\x1b[<0;5;6m" });
806
+ expect(selections).toEqual([
807
+ {
808
+ text: "cdef\nmiddle\nuvwxy",
809
+ shape: "character",
810
+ logical: { start: { row: 6, col: 2 }, end: { row: 8, col: 4 } },
811
+ screen: { start: { row: 3, col: 2 }, end: { row: 5, col: 4 } },
812
+ source: {
813
+ quote: {
814
+ exact: "cdef\nmiddle\nuvwxy",
815
+ prefix: `${"x".repeat(67)}\nfour\nfive\nab`,
816
+ suffix: "z",
817
+ },
818
+ },
819
+ },
820
+ ]);
821
+ expect((selections[0] as NativeSelectionCompleted).source?.quote?.prefix).toHaveLength(80);
822
+ dispose();
823
+ removeSelection();
824
+ });
825
+
826
+ test("ignores native releases that do not end the active selection", () => {
827
+ const scrollView = { scrollTop: 0 };
828
+ const root = box(new LinesComponent([]), [], 0, 0, 20, 10);
829
+ root.scrollView = scrollView;
830
+ const tui = createTui(root);
831
+ tui.selectionPressActive = true;
832
+ tui.selectionAnchor = { row: 1, col: 1, scrollView };
833
+ tui.selectionFocus = { row: 2, col: 2, scrollView };
834
+ const mouseRegistry = registry();
835
+ const selections: object[] = [];
836
+ const removeSelection = ensureSelectionRegistry().onSelectionCompleted((selection) => selections.push(selection));
837
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
838
+
839
+ input(tui, "\x1b[<1;2;2m");
840
+ input(tui, "\x1b[<2;2;2m");
841
+ expect(tui.selectionPressActive).toBe(true);
842
+ expect(selections).toEqual([]);
843
+ dispose();
844
+ removeSelection();
845
+ });
846
+
847
+ test("conditionally suppresses native auto-copy only around the selection release", () => {
848
+ let copies = 0;
849
+ const scrollView = { scrollTop: 0 };
850
+ const root = box(new LinesComponent([]), [], 0, 0, 20, 10);
851
+ root.scrollView = scrollView;
852
+ const prototype = createPrototype();
853
+ prototype.handleViewportInput = function (this: TestTui, data) {
854
+ this.nativeInputs.push(data);
855
+ if (data.startsWith("\x1b[<0;") && data.endsWith("m")) {
856
+ this.selectionPressActive = false;
857
+ this.copySelectionToClipboard?.();
858
+ }
859
+ return { data: `native:${data}` };
860
+ };
861
+ const tui = createTui(root, prototype);
862
+ const nativeCopy = () => (copies += 1);
863
+ tui.copySelectionToClipboard = nativeCopy;
864
+ tui.selectionPressActive = true;
865
+ tui.selectionAnchor = { row: 1, col: 1, scrollView };
866
+ tui.selectionFocus = { row: 2, col: 2, scrollView };
867
+ const mouseRegistry = registry();
868
+ mouseRegistry.registerNativeCopyDeferrer(() => {
869
+ throw new Error("optional deferrer failed");
870
+ });
871
+ const removeDeferrer = mouseRegistry.registerNativeCopyDeferrer(() => true);
872
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
873
+
874
+ input(tui, "\x1b[<0;2;2m");
875
+ expect(copies).toBe(0);
876
+ expect(tui.copySelectionToClipboard).toBe(nativeCopy);
877
+ tui.copySelectionToClipboard();
878
+ expect(copies).toBe(1);
879
+
880
+ removeDeferrer();
881
+ tui.selectionPressActive = true;
882
+ input(tui, "\x1b[<0;2;2m");
883
+ expect(copies).toBe(2);
884
+ dispose();
885
+ });
886
+
887
+ test("preserves native auto-copy when no deferrer is registered", () => {
888
+ let copies = 0;
889
+ const prototype = createPrototype();
890
+ prototype.handleViewportInput = function (this: TestTui, data) {
891
+ this.nativeInputs.push(data);
892
+ if (data.endsWith("m")) {
893
+ this.selectionPressActive = false;
894
+ this.copySelectionToClipboard?.();
895
+ }
896
+ return { data: `native:${data}` };
897
+ };
898
+ const tui = createTui(undefined, prototype);
899
+ tui.copySelectionToClipboard = () => (copies += 1);
900
+ tui.selectionPressActive = true;
901
+ tui.selectionAnchor = { row: 1, col: 1 };
902
+ tui.selectionFocus = { row: 1, col: 2 };
903
+ const dispose = installMouseBridge(asTui(tui), registry());
904
+
905
+ input(tui, "\x1b[<0;2;2m");
906
+ expect(copies).toBe(1);
907
+ dispose();
908
+ });
909
+
910
+ test("offers topmost overlay regions before layout components", () => {
911
+ const component = new TestComponent(() => true);
912
+ const tui = createTui(box(component));
913
+ const mouseRegistry = registry();
914
+ const overlayEvents: TuiMouseEvent[] = [];
915
+ mouseRegistry.registerOverlayRegion({
916
+ id: "annotation-actions",
917
+ getRect: () => ({ x: 1, y: 1, width: 5, height: 2 }),
918
+ onMouse(event) {
919
+ overlayEvents.push(event);
920
+ return true;
921
+ },
922
+ });
923
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
924
+
925
+ expect(input(tui, "\x1b[<64;3;2M")).toEqual({ consume: true });
926
+ expect(overlayEvents[0]).toMatchObject({ type: "wheel", row: 0, col: 1, wheel: -1 });
927
+ expect(component.events).toEqual([]);
928
+ dispose();
929
+ });
930
+
931
+ test("dispatches the focused Pi overlay before its raw input deferral", () => {
932
+ const overlay = new TestComponent(() => true);
933
+ const tui = createTui();
934
+ const entry = { component: overlay, options: { anchor: "top-left" } };
935
+ tui.shouldDeferViewportInputToOverlay = () => true;
936
+ tui.getTopmostVisibleOverlay = () => entry;
937
+ tui.resolveOverlayLayout = (_options, _height, _width, _rows) => ({
938
+ width: 20,
939
+ row: 2,
940
+ col: 4,
941
+ maxHeight: 10,
942
+ });
943
+ const dispose = installMouseBridge(asTui(tui), registry());
944
+
945
+ expect(input(tui, "\x1b[<0;6;3M")).toEqual({ consume: true });
946
+ expect(input(tui, "\x1b[<0;6;3m")).toEqual({ consume: true });
947
+ expect(overlay.events).toHaveLength(2);
948
+ expect(overlay.events[0]).toMatchObject({ type: "press", row: 0, col: 1, screenRow: 2, screenCol: 5 });
949
+ expect(tui.nativeInputs).toEqual([]);
950
+ dispose();
951
+ });
952
+
953
+ test("does not rerender a focused overlay during stationary motion", () => {
954
+ const overlay = new RenderCountingMouseComponent(() => true);
955
+ const tui = createTui();
956
+ tui.shouldDeferViewportInputToOverlay = () => true;
957
+ tui.getTopmostVisibleOverlay = () => ({ component: overlay, options: {} });
958
+ tui.resolveOverlayLayout = () => ({ width: 20, row: 0, col: 0, maxHeight: 2 });
959
+ const dispose = installMouseBridge(asTui(tui), registry());
960
+
961
+ for (let index = 0; index < 10_000; index += 1) input(tui, "\x1b[<35;2;1M");
962
+
963
+ expect(overlay.renders).toBe(1);
964
+ expect(overlay.events).toHaveLength(10_000);
965
+ dispose();
966
+ });
967
+
968
+ test("drops cached focused-overlay geometry when the topmost overlay changes", () => {
969
+ const first = new RenderCountingMouseComponent(() => true);
970
+ const second = new RenderCountingMouseComponent(() => true);
971
+ let current = first;
972
+ const tui = createTui();
973
+ tui.shouldDeferViewportInputToOverlay = () => true;
974
+ tui.getTopmostVisibleOverlay = () => ({ component: current, options: {} });
975
+ tui.resolveOverlayLayout = () => ({ width: 20, row: 0, col: 0, maxHeight: 2 });
976
+ const dispose = installMouseBridge(asTui(tui), registry());
977
+
978
+ input(tui, "\x1b[<35;2;1M");
979
+ current = second;
980
+ input(tui, "\x1b[<35;2;1M");
981
+
982
+ expect(first.events.map((event) => event.type)).toEqual(["enter", "leave"]);
983
+ expect(second.events.map((event) => event.type)).toEqual(["enter"]);
984
+ expect(first.renders).toBe(1);
985
+ expect(second.renders).toBe(1);
986
+ dispose();
987
+ });
988
+
989
+ test("orders overlay hits by priority then latest registration", () => {
990
+ const tui = createTui();
991
+ const mouseRegistry = registry();
992
+ const hits: string[] = [];
993
+ const register = (id: string, priority: number) =>
994
+ mouseRegistry.registerOverlayRegion({
995
+ id,
996
+ priority,
997
+ getRect: () => ({ x: 0, y: 0, width: 2, height: 2 }),
998
+ onMouse() {
999
+ hits.push(id);
1000
+ return id === "high" || id === "new-equal";
1001
+ },
1002
+ });
1003
+ register("high", 10);
1004
+ register("old-equal", 0);
1005
+ register("new-equal", 0);
1006
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
1007
+
1008
+ input(tui, "\x1b[<0;1;1M");
1009
+ expect(hits).toEqual(["high"]);
1010
+ getMouseRegistryState(mouseRegistry).regions.find((region) => region.id === "high")!.onMouse = () => false;
1011
+ hits.length = 0;
1012
+ input(tui, "\x1b[<0;1;1M");
1013
+ expect(hits).toEqual(["new-equal"]);
1014
+ dispose();
1015
+ });
1016
+
1017
+ test("drops cached hover after an overlay region unregisters", () => {
1018
+ const tui = createTui();
1019
+ const mouseRegistry = registry();
1020
+ const events: string[] = [];
1021
+ const unregister = mouseRegistry.registerOverlayRegion({
1022
+ id: "temporary",
1023
+ getRect: () => ({ x: 0, y: 0, width: 4, height: 2 }),
1024
+ onMouse(event) {
1025
+ events.push(event.type);
1026
+ return true;
1027
+ },
1028
+ });
1029
+ const dispose = installMouseBridge(asTui(tui), mouseRegistry);
1030
+
1031
+ input(tui, "\x1b[<35;2;1M");
1032
+ unregister();
1033
+ input(tui, "\x1b[<35;2;1M");
1034
+
1035
+ expect(events).toEqual(["enter", "leave"]);
1036
+ dispose();
1037
+ });
1038
+
1039
+ test("emits hover enter, move, and leave when all-motion reports arrive", () => {
1040
+ const first = new TestComponent(() => true);
1041
+ const second = new TestComponent(() => true);
1042
+ const root = new TestComponent(() => false);
1043
+ const tui = createTui(box(root, [box(first, [], 0, 0, 5, 2), box(second, [], 5, 0, 5, 2)]));
1044
+ const dispose = installMouseBridge(asTui(tui), registry());
1045
+
1046
+ input(tui, "\x1b[<35;2;1M");
1047
+ input(tui, "\x1b[<35;3;1M");
1048
+ input(tui, "\x1b[<35;7;1M");
1049
+
1050
+ expect(first.events.map((event) => event.type)).toEqual(["enter", "move", "leave"]);
1051
+ expect(second.events.map((event) => event.type)).toEqual(["enter"]);
1052
+ dispose();
1053
+ });
1054
+
1055
+ test("keeps stationary hover storms bounded", () => {
1056
+ const component = new TestComponent(() => true);
1057
+ const tui = createTui(box(component, [], 0, 0, 20, 2));
1058
+ const dispose = installMouseBridge(asTui(tui), registry());
1059
+
1060
+ for (let index = 0; index < 10_000; index += 1) input(tui, "\x1b[<35;2;1M");
1061
+
1062
+ expect(component.events).toHaveLength(10_000);
1063
+ expect(tui.renders).toBe(1);
1064
+ dispose();
1065
+ });
1066
+
1067
+ test("reuses flattened layout targets across unhandled motion", () => {
1068
+ const target = new RenderCountingMouseComponent(() => false);
1069
+ const document = new Container();
1070
+ document.addChild(target);
1071
+ const tui = createTui(box(document, [], 0, 0, 20, 1));
1072
+ const dispose = installMouseBridge(asTui(tui), registry());
1073
+
1074
+ input(tui, "\x1b[<35;2;1M");
1075
+ input(tui, "\x1b[<35;3;1M");
1076
+
1077
+ expect(target.renders).toBe(1);
1078
+ dispose();
1079
+ });
1080
+
1081
+ test("releases capture and hover when the bridge is disposed", () => {
1082
+ const component = new TestComponent(() => true);
1083
+ const tui = createTui(box(component, [], 0, 0, 20, 2));
1084
+ const dispose = installMouseBridge(asTui(tui), registry());
1085
+
1086
+ input(tui, "\x1b[<35;2;1M");
1087
+ input(tui, "\x1b[<0;2;1M");
1088
+ dispose();
1089
+
1090
+ expect(component.events.map((event) => event.type)).toEqual(["enter", "press", "release", "leave"]);
1091
+ });
1092
+
1093
+ test("bubbles hover past an innermost target that declines it", () => {
1094
+ const child = new TestComponent(() => false);
1095
+ const parent = new TestComponent(() => true);
1096
+ const tui = createTui(box(parent, [box(child, [], 0, 0, 5, 2)]));
1097
+ const dispose = installMouseBridge(asTui(tui), registry());
1098
+
1099
+ expect(input(tui, "\x1b[<35;2;1M")).toEqual({ consume: true });
1100
+ expect(child.events.map((event) => event.type)).toEqual(["enter"]);
1101
+ expect(parent.events.map((event) => event.type)).toEqual(["enter"]);
1102
+ dispose();
1103
+ });
1104
+
1105
+ test("installs once on the prototype and survives a replacement renderer", () => {
1106
+ const component = new TestComponent(() => true);
1107
+ const tui = createTui(box(component));
1108
+ const prototype = Object.getPrototypeOf(tui) as TestTuiPrototype;
1109
+ const original = tui.handleViewportInput;
1110
+ const firstDispose = installMouseBridge(asTui(tui), registry());
1111
+ const patched = tui.handleViewportInput;
1112
+ const secondDispose = installMouseBridge(asTui(tui), registry());
1113
+ expect(tui.handleViewportInput).toBe(patched);
1114
+ const replacementComponent = new TestComponent(() => true);
1115
+ const replacement = createTui(box(replacementComponent), prototype);
1116
+ expect(input(replacement, "\x1b[<0;2;1M")).toEqual({ consume: true });
1117
+ expect(replacementComponent.events).toHaveLength(1);
1118
+
1119
+ firstDispose();
1120
+ expect(tui.handleViewportInput).toBe(patched);
1121
+ secondDispose();
1122
+ expect(tui.handleViewportInput).toBe(original);
1123
+ });
1124
+
1125
+ test("publishes one current fullscreen layout capability with scroll and selection mapping", () => {
1126
+ const { tui, scrollView } = createTranscriptTui();
1127
+ const scope = globalThis as Record<PropertyKey, unknown>;
1128
+ Reflect.deleteProperty(scope, FULLSCREEN_LAYOUT_CAPABILITY_KEY);
1129
+ const dispose = installMouseBridge(asTui(tui), registry());
1130
+ const capability = getFullscreenLayoutCapability();
1131
+ expect(capability).toBeDefined();
1132
+ const duplicateDispose = installMouseBridge(asTui(tui), registry());
1133
+ expect(getFullscreenLayoutCapability()).toBe(capability);
1134
+ duplicateDispose();
1135
+ const layout = resolveFullscreenLayout(tui);
1136
+ expect(layout?.viewport).toEqual({ x: 0, y: 0, width: 20, height: 2, scrollTop: 2 });
1137
+ expect(layout?.screenPoint({ row: 2, col: 3 })).toEqual({ row: 0, col: 3 });
1138
+ expect(layout?.point({ row: 2, col: 3 }, true)).toMatchObject({ row: 2, col: 3, scrollView });
1139
+ scrollView.scrollTop = 3;
1140
+ expect(layout?.screenPoint({ row: 3, col: 1 })).toEqual({ row: 0, col: 1 });
1141
+ layout?.setSelection(layout.point({ row: 1, col: 0 }), layout.point({ row: 2, col: 1 }));
1142
+ expect(resolveFullscreenLayout(tui)?.selectionAnchor).toMatchObject({ row: 1, col: 0 });
1143
+ const absentScope = Object.create(null) as typeof globalThis;
1144
+ expect(resolveFullscreenLayout(tui, absentScope)).toBeUndefined();
1145
+ const malformedScope = Object.create(null) as Record<PropertyKey, unknown>;
1146
+ malformedScope[FULLSCREEN_LAYOUT_CAPABILITY_KEY] = {
1147
+ protocol: "pi-libtui/fullscreen-layout/v1",
1148
+ version: 1,
1149
+ resolve: () => ({ malformed: true }),
1150
+ };
1151
+ expect(resolveFullscreenLayout(tui, malformedScope as typeof globalThis)).toBeUndefined();
1152
+ const detachedScope = Object.create(null) as Record<PropertyKey, unknown>;
1153
+ detachedScope[FULLSCREEN_LAYOUT_CAPABILITY_KEY] = {
1154
+ protocol: "pi-libtui/fullscreen-layout/v1",
1155
+ version: 1,
1156
+ resolve: () => (layout ? { ...layout, primaryBox: { ...layout.primaryBox } } : undefined),
1157
+ };
1158
+ expect(resolveFullscreenLayout(tui, detachedScope as typeof globalThis)).toBeUndefined();
1159
+ dispose();
1160
+ expect(getFullscreenLayoutCapability()).toBeUndefined();
1161
+ });
1162
+
1163
+ test("does not clobber a later input patch when disposed", () => {
1164
+ const component = new TestComponent(() => true);
1165
+ const tui = createTui(box(component));
1166
+ const prototype = Object.getPrototypeOf(tui) as TestTuiPrototype;
1167
+ const dispose = installMouseBridge(asTui(tui), registry());
1168
+ const laterPatch = function (this: TestTui, data: string): TuiInputListenerResult {
1169
+ this.nativeInputs.push(`later:${data}`);
1170
+ return { consume: true };
1171
+ };
1172
+ prototype.handleViewportInput = laterPatch;
1173
+ dispose();
1174
+ expect(tui.handleViewportInput).toBe(laterPatch);
1175
+ });
1176
+
1177
+ test("keeps the lease across a later delegating wrapper and reinstall", () => {
1178
+ const component = new TestComponent(() => true);
1179
+ const tui = createTui(box(component));
1180
+ const prototype = Object.getPrototypeOf(tui) as TestTuiPrototype;
1181
+ const original = prototype.handleViewportInput;
1182
+ const firstDispose = installMouseBridge(asTui(tui), registry());
1183
+ const bridge = prototype.handleViewportInput;
1184
+ const wrapper = function (this: TestTui, data: string): TuiInputListenerResult {
1185
+ return Reflect.apply(bridge, this, [data]);
1186
+ };
1187
+ prototype.handleViewportInput = wrapper;
1188
+ firstDispose();
1189
+
1190
+ const secondDispose = installMouseBridge(asTui(tui), registry());
1191
+ expect(prototype.handleViewportInput).toBe(wrapper);
1192
+ input(tui, "\x1b[<0;2;1M");
1193
+ expect(component.events).toHaveLength(1);
1194
+
1195
+ prototype.handleViewportInput = bridge;
1196
+ secondDispose();
1197
+ expect(prototype.handleViewportInput).toBe(original);
1198
+ });
1199
+
1200
+ test("patches the fullscreen prototype when the session starts in regular mode", () => {
1201
+ const regular = createTui();
1202
+ regular.mode = "regular";
1203
+ const fullscreenPrototype = TuiAltScreen.prototype as object;
1204
+ const original = Reflect.get(fullscreenPrototype, "handleViewportInput");
1205
+ const dispose = installMouseBridge(asTui(regular), registry());
1206
+ expect(Reflect.get(fullscreenPrototype, "handleViewportInput")).not.toBe(original);
1207
+ dispose();
1208
+ expect(Reflect.get(fullscreenPrototype, "handleViewportInput")).toBe(original);
1209
+ });
1210
+
1211
+ test("matches Pi's multiplexer heuristic exactly", () => {
1212
+ expect(isPiMultiplexedEnvironment({ TERM: "tmux-256color" })).toBe(true);
1213
+ expect(isPiMultiplexedEnvironment({ TERM: "screen-256color" })).toBe(true);
1214
+ expect(isPiMultiplexedEnvironment({ TERM: "xterm-256color", TMUX: "" })).toBe(true);
1215
+ expect(isPiMultiplexedEnvironment({ TERM: "xterm-256color", ZELLIJ: "0" })).toBe(true);
1216
+ expect(isPiMultiplexedEnvironment({ TERM: "xterm-256color", STY: "session" })).toBe(true);
1217
+ expect(isPiMultiplexedEnvironment({ TERM: "xterm-256color" })).toBe(false);
1218
+ });
1219
+
1220
+ test("all-motion tracking is immediate, ref-counted, and survives renderer start", () => {
1221
+ let starts = 0;
1222
+ const prototype: TestTuiPrototype & { beforeTerminalStart(this: TestTui): void } = {
1223
+ ...createPrototype(),
1224
+ beforeTerminalStart(this: TestTui) {
1225
+ starts += 1;
1226
+ this.terminal.write("pi-start");
1227
+ },
1228
+ };
1229
+ const tui = createTui();
1230
+ Object.setPrototypeOf(tui, { ...Object.getPrototypeOf(tui), ...prototype });
1231
+ const activePrototype = Object.getPrototypeOf(tui) as object;
1232
+ const firstDispose = installAllMotionTracking(asTui(tui), activePrototype, { TERM: "tmux-256color" });
1233
+ const secondDispose = installAllMotionTracking(asTui(tui), activePrototype, { TERM: "tmux-256color" });
1234
+ expect(tui.terminal.writes).toEqual(["\x1b[?1003h"]);
1235
+ Reflect.apply(Reflect.get(activePrototype, "beforeTerminalStart"), tui, []);
1236
+ expect(starts).toBe(1);
1237
+ expect(tui.terminal.writes).toEqual(["\x1b[?1003h", "pi-start", "\x1b[?1003h"]);
1238
+
1239
+ firstDispose();
1240
+ expect(tui.terminal.writes.at(-1)).toBe("\x1b[?1003h");
1241
+ secondDispose();
1242
+ expect(tui.terminal.writes.at(-1)).toBe("\x1b[?1003l");
1243
+ });
1244
+
1245
+ test("all-motion waits for fullscreen after regular-mode startup and skips direct terminals", () => {
1246
+ const prototype: TestTuiPrototype & { beforeTerminalStart(this: TestTui): void } = {
1247
+ ...createPrototype(),
1248
+ beforeTerminalStart(this: TestTui) {
1249
+ this.terminal.write("pi-start");
1250
+ },
1251
+ };
1252
+ const regular = createTui();
1253
+ regular.mode = "regular";
1254
+ Object.setPrototypeOf(regular, prototype);
1255
+ const dispose = installAllMotionTracking(asTui(regular), prototype, { TERM: "tmux-256color" });
1256
+ expect(regular.terminal.writes).toEqual([]);
1257
+
1258
+ const fullscreen = createTui(undefined, prototype);
1259
+ Reflect.apply(Reflect.get(prototype, "beforeTerminalStart"), fullscreen, []);
1260
+ expect(fullscreen.terminal.writes).toEqual(["pi-start", "\x1b[?1003h"]);
1261
+ dispose();
1262
+ expect(regular.terminal.writes).toEqual(["\x1b[?1003l"]);
1263
+
1264
+ const direct = createTui();
1265
+ const directPrototype = Object.getPrototypeOf(direct) as object;
1266
+ const original = Reflect.get(directPrototype, "beforeTerminalStart");
1267
+ installAllMotionTracking(asTui(direct), directPrototype, { TERM: "xterm-256color" });
1268
+ expect(Reflect.get(directPrototype, "beforeTerminalStart")).toBe(original);
1269
+ expect(direct.terminal.writes).toEqual([]);
1270
+ });
1271
+
1272
+ test("all-motion lease stays inactive through a later delegating wrapper and restores after reinstall", () => {
1273
+ const prototype: TestTuiPrototype & { beforeTerminalStart(this: TestTui): void } = {
1274
+ ...createPrototype(),
1275
+ beforeTerminalStart(this: TestTui) {
1276
+ this.terminal.write("pi-start");
1277
+ },
1278
+ };
1279
+ const tui = createTui(undefined, prototype);
1280
+ const original = prototype.beforeTerminalStart;
1281
+ const firstDispose = installAllMotionTracking(asTui(tui), prototype, { TERM: "tmux-256color" });
1282
+ const allMotion = prototype.beforeTerminalStart;
1283
+ const wrapper = function (this: TestTui): void {
1284
+ Reflect.apply(allMotion, this, []);
1285
+ };
1286
+ prototype.beforeTerminalStart = wrapper;
1287
+ firstDispose();
1288
+ expect(tui.terminal.writes.at(-1)).toBe("\x1b[?1003l");
1289
+ Reflect.apply(wrapper, tui, []);
1290
+ expect(tui.terminal.writes.at(-1)).toBe("pi-start");
1291
+
1292
+ const secondDispose = installAllMotionTracking(asTui(tui), prototype, { TERM: "tmux-256color" });
1293
+ expect(tui.terminal.writes.at(-1)).toBe("\x1b[?1003h");
1294
+ Reflect.apply(wrapper, tui, []);
1295
+ expect(tui.terminal.writes.slice(-2)).toEqual(["pi-start", "\x1b[?1003h"]);
1296
+
1297
+ prototype.beforeTerminalStart = allMotion;
1298
+ secondDispose();
1299
+ expect(prototype.beforeTerminalStart).toBe(original);
1300
+ expect(tui.terminal.writes.at(-1)).toBe("\x1b[?1003l");
1301
+ });
1302
+ });