@xzzpig/pi-starline 0.2.0 → 0.4.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.
@@ -1,81 +1,78 @@
1
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 four patches. Two 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
- * `selectionHint`, one patch plus a derived hint:
14
- * - `copyActiveSelectionToClipboard` — the method Pi 0.84.4's Ctrl+X handler
15
- * reaches (`handleCopyCommand` with `preferSelection`). Wrapped so a
16
- * selection the clean-copy features recognise copies clean, calling
17
- * through for everything else.
18
- * - The hint is derived, not owned: `activeSelectionHintText()` renders the
19
- * live renderer's state — an active selection that is not being
20
- * auto-copied (`getCopyOnSelect() === false`) — so select-without-copy is
21
- * Pi's own `fullscreenCopyOnSelect`, and this module only tells the user
22
- * what to press. The copy key is `app.message.copy` (default ctrl+x),
23
- * resolved from Pi's keybinding registry so a rebind shows up in the hint.
24
- *
25
- * `clickToExpandTools`, one patch:
26
- * - `handleSelectionMouseEvent` — watched for a left-button press that landed
27
- * on a tool box's `ctrl+o to expand` hint row. On a hit it toggles that one
28
- * box and consumes the press, so the click does not also drop a selection
29
- * anchor into the box it just opened; every other press, including one
30
- * anywhere else inside the same box, calls through and starts a selection as
31
- * usual. `tool-box.ts` carries the reasoning for why the hint row is the
32
- * only target and why resolution goes through the component tree.
33
- *
34
- * `editorWheelScroll`, one patch:
35
- * - `routeWheel` — Pi's own wheel routing, which knows only about scroll views
36
- * and therefore always scrolls the transcript. Wrapped so a notch that landed
37
- * on the input box scrolls the *draft* instead, when the draft is taller than
38
- * the box. `editor-mouse.ts` carries how the box is located (not where the
39
- * plan for this said it would be) and how the live editor is reached; the
40
- * scroll itself is `editor-scroll.ts` — see its header for why it lives in
41
- * its own module. Every other notch calls through.
42
- *
43
- * `editorClickToCaret`, across two shared patches:
44
- * - `handleSelectionMouseEvent` — a left-button press inside the input box moves
45
- * the caret to the character under it, then calls through, so Pi still drops
46
- * its selection anchor there and a drag from that point still selects and
47
- * highlights as it always did. `editor-caret.ts` carries the screen-to-buffer
48
- * arithmetic and why the box's rectangle is not the text.
49
- * - `handleViewportInput` — backspace or delete over a live selection inside the
2
+ * Starline's mouse feature set, as a consumer of the `pi-mouse-events`
3
+ * extension.
4
+ *
5
+ * Starline used to install this feature set itself, by wrapping four methods
6
+ * on `TuiAltScreen.prototype`. It no longer touches any prototype: the
7
+ * `pi-mouse-events` extension owns that surface now, and everything here
8
+ * rides on the API it publishes (`mouse/api-consumer.ts` reads it off
9
+ * `globalThis`). When that extension is not installed there is no fallback —
10
+ * `installMouseFeaturesOn` is never called, no handler is registered, the
11
+ * hints derive from nothing, and every mouse feature is simply off. That is
12
+ * the deal: one owner for the prototype, Starline as a plain consumer.
13
+ *
14
+ * The features themselves are unchanged, and each maps onto one API slot:
15
+ *
16
+ * `editorWheelScroll` → `addMouseHandler`
17
+ * - A wheel notch that landed on the input box scrolls the *draft*, when the
18
+ * draft is taller than the box. `pi-mouse-events` delivers the notch before
19
+ * Pi's built-in wheel routing; consuming it here is what stops the
20
+ * transcript from scrolling too. `editor-mouse.ts` carries how the box is
21
+ * located and how the live editor is reached; the scroll itself is
22
+ * `editor-scroll.ts`.
23
+ *
24
+ * `clickToExpandTools` → `addMouseHandler`
25
+ * - A left-button press anywhere on an expandable component records the
26
+ * candidate; when the button is released on the same cell with no motion in
27
+ * between — a plain click — that one component is toggled and the release
28
+ * is consumed, so the click does not also drop a selection anchor into the
29
+ * box it just opened. The press itself is never consumed: a drag that
30
+ * starts on a box, hint row included, still selects and copies, a click on
31
+ * an OSC 8 link still opens the link, and a second same-cell click inside
32
+ * Pi's double-click window stays a word selection. Direction comes from the
33
+ * hint row when one is rendered and from the component's own `expanded`
34
+ * state otherwise; `tool-box.ts` carries the reasoning and the accepted
35
+ * limitation about a box's own output reading like its hint.
36
+ *
37
+ * `editorClickToCaret` → `addMouseHandler` + `ctx.ui.onTerminalInput`
38
+ * - The press half: a left-button press inside the input box moves the caret
39
+ * to the character under it and never consumes, so Pi still drops its
40
+ * selection anchor there and a drag from that point still selects and
41
+ * highlights as it always did. `editor-caret.ts` carries the
42
+ * screen-to-buffer arithmetic.
43
+ * - The keyboard half: backspace or delete over a live selection inside the
50
44
  * input box removes the whole range instead of one character, through the
