@xzzpig/pi-starline 0.1.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 (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. package/package.json +60 -0
@@ -0,0 +1,1119 @@
1
+ /**
2
+ * Installs the mouse feature set on Pi's live renderer prototype.
3
+ *
4
+ * This is the first module that actually touches Pi rather than describing
5
+ * what it would do to it. `installMouse` probes what the running Pi build
6
+ * exposes, logs once if something is missing, and installs each feature gated
7
+ * on exactly its own declared requirement (`capabilities.ts`). Today that is
8
+ * six features across five patches. Three of those methods carry two features
9
+ * each, and in every case the two share a single patch, because the patch
10
+ * registry holds one behaviour per adapter key and a second registration would
11
+ * silently replace the first:
12
+ *
13
+ * `selectionPendingMode`, across two patches:
14
+ * - `copySelectionToClipboard` — the method Pi calls itself on mouse
15
+ * release. Wrapped to arm a pending state instead of copying immediately
16
+ * when `copyOnSelect` is off, calling through for every other caller
17
+ * (including its own ctrl+c path below).
18
+ * - `handleViewportInput` — watched for a bare `ctrl+c` (`\x03`); with a
19
+ * selection pending it performs the real copy by calling back through
20
+ * `copySelectionToClipboard` (guarded so that call is recognised as the
21
+ * real thing and not re-armed), then clears the pending state. Anything
22
+ * else — including `ctrl+c` with nothing pending — calls through to Pi's
23
+ * own handler, which is what keeps `ctrl+c` interrupting.
24
+ *
25
+ * `pathAwareWords`, one patch:
26
+ * - `getWordSelection` — Pi's own double-click word lookup, replaced with
27
+ * `wordRangeAt` (`word-select.ts`, ported from pi issue #7746) so a path or
28
+ * a kebab-case identifier selects whole instead of stopping at `/` or `-`.
29
+ * The installed 0.84.1 build predates #7746, so this substitutes Pi's
30
+ * entire word-selection rule rather than layering path handling on top of
31
+ * it — that is what #7746 itself proposes, not scope creep introduced
32
+ * here. Calls through when `wordRangeAt` declines (column past the end of
33
+ * the line) or when `mouse.pathAwareWords` is off.
34
+ *
35
+ * `clickToExpandTools`, one patch:
36
+ * - `handleSelectionMouseEvent` — watched for a left-button press that landed
37
+ * on a tool box's `ctrl+o to expand` hint row. On a hit it toggles that one
38
+ * box and consumes the press, so the click does not also drop a selection
39
+ * anchor into the box it just opened; every other press, including one
40
+ * anywhere else inside the same box, calls through and starts a selection as
41
+ * usual. `tool-box.ts` carries the reasoning for why the hint row is the
42
+ * only target and why resolution goes through the component tree.
43
+ *
44
+ * `editorWheelScroll`, one patch:
45
+ * - `routeWheel` — Pi's own wheel routing, which knows only about scroll views
46
+ * and therefore always scrolls the transcript. Wrapped so a notch that landed
47
+ * on the input box scrolls the *draft* instead, when the draft is taller than
48
+ * the box. `editor-mouse.ts` carries how the box is located (not where the
49
+ * plan for this said it would be) and how the live editor is reached; the
50
+ * scroll itself is `editor-scroll.ts` — see its header for why it lives in
51
+ * its own module. Every other notch calls through.
52
+ *
53
+ * `editorClickToCaret`, across two shared patches:
54
+ * - `handleSelectionMouseEvent` — a left-button press inside the input box moves
55
+ * the caret to the character under it, then calls through, so Pi still drops
56
+ * its selection anchor there and a drag from that point still selects and
57
+ * highlights as it always did. `editor-caret.ts` carries the screen-to-buffer
58
+ * arithmetic and why the box's rectangle is not the text.
59
+ * - `handleViewportInput` — backspace or delete over a live selection inside the
60
+ * input box removes the whole range instead of one character, through the
61
+ * editor's own `handleForwardDelete` under a single undo snapshot. It runs
62
+ * *after* the ctrl+c branch and refuses ctrl+c and ctrl+d outright, so the
63
+ * interrupt and exit chords cannot be swallowed by it from either direction.
64
+ * Every backspace it does not act on falls through and deletes one character.
65
+ *
66
+ * `editorBufferCopy`, sharing the `copySelectionToClipboard` patch:
67
+ * - A selection lying inside the input box is copied from the *draft* rather
68
+ * than from the rendered rows. That is the one place this module builds
69
+ * clipboard bytes itself, and the reason is that the rendered rows are not
70
+ * the draft: they carry the rail glyph down the left, the padding each row is
71
+ * filled out to, and a hard newline wherever the draft happened to wrap.
72
+ *
73
+ * `transcriptCleanCopy`, sharing the same patch:
74
+ * - A selection in the transcript is copied with the chrome two components
75
+ * paint around their content removed: Starline's user message rails and
76
+ * border rules, and `pi-toolbox`'s rounded tool box frames. The cleaning
77
+ * itself is `transcript-copy.ts`, which recognises only those exact markers
78
+ * and passes everything else through untouched; a selection with no
79
+ * recognised decoration falls back to Pi's verbatim copy, so nothing that
80
+ * was clean before changes.
81
+ *
82
+ * Everywhere else the clipboard gets exactly what Pi's own
83
+ * `copySelectionToClipboard` puts there — see `installMouse` for why frame-free
84
+ * selection is not part of this.
85
+ */
86
+ import {
87
+ getKeybindings,
88
+ matchesKey,
89
+ sliceByColumn,
90
+ stripTerminalSequences,
91
+ } from "@earendil-works/pi-tui";
92
+ import type { PolishedTuiConfig } from "../config";
93
+ import { installPrototypePatch } from "../prototype-patch-registry";
94
+ import {
95
+ disabledFeatureWarning,
96
+ enabledFeatures,
97
+ type MouseFeature,
98
+ probeCapabilities,
99
+ } from "./capabilities";
100
+ import {
101
+ activeEditorViewport,
102
+ deleteEditorSelection,
103
+ editorSelectionTextFor,
104
+ moveEditorCaretTo,
105
+ } from "./editor-caret";
106
+ import { activeEditor, wheelTarget } from "./editor-mouse";
107
+ import { scrollEditorBy } from "./editor-scroll";
108
+ import { editorVisualRowCount } from "./editor-text-cursor";
109
+ import { type BoxLike, scrollContentLinesFor } from "./hit-test";
110
+ import { externalEditorName, SelectionPendingState, selectionHintText } from "./selection-state";
111
+ import { type ExpandTarget, expandKeyText, expandTargetAt, keyTextFor } from "./tool-box";
112
+ import { cleanTranscriptRows } from "./transcript-copy";
113
+ import { wordRangeAt } from "./word-select";
114
+
115
+ const CTRL_C = "\x03";
116
+ /** Pi's exit chord, and `tui.editor.deleteCharForward`'s second default key. */
117
+ const CTRL_D = "\x04";
118
+
119
+ /**
120
+ * Whether `data` is the ctrl+c chord, in whatever encoding the terminal sent
121
+ * it. Pi 0.84 pushes the Kitty keyboard protocol (and falls back to xterm
122
+ * modifyOtherKeys) on every capable terminal, and under either protocol the
123
+ * chord arrives as an escape sequence — `\x1b[99;5u` or `\x1b[27;5;99~` —
124
+ * never as the bare `\x03` a plain legacy terminal sends. The pending copy
125
+ * must recognise all three, or ctrl+c falls through to Pi's own binding
126
+ * (`app.clear`, clear the editor) exactly when it was about to copy. Pi's
127
+ * own key matcher is the authority here: it parses every protocol this
128
+ * build negotiates, and its `ctrl+c` arm accepts the raw control character
129
+ * too, so a legacy terminal keeps working unchanged.
130
+ */
131
+ function isCtrlC(data: unknown): boolean {
132
+ if (typeof data !== "string" || data.length === 0) return false;
133
+ try {
134
+ return matchesKey(data, "ctrl+c");
135
+ } catch {
136
+ // A pi-tui build that has moved `matchesKey` must not break the copy:
137
+ // fall back to the raw byte, which is what this branch has always read.
138
+ return data === CTRL_C;
139
+ }
140
+ }
141
+
142
+ /** Opens the draft in `$EDITOR` — the hint an editor selection's hint carries. */
143
+ const EXTERNAL_EDITOR_KEYBINDING = "app.editor.external";
144
+
145
+ /**
146
+ * SGR mouse bits, as `parseSgrMouseEvent` decodes them
147
+ * (`tui-alt-screen.js:390`). `button & 3` is the button — 0 is left — bit 32
148
+ * marks a motion (drag) report and bit 64 a wheel notch. `release` is the
149
+ * `m`/`M` terminator.
150
+ */
151
+ const BUTTON_MASK = 3;
152
+ const LEFT_BUTTON = 0;
153
+ const MOTION_BIT = 32;
154
+ const WHEEL_BIT = 64;
155
+
156
+ type MouseEventLike = { button: number; x: number; y: number; release?: boolean };
157
+
158
+ /** `parseWheelEvent`'s output (`tui-alt-screen.js:345`): -1 is up, 1 is down. */
159
+ type WheelEventLike = { direction: number; x: number; y: number };
160
+
161
+ type SelectionPoint = { scrollView?: unknown; row: number; col: number; boundary?: boolean };
162
+ type SelectionBounds = { start: SelectionPoint; end: SelectionPoint };
163
+ type SelectionColumns = { start: number; end: number };
164
+
165
+ /** The slice of `TuiAltScreen` this module reads or wraps. */
166
+ type MouseCapablePrototype = {
167
+ getSelectionBounds(this: unknown): SelectionBounds | undefined;
168
+ getSelectionColumns(
169
+ this: unknown,
170
+ line: string,
171
+ row: number,
172
+ selection: SelectionBounds,
173
+ ): SelectionColumns;
174
+ copySelectionToClipboard(this: unknown): void;
175
+ handleViewportInput(this: unknown, data: string): { consume: boolean } | undefined;
176
+ flash(this: unknown, message: string, durationMs?: number): void;
177
+ getWordSelection(this: unknown, point: SelectionPoint): SelectionBounds | undefined;
178
+ getSelectionSourceLine(this: unknown, point: SelectionPoint): string;
179
+ hasOverlay(this: unknown): boolean;
180
+ /**
181
+ * `TuiAltScreen.routeWheel(event)` (`tui-alt-screen.js:375`). `event` is
182
+ * `parseWheelEvent`'s output — `{ direction: -1 | 1, x, y }`, zero-based —
183
+ * and the method returns nothing.
184
+ */
185
+ routeWheel(this: unknown, event: WheelEventLike): void;
186
+ /**
187
+ * `TuiBase.requestRender(force = false)` (`tui.js:495`), inherited by
188
+ * `TuiAltScreen` and public. The only method here this module calls rather
189
+ * than patches — see `capabilities.ts`.
190
+ */
191
+ requestRender(this: unknown, force?: boolean): void;
192
+ previousScreen?: readonly string[];
193
+ // Not probed capabilities (see capabilities.ts): plain instance fields
194
+ // this module only ever reads.
195
+ currentLayout?: { root: BoxLike };
196
+ /**
197
+ * `TuiAltScreen.selectionAnchor` / `.selectionFocus` (`tui-alt-screen.js:45`).
198
+ * Plain instance fields, which Pi assigns and clears throughout its own
199
+ * selection handling; the range delete clears them the same way once the text
200
+ * they described is gone.
201
+ */
202
+ selectionAnchor?: unknown;
203
+ selectionFocus?: unknown;
204
+ /**
205
+ * `TuiBase.terminal` (`tui.js:101`); `rows` is a getter on it, and `write` is
206
+ * how `copySelectionToClipboard` reaches the clipboard — it emits OSC 52
207
+ * through the terminal rather than through any renderer method.
208
+ */
209
+ terminal?: { rows?: number; write?: (data: string) => void };
210
+ /**
211
+ * How many lines one notch moves, `max(1, options.wheelScrollLines ?? 1)`
212
+ * (`tui-alt-screen.js:73`). Read so the editor scrolls by the same amount
213
+ * the transcript would have.
214
+ */
215
+ wheelScrollLines?: number;
216
+ };
217
+
218
+ /**
219
+ * Repaints go through the receiver, not through a callback the caller supplies.
220
+ *
221
+ * The renderer these patches run inside is the thing that needs to repaint, and
222
+ * `TuiAltScreen` calls `this.requestRender()` all through its own mouse handling
223
+ * for exactly this. Routing it back out to the extension instead made the hint
224
+ * depend on whatever repaint path the extension happened to own — which, until
225
+ * this was fixed, was the footer's, so with `features.statusLine` off the
226
+ * pending hint never appeared until some unrelated frame came along. The hint
227
+ * lives in the editor's metadata row; it was never the footer's to schedule.
228
+ */
229
+ export type InstallMouseDeps = {
230
+ getConfig: () => PolishedTuiConfig;
231
+ };
232
+
233
+ /** Logged at most once per process — see `disabledFeatureWarning`. */
234
+ let hasWarned = false;
235
+
236
+ /**
237
+ * Reader for the pending-selection state of whichever `installMouse` call is
238
+ * currently active, mirroring `pasteExpandHintText`'s pattern in
239
+ * `../paste-collapse.ts`. `ui.ts` composes this with the paste hint
240
+ * on every render; there is nothing to wire when no mouse install is active.
241
+ */
242
+ let activeState: SelectionPendingState | undefined;
243
+
244
+ export function activeSelectionHintText(): string | null {
245
+ return activeState ? selectionHintText(activeState, externalEditorName()) : null;
246
+ }
247
+
248
+ /**
249
+ * The "ctrl+g to edit in $EDITOR" hint while the draft outgrows the box.
250
+ *
251
+ * An editor selection cannot grow past the visible window — there is no
252
+ * drag-scroll — so when the draft has more visual rows than the box shows,
253
+ * some of it is unreachable by mouse no matter how you drag. That is exactly
254
+ * when the external editor is the way to act on the whole draft, so the hint
255
+ * is offered whenever the draft outgrows the box, not only while a selection
256
+ * is live. Refreshed on every `handleViewportInput` call (keystrokes and
257
+ * mouse events both land there), read by `ui.ts` when nothing else owns the
258
+ * metadata row's right side.
259
+ */
260
+ let externalEditorHint: string | null = null;
261
+
262
+ export function externalEditorHintText(): string | null {
263
+ return externalEditorHint;
264
+ }
265
+
266
+ function refreshExternalEditorHint(
267
+ receiver: MouseCapablePrototype,
268
+ config: PolishedTuiConfig,
269
+ ): void {
270
+ externalEditorHint = null;
271
+ try {
272
+ const viewport = activeEditorViewport(receiver, config);
273
+ if (!viewport) return;
274
+ const visualRows = editorVisualRowCount(viewport.editor);
275
+ if (visualRows > viewport.viewport.contentRows) {
276
+ externalEditorHint = `${keyTextFor(EXTERNAL_EDITOR_KEYBINDING)} to edit in ${
277
+ externalEditorName() ?? "$EDITOR"
278
+ }`;
279
+ }
280
+ } catch {
281
+ // Best effort: an editor this module cannot read offers no hint.
282
+ }
283
+ }
284
+
285
+ /**
286
+ * The exact text `copySelectionToClipboard` would produce, built the same way
287
+ * it builds it: per row, through the receiver's own `getSelectionColumns`,
288
+ * then `sliceByColumn` and `stripTerminalSequences` (both exported by
289
+ * pi-tui), joined with "\n" — see `copySelectionToClipboard` in
290
+ * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`. Reusing Pi's
291
+ * own helpers instead of re-deriving the column math is what keeps this exact
292
+ * rather than an estimate. The scroll-view case needs the box behind
293
+ * `bounds.start.scrollView`; `getScrollViewBox` that finds it is not exported
294
+ * from pi-tui's published entry point, so `scrollContentLinesFor` mirrors its
295
+ * (trivial) tree walk in `hit-test.ts`.
296
+ *
297
+ * This is a *measurement*, not a copy: nothing here writes a clipboard. The
298
+ * pending hint needs to say how many characters ctrl+c would put there. Rows
299
+ * come back verbatim — no frame stripping, no rule-row dropping — so this is
300
+ * the count for a selection Pi's own copy would answer. When
301
+ * `transcriptCleanCopy` would answer the copy instead, `pendingSelectionText`
302
+ * counts the cleaned text — the hint's promise and the clipboard's bytes are
303
+ * kept in agreement there, by counting whichever text the copy will send.
304
+ */
305
+ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds): string {
306
+ const scrollView = bounds.start.scrollView;
307
+ const sourceLines = scrollView
308
+ ? scrollContentLinesFor(receiver.currentLayout?.root, scrollView)
309
+ : receiver.previousScreen;
310
+ if (!sourceLines) return "";
311
+ const rows: string[] = [];
312
+ for (let row = bounds.start.row; row <= bounds.end.row; row++) {
313
+ const line = sourceLines[row] ?? "";
314
+ const columns = receiver.getSelectionColumns(line, row, bounds);
315
+ rows.push(
316
+ stripTerminalSequences(
317
+ sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true),
318
+ ).trimEnd(),
319
+ );
320
+ }
321
+ return rows.join("\n");
322
+ }
323
+
324
+ /**
325
+ * Runs `copySelectionToClipboard` (Pi's real one, reached through the
326
+ * receiver so the `mouse-copy` patch's re-entrancy guard applies) with its
327
+ * own "Copied!" flash suppressed when `copyNotice` is off.
328
+ *
329
+ * Pi's copy method flashes unconditionally — see
330
+ * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`. Respecting
331
+ * `copyNotice: false` therefore means shadowing `flash` on the receiver for
332
+ * the duration of this one call and restoring it immediately after, the same
333
+ * shadow-and-restore shape `installPasteCollapse` already uses on `handlePaste`.
334
+ * `receiver` here is Pi's own instance (the wrapper runs inside Pi's own
335
+ * method call, not through the extension-facing Proxy), so a plain
336
+ * assignment is safe.
337
+ */
338
+ function copyWithNotice(receiver: MouseCapablePrototype, showNotice: boolean): void {
339
+ if (showNotice) {
340
+ receiver.copySelectionToClipboard();
341
+ return;
342
+ }
343
+ const originalFlash = receiver.flash;
344
+ receiver.flash = () => {};
345
+ try {
346
+ receiver.copySelectionToClipboard();
347
+ } finally {
348
+ receiver.flash = originalFlash;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Writes the draft's own text to the clipboard for a selection that lies inside
354
+ * the input box, and reports whether it did — `false` means this selection was
355
+ * not the editor's and Pi's own copy must run instead.
356
+ *
357
+ * This is the one place in the module that builds clipboard bytes rather than
358
+ * letting predecessor build them, and it is deliberate: Pi copies *rendered*
359
+ * rows, so a selection in the input box picks up the rail glyph, the padding
360
+ * the frame fills each row out to, and a newline wherever the draft happened to
361
+ * wrap. None of that is in the draft. The bytes go out the same way Pi's own
362
+ * copy sends them — OSC 52 through `terminal.write`, see
363
+ * `copySelectionToClipboard` in
364
+ * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js` — so nothing
365
+ * downstream can tell the two copies apart.
366
+ *
367
+ * The flash is unconditional, exactly as Pi's is. `copyNotice: false` is
368
+ * honoured by `copyWithNotice`, which shadows `flash` around the whole call;
369
+ * checking the setting here as well would suppress the notice twice on one path
370
+ * and not at all on the other.
371
+ */
372
+ function editorTextForSelection(
373
+ receiver: MouseCapablePrototype,
374
+ config: PolishedTuiConfig,
375
+ bounds: SelectionBounds,
376
+ ): string | undefined {
377
+ try {
378
+ // An overlay is composited over a layout that still contains the editor,
379
+ // so without this a selection dropped on a dialog would be read as text
380
+ // from the draft hidden behind it. Both callers ask it, or the hint could
381
+ // count one text while the copy sends another.
382
+ if (receiver.hasOverlay()) return undefined;
383
+ return editorSelectionTextFor(receiver, config, bounds);
384
+ } catch {
385
+ return undefined;
386
+ }
387
+ }
388
+
389
+ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTuiConfig): boolean {
390
+ try {
391
+ const bounds = receiver.getSelectionBounds();
392
+ if (!bounds) return false;
393
+ const text = editorTextForSelection(receiver, config, bounds);
394
+ // undefined is "not the editor's"; "" is the editor's and empty, which is
395
+ // still ours to answer — falling through would have Pi copy the rail out
396
+ // of a blank row.
397
+ if (text === undefined) return false;
398
+ if (text.length === 0) return true;
399
+ const write = receiver.terminal?.write;
400
+ if (typeof write !== "function") return false;
401
+ write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
402
+ receiver.flash("Copied!");
403
+ return true;
404
+ } catch {
405
+ // Reading the editor and the layout is best effort. Anything this trips
406
+ // over means Pi's own copy should run.
407
+ return false;
408
+ }
409
+ }
410
+
411
+ /**
412
+ * The transcript's text for this selection with the painted chrome removed,
413
+ * or undefined when there is nothing to clean — in which case Pi's verbatim
414
+ * copy runs, so a selection over plain rows takes exactly the path it always
415
+ * did.
416
+ *
417
+ * Only scroll-view selections are cleaned. A screen-space selection
418
+ * (`bounds.start.scrollView` unset) is over the dock or an overlay, where
419
+ * rows are not transcript content; both stay Pi's. The overlay guard is the
420
+ * same one every feature here asks: an overlay is composited over a layout
421
+ * that still contains the transcript, so without it a selection dropped on a
422
+ * dialog would be read as text from the scrollback behind it.
423
+ *
424
+ * Column mapping: `leftTrim` columns came off the left of a cleaned row, so
425
+ * the selection's start and end columns shift by the same amount on the rows
426
+ * they touch. The shifted bounds go through the receiver's own
427
+ * `getSelectionColumns`, which aligns both ends to grapheme cell ranges
428
+ * (`tui-alt-screen.js:716`) — the end column can no more cut a grapheme here
429
+ * than it can in Pi's own copy.
430
+ */
431
+ function transcriptSelectionText(
432
+ receiver: MouseCapablePrototype,
433
+ config: PolishedTuiConfig,
434
+ bounds: SelectionBounds,
435
+ ): string | undefined {
436
+ const scrollView = bounds.start.scrollView;
437
+ if (!scrollView) return undefined;
438
+ if (receiver.hasOverlay()) return undefined;
439
+ const sourceLines = scrollContentLinesFor(receiver.currentLayout?.root, scrollView);
440
+ if (!sourceLines) return undefined;
441
+ const cleaned = cleanTranscriptRows(
442
+ sourceLines,
443
+ bounds.start.row,
444
+ bounds.end.row,
445
+ config.icons.rail,
446
+ );
447
+ if (!cleaned.changed) return undefined;
448
+ const adjusted: SelectionBounds = {
449
+ start: bounds.start,
450
+ end: bounds.end,
451
+ };
452
+ const rows: string[] = [];
453
+ for (let i = 0; i < cleaned.rows.length; i++) {
454
+ const entry = cleaned.rows[i];
455
+ if (entry === null) continue;
456
+ const row = bounds.start.row + i;
457
+ if (entry.leftTrim > 0) {
458
+ if (row === bounds.start.row) {
459
+ adjusted.start = { ...bounds.start, col: Math.max(0, bounds.start.col - entry.leftTrim) };
460
+ }
461
+ if (row === bounds.end.row) {
462
+ adjusted.end = { ...bounds.end, col: Math.max(0, bounds.end.col - entry.leftTrim) };
463
+ }
464
+ }
465
+ const columns = receiver.getSelectionColumns(entry.text, row, adjusted);
466
+ rows.push(
467
+ sliceByColumn(
468
+ entry.text,
469
+ columns.start,
470
+ Math.max(0, columns.end - columns.start),
471
+ true,
472
+ ).trimEnd(),
473
+ );
474
+ }
475
+ return rows.join("\n");
476
+ }
477
+
478
+ /**
479
+ * Writes the transcript's cleaned text to the clipboard for a selection that
480
+ * carried recognised chrome, and reports whether it did — `false` means
481
+ * there was nothing to clean and Pi's own copy must run instead. The bytes
482
+ * go out the same way Pi's own copy sends them (OSC 52 through
483
+ * `terminal.write`), for the same reason `copyEditorSelection` does: nothing
484
+ * downstream can tell the two copies apart.
485
+ *
486
+ * A selection over pure decoration cleans to "" and is consumed without a
487
+ * clipboard write — Pi's own copy has the same shape (`if (text.length === 0)
488
+ * return`), it just gets there after building a string of rails.
489
+ */
490
+ function copyTranscriptSelection(
491
+ receiver: MouseCapablePrototype,
492
+ config: PolishedTuiConfig,
493
+ ): boolean {
494
+ try {
495
+ const bounds = receiver.getSelectionBounds();
496
+ if (!bounds) return false;
497
+ const text = transcriptSelectionText(receiver, config, bounds);
498
+ if (text === undefined) return false;
499
+ if (text.length === 0) return true;
500
+ const write = receiver.terminal?.write;
501
+ if (typeof write !== "function") return false;
502
+ write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
503
+ receiver.flash("Copied!");
504
+ return true;
505
+ } catch {
506
+ // Cleaning reads the layout and the selection, both best effort.
507
+ // Anything this trips over means Pi's own copy should run.
508
+ return false;
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Whether `data` is the backspace or delete the range delete acts on.
514
+ *
515
+ * The question is asked of Pi's own keybinding registry, against the two
516
+ * bindings Pi's editor itself dispatches on (`editor.js:599-606`), so a user who
517
+ * has rebound either gets the key they bound rather than a hardcoded byte. A
518
+ * registry that disagrees — this repo has two copies of pi-tui, though
519
+ * production resolves both to Pi's, see `expandKeyText` in `tool-box.ts` — makes
520
+ * this return false, which falls through to Pi's own one-character delete. That
521
+ * is the safe direction to be wrong in.
522
+ *
523
+ * ctrl+c and ctrl+d are refused outright, before the registry is consulted at
524
+ * all. `tui.editor.deleteCharForward` binds `ctrl+d` by default, and ctrl+c and
525
+ * ctrl+d are Pi's interrupt and exit chords; consuming either because a
526
+ * selection happens to be live would break a global key to save a keystroke.
527
+ * The ctrl+c branch above already returns before this is reached, but stating
528
+ * it here means the guarantee does not depend on branch order.
529
+ */
530
+ function isRangeDeleteKey(data: unknown): boolean {
531
+ if (typeof data !== "string" || data.length === 0) return false;
532
+ if (data === CTRL_C || data === CTRL_D) return false;
533
+ try {
534
+ const keybindings = getKeybindings();
535
+ return (
536
+ keybindings.matches(data, "tui.editor.deleteCharBackward") ||
537
+ keybindings.matches(data, "tui.editor.deleteCharForward")
538
+ );
539
+ } catch {
540
+ // A build that has moved the registry is one where backspace keeps its
541
+ // ordinary meaning; it is never a reason to break a keystroke.
542
+ return false;
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Removes the draft text a live editor selection covers, and clears the
548
+ * selection that described it.
549
+ *
550
+ * Returns false for every selection that is not the input box's, which leaves
551
+ * the key to Pi and an ordinary backspace deleting one character.
552
+ */
553
+ function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTuiConfig): boolean {
554
+ try {
555
+ if (!config.editorClickCursor) return false;
556
+ // The same overlay question the rest of this feature asks: a selection
557
+ // dropped on a dialog must not delete the draft behind it.
558
+ if (receiver.hasOverlay()) return false;
559
+ const bounds = receiver.getSelectionBounds();
560
+ if (!bounds) return false;
561
+ if (!deleteEditorSelection(receiver, config, bounds)) return false;
562
+ // The highlight described text that is gone. Pi clears these two fields
563
+ // itself all through its own selection handling; leaving them set would
564
+ // paint a selection over whatever moved up to fill the gap.
565
+ receiver.selectionAnchor = undefined;
566
+ receiver.selectionFocus = undefined;
567
+ return true;
568
+ } catch {
569
+ // Driving the editor is best effort. Anything this trips over means the
570
+ // key was an ordinary backspace.
571
+ return false;
572
+ }
573
+ }
574
+
575
+ /**
576
+ * The text a copy of this selection would put on the clipboard, for the pending
577
+ * hint's character count.
578
+ *
579
+ * It has to ask the same two paths the copy itself asks, in the same order, or
580
+ * the hint promises a number ctrl+c does not deliver.
581
+ */
582
+ function pendingSelectionText(
583
+ receiver: MouseCapablePrototype,
584
+ config: PolishedTuiConfig,
585
+ bounds: SelectionBounds,
586
+ bufferCopy: boolean,
587
+ cleanCopy: boolean,
588
+ ): { text: string; inEditor: boolean } {
589
+ const buffered = bufferCopy ? editorTextForSelection(receiver, config, bounds) : undefined;
590
+ if (buffered !== undefined) return { text: buffered, inEditor: true };
591
+ // The count the hint shows must be the number ctrl+c actually delivers, so
592
+ // a selection the copy would clean is counted after cleaning.
593
+ if (cleanCopy && config.mouse.transcriptCleanCopy) {
594
+ try {
595
+ const cleaned = transcriptSelectionText(receiver, config, bounds);
596
+ if (cleaned !== undefined) return { text: cleaned, inEditor: false };
597
+ } catch {
598
+ // A cleaning failure falls back to the verbatim count, which is what
599
+ // the copy itself falls back to as well.
600
+ }
601
+ }
602
+ return { text: selectionText(receiver, bounds), inEditor: false };
603
+ }
604
+
605
+ /**
606
+ * Installs the two features that live on `copySelectionToClipboard`.
607
+ *
608
+ * `selectionPendingMode` — arm on release, copy on ctrl+c — spans two patches:
609
+ * the copy patch decides whether a release copies now or arms and waits, and
610
+ * the `handleViewportInput` patch is what a waiting selection is eventually
611
+ * released by.
612
+ *
613
+ * `editorBufferCopy` sits in front of the real copy on that same method. They
614
+ * share one patch rather than taking a key each because
615
+ * `installPrototypePatch` holds exactly one behaviour per adapter key: a second
616
+ * registration under `mouse-copy` would silently replace the first, and two
617
+ * different keys would leave the order they run in — and which of them gets to
618
+ * consume the call — implicit. Here the precedence is written down: the buffer
619
+ * copy answers first, and only when it declines does the pending mode or
620
+ * predecessor see the call.
621
+ *
622
+ * Either feature can be off. With `selectionPendingMode` unavailable there is
623
+ * no state and every call is a real copy; with `editorBufferCopy` unavailable
624
+ * the clipboard is Pi's own bytes, exactly as before.
625
+ */
626
+ function installCopying(
627
+ prototype: MouseCapablePrototype,
628
+ deps: InstallMouseDeps,
629
+ features: ReadonlySet<MouseFeature>,
630
+ ): () => void {
631
+ const bufferCopy = features.has("editorBufferCopy");
632
+ const cleanCopy = features.has("transcriptCleanCopy");
633
+ const rangeDelete = features.has("editorClickToCaret");
634
+ const pendingMode = features.has("selectionPendingMode");
635
+ const state = pendingMode ? new SelectionPendingState() : undefined;
636
+ const previousState = activeState;
637
+ if (state) activeState = state;
638
+
639
+ // Set while this module is driving `copySelectionToClipboard` itself (the
640
+ // ctrl+c path below), so the `mouse-copy` patch performs the copy instead of
641
+ // re-arming the state it is itself in the middle of clearing.
642
+ let performingRealCopy = false;
643
+
644
+ const cleanups: Array<() => void> = [];
645
+
646
+ // Only installed when a feature that answers `copySelectionToClipboard` is
647
+ // on. With just `editorClickToCaret` the method is not ours to touch, and a
648
+ // Pi that has moved it must not make this install throw.
649
+ if (pendingMode || bufferCopy || cleanCopy) {
650
+ cleanups.push(
651
+ installPrototypePatch(
652
+ prototype,
653
+ "copySelectionToClipboard",
654
+ "mouse-copy",
655
+ ({ predecessor, receiver, args }) => {
656
+ const typedReceiver = receiver as MouseCapablePrototype;
657
+ const config = deps.getConfig();
658
+ if (!state || config.mouse.copyOnSelect || performingRealCopy) {
659
+ // The real copy, whether Pi triggered it directly on release
660
+ // (`copyOnSelect: true`), this module is driving it itself for
661
+ // ctrl+c below, or pending mode is not installed at all.
662
+ if (bufferCopy && copyEditorSelection(typedReceiver, config)) return undefined;
663
+ if (
664
+ cleanCopy &&
665
+ config.mouse.transcriptCleanCopy &&
666
+ copyTranscriptSelection(typedReceiver, config)
667
+ ) {
668
+ return undefined;
669
+ }
670
+ return Reflect.apply(predecessor, receiver, args);
671
+ }
672
+ const bounds = typedReceiver.getSelectionBounds();
673
+ if (!bounds) {
674
+ // A collapsed or empty selection (e.g. a plain click after a prior
675
+ // drag) still reaches this call — Pi runs it unconditionally on
676
+ // every release and relies on its own `if (!selection) return;`
677
+ // guard. Any stale arm from an earlier selection must not survive
678
+ // this: left in place, it would make a later ctrl+c consume the
679
+ // key for a no-op copy instead of falling through to interrupt.
680
+ if (state.pending) {
681
+ state.clear();
682
+ typedReceiver.requestRender();
683
+ }
684
+ return undefined;
685
+ }
686
+ const pendingText = pendingSelectionText(
687
+ typedReceiver,
688
+ config,
689
+ bounds,
690
+ bufferCopy,
691
+ cleanCopy,
692
+ );
693
+ state.arm(
694
+ pendingText.text.length,
695
+ // An editor selection cannot grow past the visible window —
696
+ // there is no drag-scroll — so the hint for one points at the
697
+ // external editor, the way to act on the whole draft. ""
698
+ // (unbound) simply shows no suffix.
699
+ pendingText.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
700
+ );
701
+ typedReceiver.requestRender();
702
+ return undefined;
703
+ },
704
+ ),
705
+ );
706
+ }
707
+
708
+ if (!state && !rangeDelete) {
709
+ return () => {
710
+ for (const cleanup of cleanups) cleanup();
711
+ };
712
+ }
713
+
714
+ cleanups.push(
715
+ installPrototypePatch(
716
+ prototype,
717
+ "handleViewportInput",
718
+ "mouse-viewport-input",
719
+ ({ predecessor, receiver, args }) => {
720
+ const data = args[0];
721
+ // Keystrokes and mouse events both land here, so this is the one
722
+ // place that sees every draft change; keep the outgrew-the-box
723
+ // hint current with it.
724
+ refreshExternalEditorHint(receiver as MouseCapablePrototype, deps.getConfig());
725
+ // ---- Branch 1: ctrl+c, `selectionPendingMode`'s. ----------------
726
+ //
727
+ // This branch is FIRST, and deliberately so. The one outcome that
728
+ // must never regress is a bare ctrl+c with nothing pending reaching
729
+ // Pi and interrupting; evaluating its branch before any other means
730
+ // no feature added to this method later can intercept it by being
731
+ // installed in a different order. `isRangeDeleteKey` refuses ctrl+c
732
+ // and ctrl+d outright as well, so the guarantee holds structurally
733
+ // from both directions rather than by ordering alone.
734
+ if (state && isCtrlC(data) && state.pending) {
735
+ const typedReceiver = receiver as MouseCapablePrototype;
736
+ // `state.pending` can be stale: Pi clears its own selection
737
+ // through paths this module never sees (e.g. starting a new
738
+ // drag overwrites `selectionAnchor`/`selectionFocus` directly,
739
+ // with no call to `copySelectionToClipboard`). Re-read the real
740
+ // bounds before deciding: a copy that would be a no-op must not
741
+ // consume ctrl+c, or an in-flight interrupt gets swallowed for
742
+ // nothing.
743
+ if (!typedReceiver.getSelectionBounds()) {
744
+ state.clear();
745
+ typedReceiver.requestRender();
746
+ return Reflect.apply(predecessor, receiver, args);
747
+ }
748
+ performingRealCopy = true;
749
+ try {
750
+ copyWithNotice(typedReceiver, deps.getConfig().mouse.copyNotice);
751
+ } finally {
752
+ performingRealCopy = false;
753
+ }
754
+ state.clear();
755
+ typedReceiver.requestRender();
756
+ return { consume: true };
757
+ }
758
+ // ---- Branch 2: backspace and delete, `editorClickToCaret`'s. ----
759
+ //
760
+ // The two branches key off disjoint input — ctrl+c above, backspace
761
+ // and delete here — so they compose rather than compete. Range
762
+ // delete consumes the key only when it really removed something;
763
+ // every other backspace falls through and goes on deleting one
764
+ // character, which is the behaviour it must not break.
765
+ if (rangeDelete && isRangeDeleteKey(data)) {
766
+ const typedReceiver = receiver as MouseCapablePrototype;
767
+ if (deleteSelectedRange(typedReceiver, deps.getConfig())) {
768
+ // Pi's `handleInput` returns as soon as a listener consumes,
769
+ // so the focused editor never sees the key and Pi never
770
+ // reaches its own `requestImmediateRender` (`tui.js:620`).
771
+ // Redrawing the changed draft is this branch's job.
772
+ state?.clear();
773
+ typedReceiver.requestRender();
774
+ return { consume: true };
775
+ }
776
+ }
777
+ return Reflect.apply(predecessor, receiver, args);
778
+ },
779
+ ),
780
+ );
781
+
782
+ return () => {
783
+ for (const cleanup of cleanups) cleanup();
784
+ if (activeState === state) activeState = previousState;
785
+ };
786
+ }
787
+
788
+ /**
789
+ * Installs `pathAwareWords` on `getWordSelection`.
790
+ *
791
+ * The receiver's own `getSelectionSourceLine` gives the raw line under the
792
+ * point; `stripTerminalSequences` is applied the same way predecessor itself
793
+ * applies it (see `getWordSelection` in
794
+ * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`), so
795
+ * `wordRangeAt` sees the same plain text Pi's own segmenter would. `col` is
796
+ * the only thing the wrapper changes on the way out — `row`, `scrollView`
797
+ * and any other point field ride through unmodified, which is what keeps a
798
+ * scroll-view selection landing in the right place.
799
+ */
800
+ function installPathAwareWords(
801
+ prototype: MouseCapablePrototype,
802
+ deps: InstallMouseDeps,
803
+ ): () => void {
804
+ return installPrototypePatch(
805
+ prototype,
806
+ "getWordSelection",
807
+ "mouse-word-selection",
808
+ ({ predecessor, receiver, args }) => {
809
+ if (!deps.getConfig().mouse.pathAwareWords) {
810
+ return Reflect.apply(predecessor, receiver, args);
811
+ }
812
+ const typedReceiver = receiver as MouseCapablePrototype;
813
+ const point = args[0] as SelectionPoint;
814
+ const line = stripTerminalSequences(typedReceiver.getSelectionSourceLine(point));
815
+ const range = wordRangeAt(line, point.col);
816
+ if (!range) return Reflect.apply(predecessor, receiver, args);
817
+ return {
818
+ start: { ...point, col: range.start },
819
+ end: { ...point, col: range.end, boundary: true },
820
+ };
821
+ },
822
+ );
823
+ }
824
+
825
+ /**
826
+ * A left-button press: not a release, not a drag, not another button.
827
+ *
828
+ * Pi's own handler returns immediately unless `(button & 3) === 0`, treats
829
+ * `release` as the end of a drag and bit 32 as motion during one. Only the
830
+ * press opens a selection, so only the press is the one this feature may take
831
+ * instead.
832
+ *
833
+ * Bit 64 is excluded too, even though `handleInput` peels wheel reports off
834
+ * before this method is reached (`parseWheelEvent` claims anything with bit 64
835
+ * and a direction of 0 or 1): a notch of scroll that landed on a hint row
836
+ * would otherwise expand a box the pointer was only passing over, and that
837
+ * depends on a dispatch order in a file this package does not own.
838
+ *
839
+ * The shape is checked rather than assumed — this runs on whatever Pi passes,
840
+ * and a malformed event must fall through, never throw.
841
+ */
842
+ function isLeftButtonPress(event: unknown): event is MouseEventLike {
843
+ if (typeof event !== "object" || event === null) return false;
844
+ const candidate = event as Partial<MouseEventLike>;
845
+ if (typeof candidate.button !== "number") return false;
846
+ if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
847
+ if (candidate.release) return false;
848
+ if ((candidate.button & (MOTION_BIT | WHEEL_BIT)) !== 0) return false;
849
+ return (candidate.button & BUTTON_MASK) === LEFT_BUTTON;
850
+ }
851
+
852
+ /**
853
+ * The box a press should toggle, or undefined for every press that should go
854
+ * on being a press.
855
+ *
856
+ * Everything is resolved here, inside the one call: the layout is read as it
857
+ * is right now, the component tree is built and dropped, and nothing is
858
+ * carried to the release. A tool that is still running re-renders between the
859
+ * two, so an answer kept that long would be about rows that have moved.
860
+ */
861
+ function pressExpandTarget(
862
+ receiver: MouseCapablePrototype,
863
+ event: unknown,
864
+ deps: InstallMouseDeps,
865
+ ): ExpandTarget | undefined {
866
+ try {
867
+ if (!deps.getConfig().mouse.clickToExpandTools) return undefined;
868
+ if (!isLeftButtonPress(event)) return undefined;
869
+ // Pi resolves no scroll view while an overlay is up, so neither does this
870
+ // — a click on a dialog must not reach the transcript behind it.
871
+ if (receiver.hasOverlay()) return undefined;
872
+ return expandTargetAt(
873
+ { root: receiver.currentLayout?.root, keyText: expandKeyText() },
874
+ event.x,
875
+ event.y,
876
+ );
877
+ } catch {
878
+ // Resolution is a best-effort read of Pi's internals. Anything it trips
879
+ // over means this press was an ordinary press.
880
+ return undefined;
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Puts the caret where a press landed, when the press landed in the input box.
886
+ *
887
+ * Nothing is consumed: the press goes on to Pi, which drops its selection
888
+ * anchor there exactly as it always did, so a drag from that point still
889
+ * highlights and still copies. That is also what pays for the repaint — Pi's
890
+ * own press branch ends in `requestRender()` unconditionally
891
+ * (`tui-alt-screen.js:699`), so this feature never has to ask for one itself,
892
+ * and `requestRender` is correspondingly absent from its requirements.
893
+ */
894
+ function moveCaretForPress(
895
+ receiver: MouseCapablePrototype,
896
+ event: unknown,
897
+ deps: InstallMouseDeps,
898
+ ): void {
899
+ try {
900
+ const config = deps.getConfig();
901
+ if (!config.editorClickCursor) return;
902
+ if (!isLeftButtonPress(event)) return;
903
+ // Pi resolves no scroll view while an overlay is up, so neither does this
904
+ // — a click on a dialog must not move a caret in the box behind it.
905
+ if (receiver.hasOverlay()) return;
906
+ moveEditorCaretTo(receiver, config, event.x, event.y);
907
+ } catch {
908
+ // Moving the caret is a best-effort read of Pi's internals and of an
909
+ // editor this module did not necessarily build. Anything it trips over
910
+ // means this press was an ordinary press.
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Installs the two features that live on `handleSelectionMouseEvent`.
916
+ *
917
+ * They share one patch because `installPrototypePatch` holds exactly one
918
+ * behaviour per adapter key — a second registration under
919
+ * `mouse-selection-event` would silently replace the first, and giving them a
920
+ * key each would leave the order they run in implicit, which matters here
921
+ * because only one of them may consume the press. Written as one patch the
922
+ * precedence is explicit and testable:
923
+ *
924
+ * 1. `clickToExpandTools` gets the press first, and *consumes* it when it lands
925
+ * on a tool box's hint row. That is what keeps the click from also dropping
926
+ * a selection anchor into the box it just opened.
927
+ * 2. `editorClickToCaret` gets every press expand did not take, moves the caret
928
+ * if the press was inside the input box, and never consumes.
929
+ * 3. Pi gets the press either way, unless expand took it.
930
+ *
931
+ * The two cannot collide in practice — the hint rows are in the transcript and
932
+ * the caret rows are in the dock — but the ordering is what makes that a fact
933
+ * rather than a hope, and either feature may be off without disturbing the
934
+ * other.
935
+ */
936
+ function installSelectionMouse(
937
+ prototype: MouseCapablePrototype,
938
+ deps: InstallMouseDeps,
939
+ features: ReadonlySet<MouseFeature>,
940
+ ): () => void {
941
+ const expandTools = features.has("clickToExpandTools");
942
+ const clickToCaret = features.has("editorClickToCaret");
943
+ return installPrototypePatch(
944
+ prototype,
945
+ "handleSelectionMouseEvent",
946
+ "mouse-selection-event",
947
+ ({ predecessor, receiver, args }) => {
948
+ const typedReceiver = receiver as MouseCapablePrototype;
949
+ if (expandTools) {
950
+ const target = pressExpandTarget(typedReceiver, args[0], deps);
951
+ if (target) {
952
+ target.component.setExpanded(target.expanded);
953
+ typedReceiver.requestRender();
954
+ return undefined;
955
+ }
956
+ }
957
+ if (clickToCaret) moveCaretForPress(typedReceiver, args[0], deps);
958
+ return Reflect.apply(predecessor, receiver, args);
959
+ },
960
+ );
961
+ }
962
+
963
+ /**
964
+ * The shape `parseWheelEvent` produces, checked rather than assumed — this runs
965
+ * on whatever Pi hands `routeWheel`, and anything unrecognised must fall
966
+ * through to Pi's own routing, never throw.
967
+ */
968
+ function isWheelEvent(event: unknown): event is WheelEventLike {
969
+ if (typeof event !== "object" || event === null) return false;
970
+ const candidate = event as Partial<WheelEventLike>;
971
+ return (
972
+ typeof candidate.direction === "number" &&
973
+ typeof candidate.x === "number" &&
974
+ typeof candidate.y === "number"
975
+ );
976
+ }
977
+
978
+ /**
979
+ * Scrolls the input box for this notch, or reports that the notch was not the
980
+ * input box's — in which case Pi routes it as it always did.
981
+ *
982
+ * Pi does not scroll the editor: the editor's `scrollOffset` is re-derived from
983
+ * the caret on every render, pulled back whenever the caret would fall outside
984
+ * the visible window (`components/editor.js:392-401`). So a scroll here is an
985
+ * offset *and* a caret move, which is what `scrollEditorBy` does — the same
986
+ * function that has been in daily use since pi-powerline-footer, so a notch
987
+ * behaves the way it always has.
988
+ *
989
+ * The window it scrolls within is Pi's own `max(5, rows * 0.3)`, taken from the
990
+ * terminal rather than from the box's rect: the rect includes Starline's border
991
+ * and metadata rows, and guessing how many of those there are would put the
992
+ * boundary in the wrong place. Without a row count there is nothing to compute
993
+ * it from, so the notch falls through.
994
+ */
995
+ function scrollEditorForWheel(
996
+ receiver: MouseCapablePrototype,
997
+ event: unknown,
998
+ deps: InstallMouseDeps,
999
+ ): boolean {
1000
+ try {
1001
+ if (!deps.getConfig().mouse.wheelRouting) return false;
1002
+ if (!isWheelEvent(event)) return false;
1003
+ // An overlay is composited over a layout that still contains the editor,
1004
+ // so the box is still "under" a pointer aimed at the dialog on top of it.
1005
+ if (receiver.hasOverlay()) return false;
1006
+ const editor = activeEditor();
1007
+ if (!editor) return false;
1008
+ const rows = receiver.terminal?.rows;
1009
+ if (typeof rows !== "number") return false;
1010
+ if (
1011
+ wheelTarget(receiver.currentLayout?.root, editor.component, event.x, event.y) !== "editor"
1012
+ ) {
1013
+ return false;
1014
+ }
1015
+ const lines = event.direction * Math.max(1, receiver.wheelScrollLines ?? 1);
1016
+ return scrollEditorBy(editor.scrollable, lines, rows);
1017
+ } catch {
1018
+ // Routing is a best-effort read of Pi's internals and of an editor this
1019
+ // module did not necessarily build. Anything it trips over means this
1020
+ // notch was the transcript's.
1021
+ return false;
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Installs `editorWheelScroll` on `routeWheel`.
1027
+ *
1028
+ * A thin wrapper with one job: a notch that landed on a draft taller than the
1029
+ * input box scrolls the box and is consumed; everything else calls through and
1030
+ * scrolls the transcript exactly as before. "Everything else" includes a notch
1031
+ * over the transcript, a notch over an overlay, a draft that fits entirely, an
1032
+ * editor this module cannot read, and `mouse.wheelRouting` being off.
1033
+ *
1034
+ * Reaching the top or bottom of a draft that *does* scroll is deliberately not
1035
+ * in that list: the notch stays with the box. Chaining on to the transcript at
1036
+ * the boundary would make the box feel like it slipped out from under the
1037
+ * pointer — see `editor-scroll.ts`.
1038
+ */
1039
+ function installEditorWheelScroll(
1040
+ prototype: MouseCapablePrototype,
1041
+ deps: InstallMouseDeps,
1042
+ ): () => void {
1043
+ return installPrototypePatch(
1044
+ prototype,
1045
+ "routeWheel",
1046
+ "mouse-wheel",
1047
+ ({ predecessor, receiver, args }) => {
1048
+ const typedReceiver = receiver as MouseCapablePrototype;
1049
+ if (!scrollEditorForWheel(typedReceiver, args[0], deps)) {
1050
+ return Reflect.apply(predecessor, receiver, args);
1051
+ }
1052
+ // Pi's own `routeWheel` repaints at the end of every notch; consuming
1053
+ // the event means reaching that repaint is now this wrapper's job.
1054
+ typedReceiver.requestRender();
1055
+ return undefined;
1056
+ },
1057
+ );
1058
+ }
1059
+
1060
+ /**
1061
+ * Probes Pi, warns once about whatever this build cannot support, and
1062
+ * installs the mouse features that are available. Returns a disposer that
1063
+ * removes every patch this call installed.
1064
+ */
1065
+ export function installMouse(prototype: object, deps: InstallMouseDeps): () => void {
1066
+ const available = probeCapabilities(prototype);
1067
+ const enabled = enabledFeatures(available);
1068
+
1069
+ const warning = disabledFeatureWarning(enabled);
1070
+ if (warning && !hasWarned) {
1071
+ hasWarned = true;
1072
+ console.warn(warning);
1073
+ }
1074
+
1075
+ const typedPrototype = prototype as MouseCapablePrototype;
1076
+ const cleanups: Array<() => void> = [];
1077
+ // Frame-free selection is deliberately not installed, and is not a feature
1078
+ // in `capabilities.ts` either. It rewrote the clipboard text to drop a tool
1079
+ // box's border, which meant *inferring* where a frame was from the rendered
1080
+ // rows — and a frame is not a structural concept in Pi. It is a visual
1081
+ // convention `pi-toolbox` paints, so every discriminator (rule-capped
1082
+ // edges, `setExpanded`, blank-row trimming, verticals) was a guess that a
1083
+ // new component could falsify: `BashExecutionComponent` is rule-capped and
1084
+ // expandable and draws no frame, so any box-drawn table inside bash output
1085
+ // lost its borders.
1086
+ //
1087
+ // `transcriptCleanCopy` is the narrower successor that lesson produced: it
1088
+ // removes only the exact markers two named renderers are known to paint
1089
+ // (Starline's rail + full-width rule pairs, pi-toolbox's equal-width
1090
+ // rounded-corner pairs), never inferred ones — a square-cornered markdown
1091
+ // table, a blockquote gutter and bash output all pass through verbatim,
1092
+ // and a selection with no recognised marker falls back to Pi's own copy
1093
+ // untouched. The full frame-geometry version still wants what is written
1094
+ // below: `pi-toolbox` publishing which rows it drew a border on, at which
1095
+ // columns, so Starline reads a fact instead of inferring one.
1096
+ if (
1097
+ enabled.has("selectionPendingMode") ||
1098
+ enabled.has("editorBufferCopy") ||
1099
+ enabled.has("editorClickToCaret") ||
1100
+ enabled.has("transcriptCleanCopy")
1101
+ ) {
1102
+ cleanups.push(installCopying(typedPrototype, deps, enabled));
1103
+ }
1104
+ if (enabled.has("pathAwareWords")) {
1105
+ cleanups.push(installPathAwareWords(typedPrototype, deps));
1106
+ }
1107
+ if (enabled.has("clickToExpandTools") || enabled.has("editorClickToCaret")) {
1108
+ cleanups.push(installSelectionMouse(typedPrototype, deps, enabled));
1109
+ }
1110
+ if (enabled.has("editorWheelScroll")) {
1111
+ cleanups.push(installEditorWheelScroll(typedPrototype, deps));
1112
+ }
1113
+
1114
+ if (cleanups.length === 0) return () => {};
1115
+ return () => {
1116
+ externalEditorHint = null;
1117
+ for (const cleanup of cleanups) cleanup();
1118
+ };
1119
+ }