51
- * editor's own `handleForwardDelete` under a single undo snapshot. It runs
52
- * *after* the ctrl+c branch and refuses ctrl+c and ctrl+d outright, so the
53
- * interrupt and exit chords cannot be swallowed by it from either direction.
54
- * Every backspace it does not act on falls through and deletes one character.
55
- *
56
- * `editorBufferCopy`, sharing the `copyActiveSelectionToClipboard` patch:
57
- * - A selection lying inside the input box is copied from the *draft* rather
58
- * than from the rendered rows. That is the one place this module builds
59
- * clipboard bytes itself, and the reason is that the rendered rows are not
60
- * the draft: they carry the rail glyph down the left, the padding each row is
61
- * filled out to, and a hard newline wherever the draft happened to wrap.
62
- *
63
- * `transcriptCleanCopy`, sharing the same patch:
64
- * - A selection in the transcript is copied with the chrome two components
65
- * paint around their content removed: Starline's user message rails and
66
- * border rules, and `pi-toolbox`'s rounded tool box frames. The cleaning
67
- * itself is `transcript-copy.ts`, which recognises only those exact markers
68
- * and passes everything else through untouched; a selection with no
69
- * recognised decoration falls back to Pi's verbatim copy, so nothing that
70
- * was clean before changes.
71
- *
72
- * Everywhere else the clipboard gets exactly what Pi's own
73
- * `copyActiveSelectionToClipboard` puts there — see `installMouse` for why frame-free
74
- * selection is not part of this.
45
+ * editor's own `handleForwardDelete` under a single undo snapshot. This
46
+ * used to ride the same viewport-input patch as everything else; it is
47
+ * keyboard, and `ctx.ui.onTerminalInput` — Pi's official extension input
48
+ * listener, which the renderer's mouse handling never swallows — is the
49
+ * honest place for it. `isRangeDeleteKey` refuses ctrl+c and ctrl+d
50
+ * outright, so the interrupt and exit chords cannot be swallowed by it from
51
+ * either direction. Every backspace it does not act on falls through and
52
+ * deletes one character.
53
+ *
54
+ * `editorBufferCopy` + `transcriptCleanCopy` → `addCopyHandler`
55
+ * - The copy key's path. A selection lying inside the input box is copied
56
+ * from the *draft* rather than from the rendered rows — the rows carry the
57
+ * rail glyph, the frame's padding, and a hard newline wherever the draft
58
+ * happened to wrap. A selection in the transcript is copied with the chrome
59
+ * two components paint around their content removed (Starline's rails and
60
+ * border rules, `pi-toolbox`'s rounded frames); the cleaning itself is
61
+ * `transcript-copy.ts`, and a selection with no recognised decoration falls
62
+ * back to Pi's verbatim copy.
63
+ *
64
+ * `selectionHint` → derived, no slot at all
65
+ * - `activeSelectionHintText()` renders the live renderer's state — an active
66
+ * selection that is not being auto-copied (`getCopyOnSelect() === false`) —
67
+ * so select-without-copy is Pi's own `fullscreenCopyOnSelect`, and this
68
+ * module only tells the user what to press. It is computed on demand at
69
+ * render time from the lazily bound renderer reference (`bindReceiver`);
70
+ * there is nothing to intercept.
75
71
  */
72
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
76
73
  import { getKeybindings, sliceByColumn, stripTerminalSequences } from "@earendil-works/pi-tui";
74
+ import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
77
75
  import type { PolishedTuiConfig } from "../config";
78
- import { installPrototypePatch } from "../prototype-patch-registry";
79
76
  import {
80
77
  disabledFeatureWarning,
81
78
  enabledFeatures,
@@ -88,7 +85,7 @@ import {
88
85
  editorSelectionTextFor,
89
86
  moveEditorCaretTo,
90
87
  } from "./editor-caret";
91
- import { activeEditor, wheelTarget } from "./editor-mouse";
88
+ import { activeEditor, pointerOverEditor } from "./editor-mouse";
92
89
  import { scrollEditorBy } from "./editor-scroll";
93
90
  import { editorVisualRowCount } from "./editor-text-cursor";
94
91
  import { type BoxLike, scrollContentLinesFor } from "./hit-test";
@@ -104,14 +101,21 @@ const CTRL_D = "\x04";
104
101
  /** The binding Pi's selection copy lives on (`app.message.copy`, default ctrl+x). */
105
102
  const COPY_KEYBINDING = "app.message.copy";
106
103
 
104
+ /**
105
+ * Pi's own double-click window (`DOUBLE_CLICK_INTERVAL_MS` in
106
+ * `tui-alt-screen.js`). A second same-cell click inside it is a word
107
+ * selection to Pi; without the debounce the expand flow would read it as
108
+ * another plain click and toggle the box right back.
109
+ */
110
+ const DOUBLE_CLICK_MS = 500;
111
+
107
112
  /** Opens the draft in `$EDITOR` — the hint an editor selection's hint carries. */
108
113
  const EXTERNAL_EDITOR_KEYBINDING = "app.editor.external";
109
114
 
110
115
  /**
111
- * SGR mouse bits, as `parseSgrMouseEvent` decodes them
112
- * (`tui-alt-screen.js:390`). `button & 3` is the button — 0 is left — bit 32
113
- * marks a motion (drag) report and bit 64 a wheel notch. `release` is the
114
- * `m`/`M` terminator.
116
+ * SGR mouse bits, as pi-tui's `parseSgrMouseEvent` decodes them. `button & 3`
117
+ * is the button — 0 is left — bit 32 marks a motion (drag) report and bit 64
118
+ * a wheel notch. `release` is the `m`/`M` terminator.
115
119
  */
116
120
  const BUTTON_MASK = 3;
117
121
  const LEFT_BUTTON = 0;
@@ -120,89 +124,37 @@ const WHEEL_BIT = 64;
120
124
 
121
125
  type MouseEventLike = { button: number; x: number; y: number; release?: boolean };
122
126
 
123
- /** `parseWheelEvent`'s output (`tui-alt-screen.js:345`): -1 is up, 1 is down. */
124
- type WheelEventLike = { direction: number; x: number; y: number };
125
-
126
127
  type SelectionPoint = { scrollView?: unknown; row: number; col: number; boundary?: boolean };
127
128
  type SelectionBounds = { start: SelectionPoint; end: SelectionPoint };
128
129
  type SelectionColumns = { start: number; end: number };
129
130
 
130
- /** The slice of `TuiAltScreen` this module reads or wraps. */
131
- type MouseCapablePrototype = {
132
- getSelectionBounds(this: unknown): SelectionBounds | undefined;
131
+ /**
132
+ * The slice of the live renderer this module reads. Reached through the
133
+ * `pi-mouse-events` handler context (`tui`) and its `liveReceiver()` hook —
134
+ * pi-tui 0.84.x internals, TS-private but runtime-present, the same surface
135
+ * its own consumers reach.
136
+ */
137
+ export type MouseCapableReceiver = {
138
+ getSelectionBounds?(): SelectionBounds | undefined;
133
139
  getSelectionColumns(
134
140
  this: unknown,
135
141
  line: string,
136
142
  row: number,
137
143
  selection: SelectionBounds,
138
144
  ): SelectionColumns;
139
- /**
140
- * Pi 0.84.4's public selection-copy entry (`tui-alt-screen.ts`): true when
141
- * the active selection was copied, false when there was none. This is what
142
- * coding-agent's Ctrl+X handler reaches, so a clean copy answering this
143
- * method answers exactly the copies that key performs.
144
- */
145
- copyActiveSelectionToClipboard(this: unknown): Promise<boolean>;
146
- /**
147
- * Pi 0.84.4's select-without-copy state. The hint reads it to decide
148
- * whether a selection is waiting for the copy key or was already copied on
149
- * release.
150
- */
151
- getCopyOnSelect(this: unknown): boolean;
152
- /** Whether the fullscreen viewport has a non-empty active text selection. */
153
- hasActiveSelection(this: unknown): boolean;
154
- handleViewportInput(this: unknown, data: string): { consume: boolean } | undefined;
155
- flash(this: unknown, message: string, durationMs?: number): void;
156
- hasOverlay(this: unknown): boolean;
157
- /**
158
- * `TuiAltScreen.routeWheel(event)` (`tui-alt-screen.js:375`). `event` is
159
- * `parseWheelEvent`'s output — `{ direction: -1 | 1, x, y }`, zero-based —
160
- * and the method returns nothing.
161
- */
162
- routeWheel(this: unknown, event: WheelEventLike): void;
163
- /**
164
- * `TuiBase.requestRender(force = false)` (`tui.js:495`), inherited by
165
- * `TuiAltScreen` and public. The only method here this module calls rather
166
- * than patches — see `capabilities.ts`.
167
- */
168
- requestRender(this: unknown, force?: boolean): void;
145
+ getCopyOnSelect?(this: unknown): boolean;
146
+ hasActiveSelection?(this: unknown): boolean;
147
+ flash?(this: unknown, message: string, durationMs?: number): void;
148
+ hasOverlay?(this: unknown): boolean;
149
+ requestRender?(this: unknown, force?: boolean): void;
169
150
  previousScreen?: readonly string[];
170
- // Not probed capabilities (see capabilities.ts): plain instance fields
171
- // this module only ever reads.
172
151
  currentLayout?: { root: BoxLike };
173
- /**
174
- * `TuiAltScreen.selectionAnchor` / `.selectionFocus` (`tui-alt-screen.js:45`).
175
- * Plain instance fields, which Pi assigns and clears throughout its own
176
- * selection handling; the range delete clears them the same way once the text
177
- * they described is gone.
178
- */
179
152
  selectionAnchor?: unknown;
180
153
  selectionFocus?: unknown;
181
- /**
182
- * `TuiBase.terminal` (`tui.js:101`); `rows` is a getter on it, and `write` is
183
- * how `copyActiveSelectionToClipboard` reaches the clipboard — it emits OSC 52
184
- * through the terminal rather than through any renderer method.
185
- */
186
154
  terminal?: { rows?: number; write?: (data: string) => void };
187
- /**
188
- * How many lines one notch moves, `max(1, options.wheelScrollLines ?? 1)`
189
- * (`tui-alt-screen.js:73`). Read so the editor scrolls by the same amount
190
- * the transcript would have.
191
- */
192
155
  wheelScrollLines?: number;
193
156
  };
194
157
 
195
- /**
196
- * Repaints go through the receiver, not through a callback the caller supplies.
197
- *
198
- * The renderer these patches run inside is the thing that needs to repaint, and
199
- * `TuiAltScreen` calls `this.requestRender()` all through its own mouse handling
200
- * for exactly this. Routing it back out to the extension instead made the hint
201
- * depend on whatever repaint path the extension happened to own — which, until
202
- * this was fixed, was the footer's, so with `features.statusLine` off the
203
- * pending hint never appeared until some unrelated frame came along. The hint
204
- * lives in the editor's metadata row; it was never the footer's to schedule.
205
- */
206
158
  export type InstallMouseDeps = {
207
159
  getConfig: () => PolishedTuiConfig;
208
160
  };
@@ -211,48 +163,106 @@ export type InstallMouseDeps = {
211
163
  let hasWarned = false;
212
164
 
213
165
  /**
214
- * The live renderer the hint reads. `installMouse` receives only the
215
- * prototype, so the instance is captured here from the first patched call
216
- * that sees one — `handleViewportInput` sees every key and mouse event, and
217
- * repaints ride Pi's own `requestRender`, so the hint is never more than one
218
- * input away from the current renderer.
166
+ * The live renderer, bound lazily — see `bindReceiver`.
219
167
  */
220
- let activeReceiver: MouseCapablePrototype | undefined;
168
+ let activeReceiver: MouseCapableReceiver | undefined;
221
169
 
222
170
  /**
223
- * Reader for the derived selection hint of whichever `installMouse` call is
224
- * currently active, mirroring `pasteExpandHintText`'s pattern in
225
- * `../paste-collapse.ts`. `ui.ts` composes this with the paste hint on every
226
- * render; there is nothing to wire when no mouse install is active.
171
+ * Reader for the derived selection hint, mirroring `pasteExpandHintText`'s
172
+ * pattern in `../paste-collapse.ts`. `ui.ts` composes this with the paste hint
173
+ * on every render; there is nothing to wire when no mouse install is active.
227
174
  *
228
175
  * The hint is computed on demand from the live renderer, never cached:
229
- * "selected, ctrl+x to copy" is true exactly while Pi is not auto-copying
230
- * and something is actually selected. The character count goes through the
231
- * same two copy paths the copy key itself would answer, so the hint's
232
- * promise and the clipboard's bytes stay in agreement.
176
+ * "selected, ctrl+x to copy" is true exactly while Pi is not auto-copying and
177
+ * something is actually selected. The character count goes through the same
178
+ * two copy paths the copy key itself would answer, so the hint's promise and
179
+ * the clipboard's bytes stay in agreement.
233
180
  */
234
181
  let activeHint:
235
182
  | { getConfig: () => PolishedTuiConfig; bufferCopy: boolean; cleanCopy: boolean }
236
183
  | undefined;
237
184
 
185
+ /**
186
+ * Per-install state: how to reach the live renderer (`api.liveReceiver()`),
187
+ * whether the copy slot exists, and the config reader the hint needs. Set at
188
+ * install, cleared on dispose.
189
+ */
190
+ let installRefs:
191
+ | {
192
+ liveReceiver: () => unknown;
193
+ copySlot: boolean;
194
+ deps: InstallMouseDeps;
195
+ }
196
+ | undefined;
197
+
198
+ /** Whether the probed receiver supports `feature` — false before a first bind. */
199
+ function featureOn(feature: MouseFeature): boolean {
200
+ return featureSet?.has(feature) ?? false;
201
+ }
202
+
203
+ /**
204
+ * The press a release-click is waiting to complete. `component` is the
205
+ * candidate resolved at press time — the release re-resolves and requires the
206
+ * same object, so content that scrolled between the two cannot redirect the
207
+ * toggle. `dragged` mirrors Pi's own `selectionDragged`: any button motion
208
+ * between press and release makes it a selection, not a click.
209
+ */
210
+ let expandPress: { x: number; y: number; component: object; dragged: boolean } | undefined;
211
+
212
+ /** The last completed toggle, for the double-click debounce. */
213
+ let lastToggle: { x: number; y: number; at: number } | undefined;
214
+
215
+ let featureSet: ReadonlySet<MouseFeature> | undefined;
216
+
217
+ /**
218
+ * Binds the renderer this mouse event (or the API's live reference) points at.
219
+ *
220
+ * In a real session there is no renderer to probe at install time: `ctx.ui`
221
+ * is Pi's extension-UI surface, not the renderer, and the live `TuiAltScreen`
222
+ * first appears as `tui` inside a handler invocation and in the API's
223
+ * `liveReceiver()` — which the input wrapper refreshes on every keystroke as
224
+ * well as on every mouse report. Features are therefore registered
225
+ * unconditionally at install and gated here, on the capability set the first
226
+ * bind of each distinct renderer instance computes.
227
+ *
228
+ * With no candidate the last bound receiver stands, so the render-time hint
229
+ * readers and the keyboard listener can work from it without an event of
230
+ * their own.
231
+ */
232
+ function bindReceiver(candidate?: unknown): MouseCapableReceiver | undefined {
233
+ if (!installRefs) return undefined;
234
+ const receiver = candidate ?? installRefs.liveReceiver();
235
+ if (!receiver) return activeReceiver;
236
+ if (receiver !== activeReceiver) {
237
+ activeReceiver = receiver as MouseCapableReceiver;
238
+ featureSet = enabledFeatures(probeCapabilities(activeReceiver), installRefs.copySlot);
239
+ const warning = disabledFeatureWarning(featureSet);
240
+ if (warning && !hasWarned) {
241
+ hasWarned = true;
242
+ console.warn(warning);
243
+ }
244
+ const { deps } = installRefs;
245
+ const bufferCopy = featureOn("editorBufferCopy");
246
+ const cleanCopy = featureOn("transcriptCleanCopy");
247
+ activeHint =
248
+ bufferCopy || cleanCopy ? { getConfig: deps.getConfig, bufferCopy, cleanCopy } : undefined;
249
+ }
250
+ return activeReceiver;
251
+ }
252
+
238
253
  export function activeSelectionHintText(): string | null {
239
- if (!activeHint || !activeReceiver) return null;
254
+ const receiver = bindReceiver();
255
+ if (!receiver || !activeHint) return null;
240
256
  const { getConfig, bufferCopy, cleanCopy } = activeHint;
241
257
  try {
242
258
  // Pi auto-copies on release; a selection that is already on the
243
259
  // clipboard needs no hint. `getCopyOnSelect()` is the live value, so a
244
260
  // setting flipped at runtime flips the hint with it.
245
- if (activeReceiver.getCopyOnSelect()) return null;
246
- if (!activeReceiver.hasActiveSelection()) return null;
247
- const bounds = activeReceiver.getSelectionBounds();
261
+ if (receiver.getCopyOnSelect?.()) return null;
262
+ if (!receiver.hasActiveSelection?.()) return null;
263
+ const bounds = receiver.getSelectionBounds?.();
248
264
  if (!bounds) return null;
249
- const pending = pendingSelectionText(
250
- activeReceiver,
251
- getConfig(),
252
- bounds,
253
- bufferCopy,
254
- cleanCopy,
255
- );
265
+ const pending = pendingSelectionText(receiver, getConfig(), bounds, bufferCopy, cleanCopy);
256
266
  return selectionHintText(
257
267
  pending.text.length,
258
268
  copyKeyText(),
@@ -269,16 +279,6 @@ export function activeSelectionHintText(): string | null {
269
279
  }
270
280
  }
271
281
 
272
- /**
273
- * The rendered name of the copy key, for the hint. `app.message.copy` is what
274
- * Pi 0.84.4's Ctrl+X path is bound to (see `handleCopyCommand` in
275
- * interactive-mode.ts), and the registry resolves a user rebind so the hint
276
- * always quotes the key that really copies.
277
- */
278
- function copyKeyText(): string {
279
- return keyTextFor(COPY_KEYBINDING);
280
- }
281
-
282
282
  /**
283
283
  * The "ctrl+g to edit in $EDITOR" hint while the draft outgrows the box.
284
284
  *
@@ -287,46 +287,48 @@ function copyKeyText(): string {
287
287
  * some of it is unreachable by mouse no matter how you drag. That is exactly
288
288
  * when the external editor is the way to act on the whole draft, so the hint
289
289
  * is offered whenever the draft outgrows the box, not only while a selection
290
- * is live. Refreshed on every `handleViewportInput` call (keystrokes and
291
- * mouse events both land there), read by `ui.ts` when nothing else owns the
292
- * metadata row's right side.
290
+ * is live. Computed on demand at render time (it used to be refreshed from
291
+ * the viewport patch on every key; the value is a pure read of the live
292
+ * renderer, so deriving it when asked is always at least as fresh).
293
293
  */
294
- let externalEditorHint: string | null = null;
295
-
296
294
  export function externalEditorHintText(): string | null {
297
- return externalEditorHint;
298
- }
299
-
300
- function refreshExternalEditorHint(
301
- receiver: MouseCapablePrototype,
302
- config: PolishedTuiConfig,
303
- ): void {
304
- externalEditorHint = null;
295
+ const receiver = bindReceiver();
296
+ if (!receiver || !activeHint) return null;
305
297
  try {
306
- const viewport = activeEditorViewport(receiver, config);
307
- if (!viewport) return;
298
+ const viewport = activeEditorViewport(receiver, activeHint.getConfig());
299
+ if (!viewport) return null;
308
300
  const visualRows = editorVisualRowCount(viewport.editor);
309
301
  if (visualRows > viewport.viewport.contentRows) {
310
- externalEditorHint = `${keyTextFor(EXTERNAL_EDITOR_KEYBINDING)} to edit in ${
302
+ return `${keyTextFor(EXTERNAL_EDITOR_KEYBINDING)} to edit in ${
311
303
  externalEditorName() ?? "$EDITOR"
312
304
  }`;
313
305
  }
306
+ return null;
314
307
  } catch {
315
308
  // Best effort: an editor this module cannot read offers no hint.
309
+ return null;
316
310
  }
317
311
  }
318
312
 
313
+ /**
314
+ * The rendered name of the copy key, for the hint. `app.message.copy` is what
315
+ * Pi's Ctrl+X path is bound to (see `handleCopyCommand` in
316
+ * interactive-mode.ts), and the registry resolves a user rebind so the hint
317
+ * always quotes the key that really copies.
318
+ */
319
+ function copyKeyText(): string {
320
+ return keyTextFor(COPY_KEYBINDING);
321
+ }
322
+
319
323
  /**
320
324
  * The exact text Pi's selection copy would produce, built the same way it
321
325
  * builds it: per row, through the receiver's own `getSelectionColumns`, then
322
326
  * `sliceByColumn` and `stripTerminalSequences` (both exported by pi-tui),
323
- * joined with "\n" — see `copyActiveSelectionToClipboard` in
324
- * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`. Reusing Pi's
325
- * own helpers instead of re-deriving the column math is what keeps this exact
326
- * rather than an estimate. The scroll-view case needs the box behind
327
- * `bounds.start.scrollView`; `getScrollViewBox` that finds it is not exported
328
- * from pi-tui's published entry point, so `scrollContentLinesFor` mirrors its
329
- * (trivial) tree walk in `hit-test.ts`.
327
+ * joined with "\n". Reusing Pi's own helpers instead of re-deriving the
328
+ * column math is what keeps this exact rather than an estimate. The
329
+ * scroll-view case needs the box behind `bounds.start.scrollView`;
330
+ * `getScrollViewBox` is not exported from pi-tui's published entry point, so
331
+ * `scrollContentLinesFor` mirrors its (trivial) tree walk in `hit-test.ts`.
330
332
  *
331
333
  * This is a *measurement*, not a copy: nothing here writes a clipboard. The
332
334
  * pending hint needs to say how many characters ctrl+c would put there. Rows
@@ -336,7 +338,7 @@ function refreshExternalEditorHint(
336
338
  * counts the cleaned text — the hint's promise and the clipboard's bytes are
337
339
  * kept in agreement there, by counting whichever text the copy will send.
338
340
  */
339
- function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds): string {
341
+ function selectionText(receiver: MouseCapableReceiver, bounds: SelectionBounds): string {
340
342
  const scrollView = bounds.start.scrollView;
341
343
  const sourceLines = scrollView
342
344
  ? scrollContentLinesFor(receiver.currentLayout?.root, scrollView)
@@ -345,7 +347,8 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
345
347
  const rows: string[] = [];
346
348
  for (let row = bounds.start.row; row <= bounds.end.row; row++) {
347
349
  const line = sourceLines[row] ?? "";
348
- const columns = receiver.getSelectionColumns(line, row, bounds);
350
+ const columns = receiver.getSelectionColumns?.(line, row, bounds);
351
+ if (!columns) return "";
349
352
  rows.push(
350
353
  stripTerminalSequences(
351
354
  sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true),
@@ -360,22 +363,18 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
360
363
  * the input box, and reports whether it did — `false` means this selection was
361
364
  * not the editor's and Pi's own copy must run instead.
362
365
  *
363
- * This is the one place in the module that builds clipboard bytes rather than
364
- * letting predecessor build them, and it is deliberate: Pi copies *rendered*
365
- * rows, so a selection in the input box picks up the rail glyph, the padding
366
- * the frame fills each row out to, and a newline wherever the draft happened to
367
- * wrap. None of that is in the draft. The bytes go out the same way Pi's own
368
- * copy sends them — OSC 52 through `terminal.write`, see
369
- * `copyActiveSelectionToClipboard` in
370
- * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js` — so nothing
371
- * downstream can tell the two copies apart.
366
+ * Pi copies *rendered* rows, so a selection in the input box picks up the rail
367
+ * glyph, the padding the frame fills each row out to, and a newline wherever
368
+ * the draft happened to wrap. None of that is in the draft. The bytes go out
369
+ * the same way Pi's own copy sends them — OSC 52 through `terminal.write` — so
370
+ * nothing downstream can tell the two copies apart.
372
371
  *
373
372
  * The flash is gated on `copyNotice` at the write itself, exactly once — Pi's
374
373
  * own copy flashes unconditionally, so this check is the whole notice path for
375
374
  * a clean copy.
376
375
  */
377
376
  function editorTextForSelection(
378
- receiver: MouseCapablePrototype,
377
+ receiver: MouseCapableReceiver,
379
378
  config: PolishedTuiConfig,
380
379
  bounds: SelectionBounds,
381
380
  ): string | undefined {
@@ -384,16 +383,16 @@ function editorTextForSelection(
384
383
  // so without this a selection dropped on a dialog would be read as text
385
384
  // from the draft hidden behind it. Both callers ask it, or the hint could
386
385
  // count one text while the copy sends another.
387
- if (receiver.hasOverlay()) return undefined;
386
+ if (receiver.hasOverlay?.()) return undefined;
388
387
  return editorSelectionTextFor(receiver, config, bounds);
389
388
  } catch {
390
389
  return undefined;
391
390
  }
392
391
  }
393
392
 
394
- function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTuiConfig): boolean {
393
+ function copyEditorSelection(receiver: MouseCapableReceiver, config: PolishedTuiConfig): boolean {
395
394
  try {
396
- const bounds = receiver.getSelectionBounds();
395
+ const bounds = receiver.getSelectionBounds?.();
397
396
  if (!bounds) return false;
398
397
  const text = editorTextForSelection(receiver, config, bounds);
399
398
  // undefined is "not the editor's"; "" is the editor's and empty, which is
@@ -404,7 +403,7 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
404
403
  const write = receiver.terminal?.write;
405
404
  if (typeof write !== "function") return false;
406
405
  write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
407
- if (config.mouse.copyNotice) receiver.flash("Copied!");
406
+ if (config.mouse.copyNotice) receiver.flash?.("Copied!");
408
407
  return true;
409
408
  } catch {
410
409
  // Reading the editor and the layout is best effort. Anything this trips
@@ -429,18 +428,17 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
429
428
  * Column mapping: `leftTrim` columns came off the left of a cleaned row, so
430
429
  * the selection's start and end columns shift by the same amount on the rows
431
430
  * they touch. The shifted bounds go through the receiver's own
432
- * `getSelectionColumns`, which aligns both ends to grapheme cell ranges
433
- * (`tui-alt-screen.js:716`) — the end column can no more cut a grapheme here
434
- * than it can in Pi's own copy.
431
+ * `getSelectionColumns`, which aligns both ends to grapheme cell ranges — the
432
+ * end column can no more cut a grapheme here than it can in Pi's own copy.
435
433
  */
436
434
  function transcriptSelectionText(
437
- receiver: MouseCapablePrototype,
435
+ receiver: MouseCapableReceiver,
438
436
  config: PolishedTuiConfig,
439
437
  bounds: SelectionBounds,
440
438
  ): string | undefined {
441
439
  const scrollView = bounds.start.scrollView;
442
440
  if (!scrollView) return undefined;
443
- if (receiver.hasOverlay()) return undefined;
441
+ if (receiver.hasOverlay?.()) return undefined;
444
442
  const sourceLines = scrollContentLinesFor(receiver.currentLayout?.root, scrollView);
445
443
  if (!sourceLines) return undefined;
446
444
  const cleaned = cleanTranscriptRows(
@@ -467,7 +465,8 @@ function transcriptSelectionText(
467
465
  adjusted.end = { ...bounds.end, col: Math.max(0, bounds.end.col - entry.leftTrim) };
468
466
  }
469
467
  }
470
- const columns = receiver.getSelectionColumns(entry.text, row, adjusted);
468
+ const columns = receiver.getSelectionColumns?.(entry.text, row, adjusted);
469
+ if (!columns) return undefined;
471
470
  rows.push(
472
471
  sliceByColumn(
473
472
  entry.text,
@@ -485,19 +484,18 @@ function transcriptSelectionText(
485
484
  * carried recognised chrome, and reports whether it did — `false` means
486
485
  * there was nothing to clean and Pi's own copy must run instead. The bytes
487
486
  * go out the same way Pi's own copy sends them (OSC 52 through
488
- * `terminal.write`), for the same reason `copyEditorSelection` does: nothing
489
- * downstream can tell the two copies apart.
487
+ * `terminal.write`), for the same reason `copyEditorSelection` does.
490
488
  *
491
489
  * A selection over pure decoration cleans to "" and is consumed without a
492
490
  * clipboard write — Pi's own copy has the same shape (`if (text.length === 0)
493
491
  * return`), it just gets there after building a string of rails.
494
492
  */
495
493
  function copyTranscriptSelection(
496
- receiver: MouseCapablePrototype,
494
+ receiver: MouseCapableReceiver,
497
495
  config: PolishedTuiConfig,
498
496
  ): boolean {
499
497
  try {
500
- const bounds = receiver.getSelectionBounds();
498
+ const bounds = receiver.getSelectionBounds?.();
501
499
  if (!bounds) return false;
502
500
  const text = transcriptSelectionText(receiver, config, bounds);
503
501
  if (text === undefined) return false;
@@ -505,7 +503,7 @@ function copyTranscriptSelection(
505
503
  const write = receiver.terminal?.write;
506
504
  if (typeof write !== "function") return false;
507
505
  write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
508
- if (config.mouse.copyNotice) receiver.flash("Copied!");
506
+ if (config.mouse.copyNotice) receiver.flash?.("Copied!");
509
507
  return true;
510
508
  } catch {
511
509
  // Cleaning reads the layout and the selection, both best effort.
@@ -518,12 +516,10 @@ function copyTranscriptSelection(
518
516
  * Whether `data` is the backspace or delete the range delete acts on.
519
517
  *
520
518
  * The question is asked of Pi's own keybinding registry, against the two
521
- * bindings Pi's editor itself dispatches on (`editor.js:599-606`), so a user who
522
- * has rebound either gets the key they bound rather than a hardcoded byte. A
523
- * registry that disagrees — this repo has two copies of pi-tui, though
524
- * production resolves both to Pi's, see `expandKeyText` in `tool-box.ts` — makes
525
- * this return false, which falls through to Pi's own one-character delete. That
526
- * is the safe direction to be wrong in.
519
+ * bindings Pi's editor itself dispatches on, so a user who has rebound either
520
+ * gets the key they bound rather than a hardcoded byte. A registry that
521
+ * disagrees makes this return false, which falls through to Pi's own
522
+ * one-character delete. That is the safe direction to be wrong in.
527
523
  *
528
524
  * ctrl+c and ctrl+d are refused outright, before the registry is consulted at
529
525
  * all. `tui.editor.deleteCharForward` binds `ctrl+d` by default, and ctrl+c and
@@ -553,13 +549,13 @@ function isRangeDeleteKey(data: unknown): boolean {
553
549
  * Returns false for every selection that is not the input box's, which leaves
554
550
  * the key to Pi and an ordinary backspace deleting one character.
555
551
  */
556
- function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTuiConfig): boolean {
552
+ function deleteSelectedRange(receiver: MouseCapableReceiver, config: PolishedTuiConfig): boolean {
557
553
  try {
558
554
  if (!config.editorClickCursor) return false;
559
555
  // The same overlay question the rest of this feature asks: a selection
560
556
  // dropped on a dialog must not delete the draft behind it.
561
- if (receiver.hasOverlay()) return false;
562
- const bounds = receiver.getSelectionBounds();
557
+ if (receiver.hasOverlay?.()) return false;
558
+ const bounds = receiver.getSelectionBounds?.();
563
559
  if (!bounds) return false;
564
560
  if (!deleteEditorSelection(receiver, config, bounds)) return false;
565
561
  // The highlight described text that is gone. Pi clears these two fields
@@ -583,7 +579,7 @@ function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTu
583
579
  * the hint promises a number the copy does not deliver.
584
580
  */
585
581
  function pendingSelectionText(
586
- receiver: MouseCapablePrototype,
582
+ receiver: MouseCapableReceiver,
587
583
  config: PolishedTuiConfig,
588
584
  bounds: SelectionBounds,
589
585
  bufferCopy: boolean,
@@ -606,161 +602,72 @@ function pendingSelectionText(
606
602
  }
607
603
 
608
604
  /**
609
- * Installs the clean-copy features on Pi 0.84.4's public copy entry, and the
610
- * derived selection hint.
611
- *
612
- * `editorBufferCopy` and `transcriptCleanCopy` sit in front of
613
- * `copyActiveSelectionToClipboard` — the method coding-agent's Ctrl+X handler
614
- * reaches (`handleCopyCommand` with `preferSelection`) — so the clean copy
615
- * answers exactly the copies that key performs. They share one patch rather
616
- * than taking a key each because `installPrototypePatch` holds exactly one
617
- * behaviour per adapter key: a second registration under `mouse-copy` would
618
- * silently replace the first, and two different keys would leave the order
619
- * they run in — and which of them gets to consume the call — implicit. Here
620
- * the precedence is written down: the buffer copy answers first, and only
621
- * when it declines does the clean copy or predecessor see the call.
622
- *
623
- * `selectionHint` owns no state at all: it records the config reader and the
624
- * clean-copy flags so `activeSelectionHintText()` can derive the hint on
625
- * demand from the live renderer. Either clean-copy feature can be off; with
626
- * neither available the clipboard is Pi's own bytes, exactly as before.
605
+ * A left-button press: not a release, not a drag, not another button, not a
606
+ * wheel notch. `pi-mouse-events` classifies the event before handing it here
607
+ * (`event.kind === "down"`); the shape is still checked rather than assumed,
608
+ * and a malformed event must fall through, never throw.
627
609
  */
628
- function installCopying(
629
- prototype: MouseCapablePrototype,
630
- deps: InstallMouseDeps,
631
- features: ReadonlySet<MouseFeature>,
632
- ): () => void {
633
- const bufferCopy = features.has("editorBufferCopy");
634
- const cleanCopy = features.has("transcriptCleanCopy");
635
- const rangeDelete = features.has("editorClickToCaret");
636
- const hint = features.has("selectionHint");
637
- const previousHint = activeHint;
638
- if (hint) activeHint = { getConfig: deps.getConfig, bufferCopy, cleanCopy };
639
-
640
- const cleanups: Array<() => void> = [];
641
-
642
- // Only installed when a feature that answers `copyActiveSelectionToClipboard`
643
- // is on. With just `editorClickToCaret` or `selectionHint` the method is
644
- // not ours to touch, and a Pi that has moved it must not make this install
645
- // throw.
646
- if (bufferCopy || cleanCopy) {
647
- cleanups.push(
648
- installPrototypePatch(
649
- prototype,
650
- "copyActiveSelectionToClipboard",
651
- "mouse-copy",
652
- ({ predecessor, receiver, args }) => {
653
- const typedReceiver = receiver as MouseCapablePrototype;
654
- const config = deps.getConfig();
655
- if (bufferCopy && copyEditorSelection(typedReceiver, config)) return true;
656
- if (
657
- cleanCopy &&
658
- config.mouse.transcriptCleanCopy &&
659
- copyTranscriptSelection(typedReceiver, config)
660
- ) {
661
- return true;
662
- }
663
- return Reflect.apply(predecessor, receiver, args);
664
- },
665
- ),
666
- );
667
- }
668
-
669
- if (!hint && !rangeDelete) {
670
- return () => {
671
- for (const cleanup of cleanups) cleanup();
672
- };
673
- }
674
-
675
- cleanups.push(
676
- installPrototypePatch(
677
- prototype,
678
- "handleViewportInput",
679
- "mouse-viewport-input",
680
- ({ predecessor, receiver, args }) => {
681
- const data = args[0];
682
- const typedReceiver = receiver as MouseCapablePrototype;
683
- // Keystrokes and mouse events both land here, so this is the one
684
- // place that sees every draft change; keep the outgrew-the-box
685
- // hint current with it, and keep the selection hint's renderer
686
- // reference pointed at the live instance.
687
- activeReceiver = typedReceiver;
688
- refreshExternalEditorHint(typedReceiver, deps.getConfig());
689
- // ---- Backspace and delete, `editorClickToCaret`'s. ------------
690
- //
691
- // Range delete consumes the key only when it really removed
692
- // something; every other backspace falls through and goes on
693
- // deleting one character, which is the behaviour it must not
694
- // break. `isRangeDeleteKey` refuses ctrl+c and ctrl+d outright,
695
- // so Pi's interrupt and exit chords can never be swallowed by
696
- // this branch.
697
- if (rangeDelete && isRangeDeleteKey(data)) {
698
- if (deleteSelectedRange(typedReceiver, deps.getConfig())) {
699
- // Pi's `handleInput` returns as soon as a listener consumes,
700
- // so the focused editor never sees the key and Pi never
701
- // reaches its own `requestImmediateRender` (`tui.js:620`).
702
- // Redrawing the changed draft is this branch's job.
703
- typedReceiver.requestRender();
704
- return { consume: true };
705
- }
706
- }
707
- return Reflect.apply(predecessor, receiver, args);
708
- },
709
- ),
710
- );
711
-
712
- return () => {
713
- for (const cleanup of cleanups) cleanup();
714
- };
610
+ function isLeftButtonPress(event: unknown): boolean {
611
+ if (typeof event !== "object" || event === null) return false;
612
+ const candidate = event as Partial<MouseEventLike>;
613
+ if (typeof candidate.button !== "number") return false;
614
+ if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
615
+ if (candidate.release) return false;
616
+ if ((candidate.button & (MOTION_BIT | WHEEL_BIT)) !== 0) return false;
617
+ return (candidate.button & BUTTON_MASK) === LEFT_BUTTON;
715
618
  }
716
619
 
717
620
  /**
718
- * A left-button press: not a release, not a drag, not another button.
719
- *
720
- * Pi's own handler returns immediately unless `(button & 3) === 0`, treats
721
- * `release` as the end of a drag and bit 32 as motion during one. Only the
722
- * press opens a selection, so only the press is the one this feature may take
723
- * instead.
724
- *
725
- * Bit 64 is excluded too, even though `handleInput` peels wheel reports off
726
- * before this method is reached (`parseWheelEvent` claims anything with bit 64
727
- * and a direction of 0 or 1): a notch of scroll that landed on a hint row
728
- * would otherwise expand a box the pointer was only passing over, and that
729
- * depends on a dispatch order in a file this package does not own.
730
- *
731
- * The shape is checked rather than assumed — this runs on whatever Pi passes,
732
- * and a malformed event must fall through, never throw.
621
+ * A left-button release: the `m` terminator, encoded as button 0 by SGR
622
+ * terminals and as button 3 by the legacy fallback — Pi's own click handling
623
+ * accepts either (`tui-alt-screen.js`'s release branch), so both are here.
733
624
  */
734
- function isLeftButtonPress(event: unknown): event is MouseEventLike {
625
+ function isLeftButtonRelease(event: unknown): boolean {
735
626
  if (typeof event !== "object" || event === null) return false;
736
627
  const candidate = event as Partial<MouseEventLike>;
737
628
  if (typeof candidate.button !== "number") return false;
738
629
  if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
739
- if (candidate.release) return false;
630
+ if (!candidate.release) return false;
740
631
  if ((candidate.button & (MOTION_BIT | WHEEL_BIT)) !== 0) return false;
741
- return (candidate.button & BUTTON_MASK) === LEFT_BUTTON;
632
+ const button = candidate.button & BUTTON_MASK;
633
+ return button === LEFT_BUTTON || button === 3;
634
+ }
635
+
636
+ /**
637
+ * A motion report with a button held — the drag that turns a press into a
638
+ * selection rather than a click. Motion without a button bit never arrives
639
+ * from SGR terminals while tracking is button-only, but the bit is checked
640
+ * rather than assumed.
641
+ */
642
+ function isButtonMotion(event: unknown): boolean {
643
+ if (typeof event !== "object" || event === null) return false;
644
+ const candidate = event as Partial<MouseEventLike>;
645
+ if (typeof candidate.button !== "number") return false;
646
+ if (candidate.release) return false;
647
+ return (candidate.button & MOTION_BIT) !== 0;
742
648
  }
743
649
 
744
650
  /**
745
- * The box a press should toggle, or undefined for every press that should go
746
- * on being a press.
651
+ * The expandable component a click should toggle, and the expansion to set —
652
+ * resolved identically at press (identity only) and at release (action), so
653
+ * the release can tell content that scrolled from the box it started on.
747
654
  *
748
655
  * Everything is resolved here, inside the one call: the layout is read as it
749
- * is right now, the component tree is built and dropped, and nothing is
750
- * carried to the release. A tool that is still running re-renders between the
751
- * two, so an answer kept that long would be about rows that have moved.
656
+ * is right now, the component tree is built and dropped, and nothing inside
657
+ * the answer outlives the event. A tool that is still running re-renders
658
+ * between press and release, which is exactly why the release re-resolves
659
+ * instead of trusting the press's answer.
752
660
  */
753
- function pressExpandTarget(
754
- receiver: MouseCapablePrototype,
755
- event: unknown,
661
+ function expandClickTarget(
662
+ receiver: MouseCapableReceiver,
663
+ event: MouseEventLike,
756
664
  deps: InstallMouseDeps,
757
665
  ): ExpandTarget | undefined {
758
666
  try {
759
667
  if (!deps.getConfig().mouse.clickToExpandTools) return undefined;
760
- if (!isLeftButtonPress(event)) return undefined;
761
668
  // Pi resolves no scroll view while an overlay is up, so neither does this
762
669
  // — a click on a dialog must not reach the transcript behind it.
763
- if (receiver.hasOverlay()) return undefined;
670
+ if (receiver.hasOverlay?.()) return undefined;
764
671
  return expandTargetAt(
765
672
  { root: receiver.currentLayout?.root, keyText: expandKeyText() },
766
673
  event.x,
@@ -768,23 +675,32 @@ function pressExpandTarget(
768
675
  );
769
676
  } catch {
770
677
  // Resolution is a best-effort read of Pi's internals. Anything it trips
771
- // over means this press was an ordinary press.
678
+ // over means this click was an ordinary click.
772
679
  return undefined;
773
680
  }
774
681
  }
775
682
 
683
+ /**
684
+ * The OSC 8 link Pi's built-in press branch stored for this press, if any.
685
+ * The built-in runs after this handler declines the press, so by release time
686
+ * a non-empty `pressedUrl` means Pi is about to open a link — the box stays
687
+ * as it is and the release goes through unconsumed.
688
+ */
689
+ function pressedUrlAt(receiver: MouseCapableReceiver): string | undefined {
690
+ return (receiver as { pressedUrl?: unknown }).pressedUrl as string | undefined;
691
+ }
692
+
776
693
  /**
777
694
  * Puts the caret where a press landed, when the press landed in the input box.
778
695
  *
779
696
  * Nothing is consumed: the press goes on to Pi, which drops its selection
780
697
  * anchor there exactly as it always did, so a drag from that point still
781
698
  * highlights and still copies. That is also what pays for the repaint — Pi's
782
- * own press branch ends in `requestRender()` unconditionally
783
- * (`tui-alt-screen.js:699`), so this feature never has to ask for one itself,
784
- * and `requestRender` is correspondingly absent from its requirements.
699
+ * own press branch ends in `requestRender()` unconditionally, so this feature
700
+ * never has to ask for one itself.
785
701
  */
786
702
  function moveCaretForPress(
787
- receiver: MouseCapablePrototype,
703
+ receiver: MouseCapableReceiver,
788
704
  event: unknown,
789
705
  deps: InstallMouseDeps,
790
706
  ): void {
@@ -794,8 +710,8 @@ function moveCaretForPress(
794
710
  if (!isLeftButtonPress(event)) return;
795
711
  // Pi resolves no scroll view while an overlay is up, so neither does this
796
712
  // — a click on a dialog must not move a caret in the box behind it.
797
- if (receiver.hasOverlay()) return;
798
- moveEditorCaretTo(receiver, config, event.x, event.y);
713
+ if (receiver.hasOverlay?.()) return;
714
+ moveEditorCaretTo(receiver, config, (event as MouseEventLike).x, (event as MouseEventLike).y);
799
715
  } catch {
800
716
  // Moving the caret is a best-effort read of Pi's internals and of an
801
717
  // editor this module did not necessarily build. Anything it trips over
@@ -803,80 +719,13 @@ function moveCaretForPress(
803
719
  }
804
720
  }
805
721
 
806
- /**
807
- * Installs the two features that live on `handleSelectionMouseEvent`.
808
- *
809
- * They share one patch because `installPrototypePatch` holds exactly one
810
- * behaviour per adapter key — a second registration under
811
- * `mouse-selection-event` would silently replace the first, and giving them a
812
- * key each would leave the order they run in implicit, which matters here
813
- * because only one of them may consume the press. Written as one patch the
814
- * precedence is explicit and testable:
815
- *
816
- * 1. `clickToExpandTools` gets the press first, and *consumes* it when it lands
817
- * on a tool box's hint row. That is what keeps the click from also dropping
818
- * a selection anchor into the box it just opened.
819
- * 2. `editorClickToCaret` gets every press expand did not take, moves the caret
820
- * if the press was inside the input box, and never consumes.
821
- * 3. Pi gets the press either way, unless expand took it.
822
- *
823
- * The two cannot collide in practice — the hint rows are in the transcript and
824
- * the caret rows are in the dock — but the ordering is what makes that a fact
825
- * rather than a hope, and either feature may be off without disturbing the
826
- * other.
827
- */
828
- function installSelectionMouse(
829
- prototype: MouseCapablePrototype,
830
- deps: InstallMouseDeps,
831
- features: ReadonlySet<MouseFeature>,
832
- ): () => void {
833
- const expandTools = features.has("clickToExpandTools");
834
- const clickToCaret = features.has("editorClickToCaret");
835
- return installPrototypePatch(
836
- prototype,
837
- "handleSelectionMouseEvent",
838
- "mouse-selection-event",
839
- ({ predecessor, receiver, args }) => {
840
- const typedReceiver = receiver as MouseCapablePrototype;
841
- if (expandTools) {
842
- const target = pressExpandTarget(typedReceiver, args[0], deps);
843
- if (target) {
844
- target.component.setExpanded(target.expanded);
845
- typedReceiver.requestRender();
846
- return undefined;
847
- }
848
- }
849
- if (clickToCaret) moveCaretForPress(typedReceiver, args[0], deps);
850
- return Reflect.apply(predecessor, receiver, args);
851
- },
852
- );
853
- }
854
-
855
- /**
856
- * The shape `parseWheelEvent` produces, checked rather than assumed — this runs
857
- * on whatever Pi hands `routeWheel`, and anything unrecognised must fall
858
- * through to Pi's own routing, never throw.
859
- */
860
- function isWheelEvent(event: unknown): event is WheelEventLike {
861
- if (typeof event !== "object" || event === null) return false;
862
- const candidate = event as Partial<WheelEventLike>;
863
- return (
864
- typeof candidate.direction === "number" &&
865
- typeof candidate.x === "number" &&
866
- typeof candidate.y === "number"
867
- );
868
- }
869
-
870
722
  /**
871
723
  * Scrolls the input box for this notch, or reports that the notch was not the
872
724
  * input box's — in which case Pi routes it as it always did.
873
725
  *
874
726
  * Pi does not scroll the editor: the editor's `scrollOffset` is re-derived from
875
- * the caret on every render, pulled back whenever the caret would fall outside
876
- * the visible window (`components/editor.js:392-401`). So a scroll here is an
877
- * offset *and* a caret move, which is what `scrollEditorBy` does — the same
878
- * function that has been in daily use since pi-powerline-footer, so a notch
879
- * behaves the way it always has.
727
+ * the caret on every render. So a scroll here is an offset *and* a caret move,
728
+ * which is what `scrollEditorBy` does.
880
729
  *
881
730
  * The window it scrolls within is Pi's own `max(5, rows * 0.3)`, taken from the
882
731
  * terminal rather than from the box's rect: the rect includes Starline's border
@@ -885,25 +734,20 @@ function isWheelEvent(event: unknown): event is WheelEventLike {
885
734
  * it from, so the notch falls through.
886
735
  */
887
736
  function scrollEditorForWheel(
888
- receiver: MouseCapablePrototype,
889
- event: unknown,
737
+ receiver: MouseCapableReceiver,
738
+ event: { direction: number; x: number; y: number },
890
739
  deps: InstallMouseDeps,
891
740
  ): boolean {
892
741
  try {
893
742
  if (!deps.getConfig().mouse.wheelRouting) return false;
894
- if (!isWheelEvent(event)) return false;
895
743
  // An overlay is composited over a layout that still contains the editor,
896
744
  // so the box is still "under" a pointer aimed at the dialog on top of it.
897
- if (receiver.hasOverlay()) return false;
745
+ if (receiver.hasOverlay?.()) return false;
898
746
  const editor = activeEditor();
899
747
  if (!editor) return false;
900
748
  const rows = receiver.terminal?.rows;
901
749
  if (typeof rows !== "number") return false;
902
- if (
903
- wheelTarget(receiver.currentLayout?.root, editor.component, event.x, event.y) !== "editor"
904
- ) {
905
- return false;
906
- }
750
+ if (!pointerOverEditorBox(receiver, editor.component, event.x, event.y)) return false;
907
751
  const lines = event.direction * Math.max(1, receiver.wheelScrollLines ?? 1);
908
752
  return scrollEditorBy(editor.scrollable, lines, rows);
909
753
  } catch {
@@ -914,97 +758,184 @@ function scrollEditorForWheel(
914
758
  }
915
759
  }
916
760
 
761
+ /** Whether the input box is what is painted at `(x, y)` — see `editor-mouse.ts`. */
762
+ function pointerOverEditorBox(
763
+ receiver: MouseCapableReceiver,
764
+ editor: unknown,
765
+ x: number,
766
+ y: number,
767
+ ): boolean {
768
+ return pointerOverEditor(receiver.currentLayout?.root, editor, x, y);
769
+ }
770
+
917
771
  /**
918
- * Installs `editorWheelScroll` on `routeWheel`.
919
- *
920
- * A thin wrapper with one job: a notch that landed on a draft taller than the
921
- * input box scrolls the box and is consumed; everything else calls through and
922
- * scrolls the transcript exactly as before. "Everything else" includes a notch
923
- * over the transcript, a notch over an overlay, a draft that fits entirely, an
924
- * editor this module cannot read, and `mouse.wheelRouting` being off.
925
- *
926
- * Reaching the top or bottom of a draft that *does* scroll is deliberately not
927
- * in that list: the notch stays with the box. Chaining on to the transcript at
928
- * the boundary would make the box feel like it slipped out from under the
929
- * pointer — see `editor-scroll.ts`.
772
+ * Registers the mouse features with the `pi-mouse-events` extension and
773
+ * returns a disposer that unregisters them all.
774
+ *
775
+ * Every handler is registered unconditionally and gated inside on the
776
+ * capability set `bindReceiver` computes from the live renderer — there is
777
+ * nothing to probe at install time (see `bindReceiver`). The one install-time
778
+ * gate left is `api.copySlotAvailable` for the two copy features: without the
779
+ * copy slot they would register into a method that is never reached (pi-tui
780
+ * < 0.84.3 has no `copyActiveSelectionToClipboard`), and a feature that
781
+ * silently cannot answer its own copy is exactly the half-working install
782
+ * `capabilities.ts` exists to prevent.
930
783
  */
931
- function installEditorWheelScroll(
932
- prototype: MouseCapablePrototype,
784
+ export function installMouseFeaturesOn(
785
+ api: MouseEventsApi,
786
+ ctx: ExtensionContext,
933
787
  deps: InstallMouseDeps,
934
788
  ): () => void {
935
- return installPrototypePatch(
936
- prototype,
937
- "routeWheel",
938
- "mouse-wheel",
939
- ({ predecessor, receiver, args }) => {
940
- const typedReceiver = receiver as MouseCapablePrototype;
941
- if (!scrollEditorForWheel(typedReceiver, args[0], deps)) {
942
- return Reflect.apply(predecessor, receiver, args);
943
- }
944
- // Pi's own `routeWheel` repaints at the end of every notch; consuming
945
- // the event means reaching that repaint is now this wrapper's job.
946
- typedReceiver.requestRender();
947
- return undefined;
948
- },
789
+ installRefs = {
790
+ liveReceiver: () => api.liveReceiver?.(),
791
+ copySlot: api.copySlotAvailable,
792
+ deps,
793
+ };
794
+ activeReceiver = undefined;
795
+ featureSet = undefined;
796
+ activeHint = undefined;
797
+
798
+ const cleanups: Array<() => void> = [];
799
+
800
+ // The expand flow never consumes the press — Pi's selection machinery
801
+ // anchors on it, so a drag that starts anywhere on a box still selects —
802
+ // and consumes the release of a plain click, which Pi's own release path
803
+ // treats as a no-op. It still registers ahead of the caret handler, the
804
+ // same precedence the old shared selection patch made explicit.
805
+ cleanups.push(
806
+ api.addMouseHandler(
807
+ ({ event, tui }) => {
808
+ const receiver = bindReceiver(tui);
809
+ if (!receiver || !featureOn("clickToExpandTools")) {
810
+ expandPress = undefined;
811
+ return undefined;
812
+ }
813
+ // One cast, here: the guards below classify without narrowing, and
814
+ // the parsed event already carries every field this reads.
815
+ const mouse = event as MouseEventLike;
816
+ if (isLeftButtonPress(mouse)) {
817
+ // Identity only: the release re-resolves, because a running tool
818
+ // re-renders between the two and rows move under the pointer.
819
+ const candidate = expandClickTarget(receiver, mouse, deps);
820
+ expandPress = candidate
821
+ ? { x: mouse.x, y: mouse.y, component: candidate.component, dragged: false }
822
+ : undefined;
823
+ return undefined;
824
+ }
825
+ if (expandPress && isButtonMotion(mouse)) {
826
+ expandPress.dragged = true;
827
+ return undefined;
828
+ }
829
+ if (!isLeftButtonRelease(mouse)) return undefined;
830
+ const press = expandPress;
831
+ expandPress = undefined;
832
+ if (!press || press.dragged) return undefined;
833
+ if (press.x !== mouse.x || press.y !== mouse.y) return undefined;
834
+ // A press on an OSC 8 link: Pi's own release opens it, and the box
835
+ // keeps its state.
836
+ if (pressedUrlAt(receiver) !== undefined) return undefined;
837
+ const target = expandClickTarget(receiver, event, deps);
838
+ if (!target || target.component !== press.component) return undefined;
839
+ const now = Date.now();
840
+ if (
841
+ lastToggle &&
842
+ lastToggle.x === press.x &&
843
+ lastToggle.y === press.y &&
844
+ now - lastToggle.at < DOUBLE_CLICK_MS
845
+ ) {
846
+ return undefined;
847
+ }
848
+ target.component.setExpanded(target.expanded);
849
+ receiver.requestRender?.();
850
+ lastToggle = { x: press.x, y: press.y, at: now };
851
+ return { handled: true };
852
+ },
853
+ { priority: 20 },
854
+ ),
949
855
  );
950
- }
951
856
 
952
- /**
953
- * Probes Pi, warns once about whatever this build cannot support, and
954
- * installs the mouse features that are available. Returns a disposer that
955
- * removes every patch this call installed.
956
- */
957
- export function installMouse(prototype: object, deps: InstallMouseDeps): () => void {
958
- const available = probeCapabilities(prototype);
959
- const enabled = enabledFeatures(available);
960
-
961
- const warning = disabledFeatureWarning(enabled);
962
- if (warning && !hasWarned) {
963
- hasWarned = true;
964
- console.warn(warning);
965
- }
857
+ cleanups.push(
858
+ api.addMouseHandler(
859
+ ({ event, tui }) => {
860
+ if (event.wheel === undefined) return undefined;
861
+ const receiver = bindReceiver(tui);
862
+ if (!receiver || !featureOn("editorWheelScroll")) return undefined;
863
+ const notch = { direction: event.wheel, x: event.x, y: event.y };
864
+ if (!scrollEditorForWheel(receiver, notch, deps)) {
865
+ return undefined;
866
+ }
867
+ // Pi's own wheel routing repaints at the end of every notch;
868
+ // consuming the event means reaching that repaint is now this
869
+ // handler's job.
870
+ receiver.requestRender?.();
871
+ return { handled: true };
872
+ },
873
+ { priority: 10 },
874
+ ),
875
+ );
966
876
 
967
- const typedPrototype = prototype as MouseCapablePrototype;
968
- const cleanups: Array<() => void> = [];
969
- // Frame-free selection is deliberately not installed, and is not a feature
970
- // in `capabilities.ts` either. It rewrote the clipboard text to drop a tool
971
- // box's border, which meant *inferring* where a frame was from the rendered
972
- // rows — and a frame is not a structural concept in Pi. It is a visual
973
- // convention `pi-toolbox` paints, so every discriminator (rule-capped
974
- // edges, `setExpanded`, blank-row trimming, verticals) was a guess that a
975
- // new component could falsify: `BashExecutionComponent` is rule-capped and
976
- // expandable and draws no frame, so any box-drawn table inside bash output
977
- // lost its borders.
978
- //
979
- // `transcriptCleanCopy` is the narrower successor that lesson produced: it
980
- // removes only the exact markers two named renderers are known to paint
981
- // (Starline's rail + full-width rule pairs, pi-toolbox's equal-width
982
- // rounded-corner pairs), never inferred ones — a square-cornered markdown
983
- // table, a blockquote gutter and bash output all pass through verbatim,
984
- // and a selection with no recognised marker falls back to Pi's own copy
985
- // untouched. The full frame-geometry version still wants what is written
986
- // below: `pi-toolbox` publishing which rows it drew a border on, at which
987
- // columns, so Starline reads a fact instead of inferring one.
988
- if (
989
- enabled.has("selectionHint") ||
990
- enabled.has("editorBufferCopy") ||
991
- enabled.has("editorClickToCaret") ||
992
- enabled.has("transcriptCleanCopy")
993
- ) {
994
- cleanups.push(installCopying(typedPrototype, deps, enabled));
995
- }
996
- if (enabled.has("clickToExpandTools") || enabled.has("editorClickToCaret")) {
997
- cleanups.push(installSelectionMouse(typedPrototype, deps, enabled));
877
+ cleanups.push(
878
+ api.addMouseHandler(
879
+ ({ event, tui }) => {
880
+ const receiver = bindReceiver(tui);
881
+ if (!receiver || !featureOn("editorClickToCaret")) return undefined;
882
+ moveCaretForPress(receiver, event, deps);
883
+ return undefined;
884
+ },
885
+ { priority: 0 },
886
+ ),
887
+ );
888
+
889
+ if (api.copySlotAvailable) {
890
+ cleanups.push(
891
+ api.addCopyHandler(({ tui }) => {
892
+ const receiver = bindReceiver(tui);
893
+ if (!receiver) return undefined;
894
+ const config = deps.getConfig();
895
+ // Precedence written down, as it always was: the buffer copy
896
+ // answers first, and only when it declines does the clean copy or
897
+ // Pi's own copy see the call.
898
+ if (featureOn("editorBufferCopy") && copyEditorSelection(receiver, config)) {
899
+ return { handled: true };
900
+ }
901
+ if (
902
+ featureOn("transcriptCleanCopy") &&
903
+ config.mouse.transcriptCleanCopy &&
904
+ copyTranscriptSelection(receiver, config)
905
+ ) {
906
+ return { handled: true };
907
+ }
908
+ return undefined;
909
+ }),
910
+ );
998
911
  }
999
- if (enabled.has("editorWheelScroll")) {
1000
- cleanups.push(installEditorWheelScroll(typedPrototype, deps));
912
+
913
+ // The keyboard half of click-to-caret: `ctx.ui.onTerminalInput` is Pi's
914
+ // official extension input listener, and the renderer's own handling never
915
+ // swallows a keystroke before it reaches the listener — mouse reports do,
916
+ // which is exactly why they are not handled here.
917
+ if (typeof ctx.ui.onTerminalInput === "function") {
918
+ cleanups.push(
919
+ ctx.ui.onTerminalInput((data: string) => {
920
+ if (!isRangeDeleteKey(data)) return undefined;
921
+ const receiver = bindReceiver();
922
+ if (!receiver || !featureOn("editorClickToCaret")) return undefined;
923
+ if (!deleteSelectedRange(receiver, deps.getConfig())) return undefined;
924
+ // Consuming the key means Pi's `handleInput` never reaches its own
925
+ // repaint; redrawing the changed draft is this branch's job.
926
+ receiver.requestRender?.();
927
+ return { consume: true };
928
+ }),
929
+ );
1001
930
  }
1002
931
 
1003
- if (cleanups.length === 0) return () => {};
1004
932
  return () => {
1005
- externalEditorHint = null;
1006
- activeReceiver = undefined;
1007
- activeHint = undefined;
1008
933
  for (const cleanup of cleanups) cleanup();
934
+ if (activeHint?.getConfig === deps.getConfig) activeHint = undefined;
935
+ installRefs = undefined;
936
+ activeReceiver = undefined;
937
+ featureSet = undefined;
938
+ expandPress = undefined;
939
+ lastToggle = undefined;
1009
940
  };
1010
941
  }