@xzzpig/pi-starline 0.1.1 → 0.3.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,96 +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 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
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
60
44
  * 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.
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.
85
71
  */
86
- import {
87
- getKeybindings,
88
- matchesKey,
89
- sliceByColumn,
90
- stripTerminalSequences,
91
- } from "@earendil-works/pi-tui";
72
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
73
+ import { getKeybindings, sliceByColumn, stripTerminalSequences } from "@earendil-works/pi-tui";
74
+ import type { MouseEventsApi } from "@xzzpig/pi-mouse-events/api";
92
75
  import type { PolishedTuiConfig } from "../config";
93
- import { installPrototypePatch } from "../prototype-patch-registry";
94
76
  import {
95
77
  disabledFeatureWarning,
96
78
  enabledFeatures,
@@ -103,50 +85,37 @@ import {
103
85
  editorSelectionTextFor,
104
86
  moveEditorCaretTo,
105
87
  } from "./editor-caret";
106
- import { activeEditor, wheelTarget } from "./editor-mouse";
88
+ import { activeEditor, pointerOverEditor } from "./editor-mouse";
107
89
  import { scrollEditorBy } from "./editor-scroll";
108
90
  import { editorVisualRowCount } from "./editor-text-cursor";
109
91
  import { type BoxLike, scrollContentLinesFor } from "./hit-test";
110
- import { externalEditorName, SelectionPendingState, selectionHintText } from "./selection-state";
92
+ import { externalEditorName, selectionHintText } from "./selection-state";
111
93
  import { type ExpandTarget, expandKeyText, expandTargetAt, keyTextFor } from "./tool-box";
112
94
  import { cleanTranscriptRows } from "./transcript-copy";
113
- import { wordRangeAt } from "./word-select";
114
95
 
96
+ /** Pi's interrupt chord — refused by the range-delete branch, never consumed. */
115
97
  const CTRL_C = "\x03";
116
98
  /** Pi's exit chord, and `tui.editor.deleteCharForward`'s second default key. */
117
99
  const CTRL_D = "\x04";
118
100
 
101
+ /** The binding Pi's selection copy lives on (`app.message.copy`, default ctrl+x). */
102
+ const COPY_KEYBINDING = "app.message.copy";
103
+
119
104
  /**
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.
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.
130
109
  */
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
- }
110
+ const DOUBLE_CLICK_MS = 500;
141
111
 
142
112
  /** Opens the draft in `$EDITOR` — the hint an editor selection's hint carries. */
143
113
  const EXTERNAL_EDITOR_KEYBINDING = "app.editor.external";
144
114
 
145
115
  /**
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.
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.
150
119
  */
151
120
  const BUTTON_MASK = 3;
152
121
  const LEFT_BUTTON = 0;
@@ -155,77 +124,37 @@ const WHEEL_BIT = 64;
155
124
 
156
125
  type MouseEventLike = { button: number; x: number; y: number; release?: boolean };
157
126
 
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
127
  type SelectionPoint = { scrollView?: unknown; row: number; col: number; boundary?: boolean };
162
128
  type SelectionBounds = { start: SelectionPoint; end: SelectionPoint };
163
129
  type SelectionColumns = { start: number; end: number };
164
130
 
165
- /** The slice of `TuiAltScreen` this module reads or wraps. */
166
- type MouseCapablePrototype = {
167
- 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;
168
139
  getSelectionColumns(
169
140
  this: unknown,
170
141
  line: string,
171
142
  row: number,
172
143
  selection: SelectionBounds,
173
144
  ): SelectionColumns;
174
- copySelectionToClipboard(this: unknown): Promise<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;
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;
192
150
  previousScreen?: readonly string[];
193
- // Not probed capabilities (see capabilities.ts): plain instance fields
194
- // this module only ever reads.
195
151
  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
152
  selectionAnchor?: unknown;
203
153
  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
154
  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
155
  wheelScrollLines?: number;
216
156
  };
217
157
 
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
158
  export type InstallMouseDeps = {
230
159
  getConfig: () => PolishedTuiConfig;
231
160
  };
@@ -234,15 +163,120 @@ export type InstallMouseDeps = {
234
163
  let hasWarned = false;
235
164
 
236
165
  /**
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
166
+ * The live renderer, bound lazily — see `bindReceiver`.
167
+ */
168
+ let activeReceiver: MouseCapableReceiver | undefined;
169
+
170
+ /**
171
+ * Reader for the derived selection hint, mirroring `pasteExpandHintText`'s
172
+ * pattern in `../paste-collapse.ts`. `ui.ts` composes this with the paste hint
240
173
  * on every render; there is nothing to wire when no mouse install is active.
174
+ *
175
+ * The hint is computed on demand from the live renderer, never cached:
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.
180
+ */
181
+ let activeHint:
182
+ | { getConfig: () => PolishedTuiConfig; bufferCopy: boolean; cleanCopy: boolean }
183
+ | undefined;
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.
241
209
  */
242
- let activeState: SelectionPendingState | undefined;
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
+ }
243
252
 
244
253
  export function activeSelectionHintText(): string | null {
245
- return activeState ? selectionHintText(activeState, externalEditorName()) : null;
254
+ const receiver = bindReceiver();
255
+ if (!receiver || !activeHint) return null;
256
+ const { getConfig, bufferCopy, cleanCopy } = activeHint;
257
+ try {
258
+ // Pi auto-copies on release; a selection that is already on the
259
+ // clipboard needs no hint. `getCopyOnSelect()` is the live value, so a
260
+ // setting flipped at runtime flips the hint with it.
261
+ if (receiver.getCopyOnSelect?.()) return null;
262
+ if (!receiver.hasActiveSelection?.()) return null;
263
+ const bounds = receiver.getSelectionBounds?.();
264
+ if (!bounds) return null;
265
+ const pending = pendingSelectionText(receiver, getConfig(), bounds, bufferCopy, cleanCopy);
266
+ return selectionHintText(
267
+ pending.text.length,
268
+ copyKeyText(),
269
+ // An editor selection cannot grow past the visible window — there is
270
+ // no drag-scroll — so the hint for one points at the external editor,
271
+ // the way to act on the whole draft. "" (unbound) shows no suffix.
272
+ pending.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
273
+ externalEditorName(),
274
+ );
275
+ } catch {
276
+ // The renderer is best effort; a receiver that has moved on offers no
277
+ // hint rather than breaking the metadata row.
278
+ return null;
279
+ }
246
280
  }
247
281
 
248
282
  /**
@@ -253,46 +287,48 @@ export function activeSelectionHintText(): string | null {
253
287
  * some of it is unreachable by mouse no matter how you drag. That is exactly
254
288
  * when the external editor is the way to act on the whole draft, so the hint
255
289
  * 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.
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).
259
293
  */
260
- let externalEditorHint: string | null = null;
261
-
262
294
  export function externalEditorHintText(): string | null {
263
- return externalEditorHint;
264
- }
265
-
266
- function refreshExternalEditorHint(
267
- receiver: MouseCapablePrototype,
268
- config: PolishedTuiConfig,
269
- ): void {
270
- externalEditorHint = null;
295
+ const receiver = bindReceiver();
296
+ if (!receiver || !activeHint) return null;
271
297
  try {
272
- const viewport = activeEditorViewport(receiver, config);
273
- if (!viewport) return;
298
+ const viewport = activeEditorViewport(receiver, activeHint.getConfig());
299
+ if (!viewport) return null;
274
300
  const visualRows = editorVisualRowCount(viewport.editor);
275
301
  if (visualRows > viewport.viewport.contentRows) {
276
- externalEditorHint = `${keyTextFor(EXTERNAL_EDITOR_KEYBINDING)} to edit in ${
302
+ return `${keyTextFor(EXTERNAL_EDITOR_KEYBINDING)} to edit in ${
277
303
  externalEditorName() ?? "$EDITOR"
278
304
  }`;
279
305
  }
306
+ return null;
280
307
  } catch {
281
308
  // Best effort: an editor this module cannot read offers no hint.
309
+ return null;
282
310
  }
283
311
  }
284
312
 
285
313
  /**
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`.
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
+
323
+ /**
324
+ * The exact text Pi's selection copy would produce, built the same way it
325
+ * builds it: per row, through the receiver's own `getSelectionColumns`, then
326
+ * `sliceByColumn` and `stripTerminalSequences` (both exported by pi-tui),
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`.
296
332
  *
297
333
  * This is a *measurement*, not a copy: nothing here writes a clipboard. The
298
334
  * pending hint needs to say how many characters ctrl+c would put there. Rows
@@ -302,7 +338,7 @@ function refreshExternalEditorHint(
302
338
  * counts the cleaned text — the hint's promise and the clipboard's bytes are
303
339
  * kept in agreement there, by counting whichever text the copy will send.
304
340
  */
305
- function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds): string {
341
+ function selectionText(receiver: MouseCapableReceiver, bounds: SelectionBounds): string {
306
342
  const scrollView = bounds.start.scrollView;
307
343
  const sourceLines = scrollView
308
344
  ? scrollContentLinesFor(receiver.currentLayout?.root, scrollView)
@@ -311,7 +347,8 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
311
347
  const rows: string[] = [];
312
348
  for (let row = bounds.start.row; row <= bounds.end.row; row++) {
313
349
  const line = sourceLines[row] ?? "";
314
- const columns = receiver.getSelectionColumns(line, row, bounds);
350
+ const columns = receiver.getSelectionColumns?.(line, row, bounds);
351
+ if (!columns) return "";
315
352
  rows.push(
316
353
  stripTerminalSequences(
317
354
  sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true),
@@ -321,64 +358,23 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
321
358
  return rows.join("\n");
322
359
  }
323
360
 
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
- * 0.84.2 (#8110) boundary: Pi's copy is now async, and when the host injects
339
- * a clipboard (`copySelection` — coding-agent does since 0.84.2), the flash
340
- * lands after `await`, outside this synchronous shadow. `copyNotice: false`
341
- * therefore still suppresses the toast on the OSC 52 fallback path (no host
342
- * clipboard) but not on the host-clipboard path. The ctrl+c caller must stay
343
- * synchronous — it returns `{ consume: true }` to Pi's `handleInput` — so
344
- * awaiting Pi's copy here is not an option.
345
- */
346
- function copyWithNotice(receiver: MouseCapablePrototype, showNotice: boolean): void {
347
- if (showNotice) {
348
- receiver.copySelectionToClipboard();
349
- return;
350
- }
351
- const originalFlash = receiver.flash;
352
- receiver.flash = () => {};
353
- try {
354
- receiver.copySelectionToClipboard();
355
- } finally {
356
- receiver.flash = originalFlash;
357
- }
358
- }
359
-
360
361
  /**
361
362
  * Writes the draft's own text to the clipboard for a selection that lies inside
362
363
  * the input box, and reports whether it did — `false` means this selection was
363
364
  * not the editor's and Pi's own copy must run instead.
364
365
  *
365
- * This is the one place in the module that builds clipboard bytes rather than
366
- * letting predecessor build them, and it is deliberate: Pi copies *rendered*
367
- * rows, so a selection in the input box picks up the rail glyph, the padding
368
- * the frame fills each row out to, and a newline wherever the draft happened to
369
- * wrap. None of that is in the draft. The bytes go out the same way Pi's own
370
- * copy sends them — OSC 52 through `terminal.write`, see
371
- * `copySelectionToClipboard` in
372
- * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js` — so nothing
373
- * 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.
374
371
  *
375
- * The flash is unconditional, exactly as Pi's is. `copyNotice: false` is
376
- * honoured by `copyWithNotice`, which shadows `flash` around the whole call;
377
- * checking the setting here as well would suppress the notice twice on one path
378
- * and not at all on the other.
372
+ * The flash is gated on `copyNotice` at the write itself, exactly once — Pi's
373
+ * own copy flashes unconditionally, so this check is the whole notice path for
374
+ * a clean copy.
379
375
  */
380
376
  function editorTextForSelection(
381
- receiver: MouseCapablePrototype,
377
+ receiver: MouseCapableReceiver,
382
378
  config: PolishedTuiConfig,
383
379
  bounds: SelectionBounds,
384
380
  ): string | undefined {
@@ -387,16 +383,16 @@ function editorTextForSelection(
387
383
  // so without this a selection dropped on a dialog would be read as text
388
384
  // from the draft hidden behind it. Both callers ask it, or the hint could
389
385
  // count one text while the copy sends another.
390
- if (receiver.hasOverlay()) return undefined;
386
+ if (receiver.hasOverlay?.()) return undefined;
391
387
  return editorSelectionTextFor(receiver, config, bounds);
392
388
  } catch {
393
389
  return undefined;
394
390
  }
395
391
  }
396
392
 
397
- function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTuiConfig): boolean {
393
+ function copyEditorSelection(receiver: MouseCapableReceiver, config: PolishedTuiConfig): boolean {
398
394
  try {
399
- const bounds = receiver.getSelectionBounds();
395
+ const bounds = receiver.getSelectionBounds?.();
400
396
  if (!bounds) return false;
401
397
  const text = editorTextForSelection(receiver, config, bounds);
402
398
  // undefined is "not the editor's"; "" is the editor's and empty, which is
@@ -407,7 +403,7 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
407
403
  const write = receiver.terminal?.write;
408
404
  if (typeof write !== "function") return false;
409
405
  write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
410
- receiver.flash("Copied!");
406
+ if (config.mouse.copyNotice) receiver.flash?.("Copied!");
411
407
  return true;
412
408
  } catch {
413
409
  // Reading the editor and the layout is best effort. Anything this trips
@@ -432,18 +428,17 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
432
428
  * Column mapping: `leftTrim` columns came off the left of a cleaned row, so
433
429
  * the selection's start and end columns shift by the same amount on the rows
434
430
  * they touch. The shifted bounds go through the receiver's own
435
- * `getSelectionColumns`, which aligns both ends to grapheme cell ranges
436
- * (`tui-alt-screen.js:716`) — the end column can no more cut a grapheme here
437
- * 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.
438
433
  */
439
434
  function transcriptSelectionText(
440
- receiver: MouseCapablePrototype,
435
+ receiver: MouseCapableReceiver,
441
436
  config: PolishedTuiConfig,
442
437
  bounds: SelectionBounds,
443
438
  ): string | undefined {
444
439
  const scrollView = bounds.start.scrollView;
445
440
  if (!scrollView) return undefined;
446
- if (receiver.hasOverlay()) return undefined;
441
+ if (receiver.hasOverlay?.()) return undefined;
447
442
  const sourceLines = scrollContentLinesFor(receiver.currentLayout?.root, scrollView);
448
443
  if (!sourceLines) return undefined;
449
444
  const cleaned = cleanTranscriptRows(
@@ -470,7 +465,8 @@ function transcriptSelectionText(
470
465
  adjusted.end = { ...bounds.end, col: Math.max(0, bounds.end.col - entry.leftTrim) };
471
466
  }
472
467
  }
473
- const columns = receiver.getSelectionColumns(entry.text, row, adjusted);
468
+ const columns = receiver.getSelectionColumns?.(entry.text, row, adjusted);
469
+ if (!columns) return undefined;
474
470
  rows.push(
475
471
  sliceByColumn(
476
472
  entry.text,
@@ -488,19 +484,18 @@ function transcriptSelectionText(
488
484
  * carried recognised chrome, and reports whether it did — `false` means
489
485
  * there was nothing to clean and Pi's own copy must run instead. The bytes
490
486
  * go out the same way Pi's own copy sends them (OSC 52 through
491
- * `terminal.write`), for the same reason `copyEditorSelection` does: nothing
492
- * downstream can tell the two copies apart.
487
+ * `terminal.write`), for the same reason `copyEditorSelection` does.
493
488
  *
494
489
  * A selection over pure decoration cleans to "" and is consumed without a
495
490
  * clipboard write — Pi's own copy has the same shape (`if (text.length === 0)
496
491
  * return`), it just gets there after building a string of rails.
497
492
  */
498
493
  function copyTranscriptSelection(
499
- receiver: MouseCapablePrototype,
494
+ receiver: MouseCapableReceiver,
500
495
  config: PolishedTuiConfig,
501
496
  ): boolean {
502
497
  try {
503
- const bounds = receiver.getSelectionBounds();
498
+ const bounds = receiver.getSelectionBounds?.();
504
499
  if (!bounds) return false;
505
500
  const text = transcriptSelectionText(receiver, config, bounds);
506
501
  if (text === undefined) return false;
@@ -508,7 +503,7 @@ function copyTranscriptSelection(
508
503
  const write = receiver.terminal?.write;
509
504
  if (typeof write !== "function") return false;
510
505
  write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
511
- receiver.flash("Copied!");
506
+ if (config.mouse.copyNotice) receiver.flash?.("Copied!");
512
507
  return true;
513
508
  } catch {
514
509
  // Cleaning reads the layout and the selection, both best effort.
@@ -521,19 +516,15 @@ function copyTranscriptSelection(
521
516
  * Whether `data` is the backspace or delete the range delete acts on.
522
517
  *
523
518
  * The question is asked of Pi's own keybinding registry, against the two
524
- * bindings Pi's editor itself dispatches on (`editor.js:599-606`), so a user who
525
- * has rebound either gets the key they bound rather than a hardcoded byte. A
526
- * registry that disagrees — this repo has two copies of pi-tui, though
527
- * production resolves both to Pi's, see `expandKeyText` in `tool-box.ts` — makes
528
- * this return false, which falls through to Pi's own one-character delete. That
529
- * 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.
530
523
  *
531
524
  * ctrl+c and ctrl+d are refused outright, before the registry is consulted at
532
525
  * all. `tui.editor.deleteCharForward` binds `ctrl+d` by default, and ctrl+c and
533
526
  * ctrl+d are Pi's interrupt and exit chords; consuming either because a
534
527
  * selection happens to be live would break a global key to save a keystroke.
535
- * The ctrl+c branch above already returns before this is reached, but stating
536
- * it here means the guarantee does not depend on branch order.
537
528
  */
538
529
  function isRangeDeleteKey(data: unknown): boolean {
539
530
  if (typeof data !== "string" || data.length === 0) return false;
@@ -558,13 +549,13 @@ function isRangeDeleteKey(data: unknown): boolean {
558
549
  * Returns false for every selection that is not the input box's, which leaves
559
550
  * the key to Pi and an ordinary backspace deleting one character.
560
551
  */
561
- function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTuiConfig): boolean {
552
+ function deleteSelectedRange(receiver: MouseCapableReceiver, config: PolishedTuiConfig): boolean {
562
553
  try {
563
554
  if (!config.editorClickCursor) return false;
564
555
  // The same overlay question the rest of this feature asks: a selection
565
556
  // dropped on a dialog must not delete the draft behind it.
566
- if (receiver.hasOverlay()) return false;
567
- const bounds = receiver.getSelectionBounds();
557
+ if (receiver.hasOverlay?.()) return false;
558
+ const bounds = receiver.getSelectionBounds?.();
568
559
  if (!bounds) return false;
569
560
  if (!deleteEditorSelection(receiver, config, bounds)) return false;
570
561
  // The highlight described text that is gone. Pi clears these two fields
@@ -581,14 +572,14 @@ function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTu
581
572
  }
582
573
 
583
574
  /**
584
- * The text a copy of this selection would put on the clipboard, for the pending
585
- * hint's character count.
575
+ * The text a copy of this selection would put on the clipboard, for the hint's
576
+ * character count.
586
577
  *
587
578
  * It has to ask the same two paths the copy itself asks, in the same order, or
588
- * the hint promises a number ctrl+c does not deliver.
579
+ * the hint promises a number the copy does not deliver.
589
580
  */
590
581
  function pendingSelectionText(
591
- receiver: MouseCapablePrototype,
582
+ receiver: MouseCapableReceiver,
592
583
  config: PolishedTuiConfig,
593
584
  bounds: SelectionBounds,
594
585
  bufferCopy: boolean,
@@ -596,8 +587,8 @@ function pendingSelectionText(
596
587
  ): { text: string; inEditor: boolean } {
597
588
  const buffered = bufferCopy ? editorTextForSelection(receiver, config, bounds) : undefined;
598
589
  if (buffered !== undefined) return { text: buffered, inEditor: true };
599
- // The count the hint shows must be the number ctrl+c actually delivers, so
600
- // a selection the copy would clean is counted after cleaning.
590
+ // The count the hint shows must be the number the copy key actually
591
+ // delivers, so a selection the copy would clean is counted after cleaning.
601
592
  if (cleanCopy && config.mouse.transcriptCleanCopy) {
602
593
  try {
603
594
  const cleaned = transcriptSelectionText(receiver, config, bounds);
@@ -611,272 +602,72 @@ function pendingSelectionText(
611
602
  }
612
603
 
613
604
  /**
614
- * Installs the two features that live on `copySelectionToClipboard`.
615
- *
616
- * `selectionPendingMode` — arm on release, copy on ctrl+c — spans two patches:
617
- * the copy patch decides whether a release copies now or arms and waits, and
618
- * the `handleViewportInput` patch is what a waiting selection is eventually
619
- * released by.
620
- *
621
- * `editorBufferCopy` sits in front of the real copy on that same method. They
622
- * share one patch rather than taking a key each because
623
- * `installPrototypePatch` holds exactly one behaviour per adapter key: a second
624
- * registration under `mouse-copy` would silently replace the first, and two
625
- * different keys would leave the order they run in — and which of them gets to
626
- * consume the call — implicit. Here the precedence is written down: the buffer
627
- * copy answers first, and only when it declines does the pending mode or
628
- * predecessor see the call.
629
- *
630
- * Either feature can be off. With `selectionPendingMode` unavailable there is
631
- * no state and every call is a real copy; with `editorBufferCopy` unavailable
632
- * 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.
633
609
  */
634
- function installCopying(
635
- prototype: MouseCapablePrototype,
636
- deps: InstallMouseDeps,
637
- features: ReadonlySet<MouseFeature>,
638
- ): () => void {
639
- const bufferCopy = features.has("editorBufferCopy");
640
- const cleanCopy = features.has("transcriptCleanCopy");
641
- const rangeDelete = features.has("editorClickToCaret");
642
- const pendingMode = features.has("selectionPendingMode");
643
- const state = pendingMode ? new SelectionPendingState() : undefined;
644
- const previousState = activeState;
645
- if (state) activeState = state;
646
-
647
- // Set while this module is driving `copySelectionToClipboard` itself (the
648
- // ctrl+c path below), so the `mouse-copy` patch performs the copy instead of
649
- // re-arming the state it is itself in the middle of clearing.
650
- let performingRealCopy = false;
651
-
652
- const cleanups: Array<() => void> = [];
653
-
654
- // Only installed when a feature that answers `copySelectionToClipboard` is
655
- // on. With just `editorClickToCaret` the method is not ours to touch, and a
656
- // Pi that has moved it must not make this install throw.
657
- if (pendingMode || bufferCopy || cleanCopy) {
658
- cleanups.push(
659
- installPrototypePatch(
660
- prototype,
661
- "copySelectionToClipboard",
662
- "mouse-copy",
663
- ({ predecessor, receiver, args }) => {
664
- const typedReceiver = receiver as MouseCapablePrototype;
665
- const config = deps.getConfig();
666
- if (!state || config.mouse.copyOnSelect || performingRealCopy) {
667
- // The real copy, whether Pi triggered it directly on release
668
- // (`copyOnSelect: true`), this module is driving it itself for
669
- // ctrl+c below, or pending mode is not installed at all.
670
- if (bufferCopy && copyEditorSelection(typedReceiver, config)) return undefined;
671
- if (
672
- cleanCopy &&
673
- config.mouse.transcriptCleanCopy &&
674
- copyTranscriptSelection(typedReceiver, config)
675
- ) {
676
- return undefined;
677
- }
678
- return Reflect.apply(predecessor, receiver, args);
679
- }
680
- const bounds = typedReceiver.getSelectionBounds();
681
- if (!bounds) {
682
- // A collapsed or empty selection (e.g. a plain click after a prior
683
- // drag) still reaches this call — Pi runs it unconditionally on
684
- // every release and relies on its own `if (!selection) return;`
685
- // guard. Any stale arm from an earlier selection must not survive
686
- // this: left in place, it would make a later ctrl+c consume the
687
- // key for a no-op copy instead of falling through to interrupt.
688
- if (state.pending) {
689
- state.clear();
690
- typedReceiver.requestRender();
691
- }
692
- return undefined;
693
- }
694
- const pendingText = pendingSelectionText(
695
- typedReceiver,
696
- config,
697
- bounds,
698
- bufferCopy,
699
- cleanCopy,
700
- );
701
- state.arm(
702
- pendingText.text.length,
703
- // An editor selection cannot grow past the visible window —
704
- // there is no drag-scroll — so the hint for one points at the
705
- // external editor, the way to act on the whole draft. ""
706
- // (unbound) simply shows no suffix.
707
- pendingText.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
708
- );
709
- typedReceiver.requestRender();
710
- return undefined;
711
- },
712
- ),
713
- );
714
- }
715
-
716
- if (!state && !rangeDelete) {
717
- return () => {
718
- for (const cleanup of cleanups) cleanup();
719
- };
720
- }
721
-
722
- cleanups.push(
723
- installPrototypePatch(
724
- prototype,
725
- "handleViewportInput",
726
- "mouse-viewport-input",
727
- ({ predecessor, receiver, args }) => {
728
- const data = args[0];
729
- // Keystrokes and mouse events both land here, so this is the one
730
- // place that sees every draft change; keep the outgrew-the-box
731
- // hint current with it.
732
- refreshExternalEditorHint(receiver as MouseCapablePrototype, deps.getConfig());
733
- // ---- Branch 1: ctrl+c, `selectionPendingMode`'s. ----------------
734
- //
735
- // This branch is FIRST, and deliberately so. The one outcome that
736
- // must never regress is a bare ctrl+c with nothing pending reaching
737
- // Pi and interrupting; evaluating its branch before any other means
738
- // no feature added to this method later can intercept it by being
739
- // installed in a different order. `isRangeDeleteKey` refuses ctrl+c
740
- // and ctrl+d outright as well, so the guarantee holds structurally
741
- // from both directions rather than by ordering alone.
742
- if (state && isCtrlC(data) && state.pending) {
743
- const typedReceiver = receiver as MouseCapablePrototype;
744
- // `state.pending` can be stale: Pi clears its own selection
745
- // through paths this module never sees (e.g. starting a new
746
- // drag overwrites `selectionAnchor`/`selectionFocus` directly,
747
- // with no call to `copySelectionToClipboard`). Re-read the real
748
- // bounds before deciding: a copy that would be a no-op must not
749
- // consume ctrl+c, or an in-flight interrupt gets swallowed for
750
- // nothing.
751
- if (!typedReceiver.getSelectionBounds()) {
752
- state.clear();
753
- typedReceiver.requestRender();
754
- return Reflect.apply(predecessor, receiver, args);
755
- }
756
- performingRealCopy = true;
757
- try {
758
- copyWithNotice(typedReceiver, deps.getConfig().mouse.copyNotice);
759
- } finally {
760
- performingRealCopy = false;
761
- }
762
- state.clear();
763
- typedReceiver.requestRender();
764
- return { consume: true };
765
- }
766
- // ---- Branch 2: backspace and delete, `editorClickToCaret`'s. ----
767
- //
768
- // The two branches key off disjoint input — ctrl+c above, backspace
769
- // and delete here — so they compose rather than compete. Range
770
- // delete consumes the key only when it really removed something;
771
- // every other backspace falls through and goes on deleting one
772
- // character, which is the behaviour it must not break.
773
- if (rangeDelete && isRangeDeleteKey(data)) {
774
- const typedReceiver = receiver as MouseCapablePrototype;
775
- if (deleteSelectedRange(typedReceiver, deps.getConfig())) {
776
- // Pi's `handleInput` returns as soon as a listener consumes,
777
- // so the focused editor never sees the key and Pi never
778
- // reaches its own `requestImmediateRender` (`tui.js:620`).
779
- // Redrawing the changed draft is this branch's job.
780
- state?.clear();
781
- typedReceiver.requestRender();
782
- return { consume: true };
783
- }
784
- }
785
- return Reflect.apply(predecessor, receiver, args);
786
- },
787
- ),
788
- );
789
-
790
- return () => {
791
- for (const cleanup of cleanups) cleanup();
792
- if (activeState === state) activeState = previousState;
793
- };
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;
794
618
  }
795
619
 
796
620
  /**
797
- * Installs `pathAwareWords` on `getWordSelection`.
798
- *
799
- * The receiver's own `getSelectionSourceLine` gives the raw line under the
800
- * point; `stripTerminalSequences` is applied the same way predecessor itself
801
- * applies it (see `getWordSelection` in
802
- * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`), so
803
- * `wordRangeAt` sees the same plain text Pi's own segmenter would. `col` is
804
- * the only thing the wrapper changes on the way out — `row`, `scrollView`
805
- * and any other point field ride through unmodified, which is what keeps a
806
- * scroll-view selection landing in the right place.
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.
807
624
  */
808
- function installPathAwareWords(
809
- prototype: MouseCapablePrototype,
810
- deps: InstallMouseDeps,
811
- ): () => void {
812
- return installPrototypePatch(
813
- prototype,
814
- "getWordSelection",
815
- "mouse-word-selection",
816
- ({ predecessor, receiver, args }) => {
817
- if (!deps.getConfig().mouse.pathAwareWords) {
818
- return Reflect.apply(predecessor, receiver, args);
819
- }
820
- const typedReceiver = receiver as MouseCapablePrototype;
821
- const point = args[0] as SelectionPoint;
822
- const line = stripTerminalSequences(typedReceiver.getSelectionSourceLine(point));
823
- const range = wordRangeAt(line, point.col);
824
- if (!range) return Reflect.apply(predecessor, receiver, args);
825
- return {
826
- start: { ...point, col: range.start },
827
- end: { ...point, col: range.end, boundary: true },
828
- };
829
- },
830
- );
625
+ function isLeftButtonRelease(event: unknown): boolean {
626
+ if (typeof event !== "object" || event === null) return false;
627
+ const candidate = event as Partial<MouseEventLike>;
628
+ if (typeof candidate.button !== "number") return false;
629
+ if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
630
+ if (!candidate.release) return false;
631
+ if ((candidate.button & (MOTION_BIT | WHEEL_BIT)) !== 0) return false;
632
+ const button = candidate.button & BUTTON_MASK;
633
+ return button === LEFT_BUTTON || button === 3;
831
634
  }
832
635
 
833
636
  /**
834
- * A left-button press: not a release, not a drag, not another button.
835
- *
836
- * Pi's own handler returns immediately unless `(button & 3) === 0`, treats
837
- * `release` as the end of a drag and bit 32 as motion during one. Only the
838
- * press opens a selection, so only the press is the one this feature may take
839
- * instead.
840
- *
841
- * Bit 64 is excluded too, even though `handleInput` peels wheel reports off
842
- * before this method is reached (`parseWheelEvent` claims anything with bit 64
843
- * and a direction of 0 or 1): a notch of scroll that landed on a hint row
844
- * would otherwise expand a box the pointer was only passing over, and that
845
- * depends on a dispatch order in a file this package does not own.
846
- *
847
- * The shape is checked rather than assumed — this runs on whatever Pi passes,
848
- * and a malformed event must fall through, never throw.
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.
849
641
  */
850
- function isLeftButtonPress(event: unknown): event is MouseEventLike {
642
+ function isButtonMotion(event: unknown): boolean {
851
643
  if (typeof event !== "object" || event === null) return false;
852
644
  const candidate = event as Partial<MouseEventLike>;
853
645
  if (typeof candidate.button !== "number") return false;
854
- if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return false;
855
646
  if (candidate.release) return false;
856
- if ((candidate.button & (MOTION_BIT | WHEEL_BIT)) !== 0) return false;
857
- return (candidate.button & BUTTON_MASK) === LEFT_BUTTON;
647
+ return (candidate.button & MOTION_BIT) !== 0;
858
648
  }
859
649
 
860
650
  /**
861
- * The box a press should toggle, or undefined for every press that should go
862
- * 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.
863
654
  *
864
655
  * Everything is resolved here, inside the one call: the layout is read as it
865
- * is right now, the component tree is built and dropped, and nothing is
866
- * carried to the release. A tool that is still running re-renders between the
867
- * 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.
868
660
  */
869
- function pressExpandTarget(
870
- receiver: MouseCapablePrototype,
871
- event: unknown,
661
+ function expandClickTarget(
662
+ receiver: MouseCapableReceiver,
663
+ event: MouseEventLike,
872
664
  deps: InstallMouseDeps,
873
665
  ): ExpandTarget | undefined {
874
666
  try {
875
667
  if (!deps.getConfig().mouse.clickToExpandTools) return undefined;
876
- if (!isLeftButtonPress(event)) return undefined;
877
668
  // Pi resolves no scroll view while an overlay is up, so neither does this
878
669
  // — a click on a dialog must not reach the transcript behind it.
879
- if (receiver.hasOverlay()) return undefined;
670
+ if (receiver.hasOverlay?.()) return undefined;
880
671
  return expandTargetAt(
881
672
  { root: receiver.currentLayout?.root, keyText: expandKeyText() },
882
673
  event.x,
@@ -884,23 +675,32 @@ function pressExpandTarget(
884
675
  );
885
676
  } catch {
886
677
  // Resolution is a best-effort read of Pi's internals. Anything it trips
887
- // over means this press was an ordinary press.
678
+ // over means this click was an ordinary click.
888
679
  return undefined;
889
680
  }
890
681
  }
891
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): unknown {
690
+ return (receiver as { pressedUrl?: unknown }).pressedUrl;
691
+ }
692
+
892
693
  /**
893
694
  * Puts the caret where a press landed, when the press landed in the input box.
894
695
  *
895
696
  * Nothing is consumed: the press goes on to Pi, which drops its selection
896
697
  * anchor there exactly as it always did, so a drag from that point still
897
698
  * highlights and still copies. That is also what pays for the repaint — Pi's
898
- * own press branch ends in `requestRender()` unconditionally
899
- * (`tui-alt-screen.js:699`), so this feature never has to ask for one itself,
900
- * 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.
901
701
  */
902
702
  function moveCaretForPress(
903
- receiver: MouseCapablePrototype,
703
+ receiver: MouseCapableReceiver,
904
704
  event: unknown,
905
705
  deps: InstallMouseDeps,
906
706
  ): void {
@@ -910,8 +710,8 @@ function moveCaretForPress(
910
710
  if (!isLeftButtonPress(event)) return;
911
711
  // Pi resolves no scroll view while an overlay is up, so neither does this
912
712
  // — a click on a dialog must not move a caret in the box behind it.
913
- if (receiver.hasOverlay()) return;
914
- moveEditorCaretTo(receiver, config, event.x, event.y);
713
+ if (receiver.hasOverlay?.()) return;
714
+ moveEditorCaretTo(receiver, config, (event as MouseEventLike).x, (event as MouseEventLike).y);
915
715
  } catch {
916
716
  // Moving the caret is a best-effort read of Pi's internals and of an
917
717
  // editor this module did not necessarily build. Anything it trips over
@@ -919,80 +719,13 @@ function moveCaretForPress(
919
719
  }
920
720
  }
921
721
 
922
- /**
923
- * Installs the two features that live on `handleSelectionMouseEvent`.
924
- *
925
- * They share one patch because `installPrototypePatch` holds exactly one
926
- * behaviour per adapter key — a second registration under
927
- * `mouse-selection-event` would silently replace the first, and giving them a
928
- * key each would leave the order they run in implicit, which matters here
929
- * because only one of them may consume the press. Written as one patch the
930
- * precedence is explicit and testable:
931
- *
932
- * 1. `clickToExpandTools` gets the press first, and *consumes* it when it lands
933
- * on a tool box's hint row. That is what keeps the click from also dropping
934
- * a selection anchor into the box it just opened.
935
- * 2. `editorClickToCaret` gets every press expand did not take, moves the caret
936
- * if the press was inside the input box, and never consumes.
937
- * 3. Pi gets the press either way, unless expand took it.
938
- *
939
- * The two cannot collide in practice — the hint rows are in the transcript and
940
- * the caret rows are in the dock — but the ordering is what makes that a fact
941
- * rather than a hope, and either feature may be off without disturbing the
942
- * other.
943
- */
944
- function installSelectionMouse(
945
- prototype: MouseCapablePrototype,
946
- deps: InstallMouseDeps,
947
- features: ReadonlySet<MouseFeature>,
948
- ): () => void {
949
- const expandTools = features.has("clickToExpandTools");
950
- const clickToCaret = features.has("editorClickToCaret");
951
- return installPrototypePatch(
952
- prototype,
953
- "handleSelectionMouseEvent",
954
- "mouse-selection-event",
955
- ({ predecessor, receiver, args }) => {
956
- const typedReceiver = receiver as MouseCapablePrototype;
957
- if (expandTools) {
958
- const target = pressExpandTarget(typedReceiver, args[0], deps);
959
- if (target) {
960
- target.component.setExpanded(target.expanded);
961
- typedReceiver.requestRender();
962
- return undefined;
963
- }
964
- }
965
- if (clickToCaret) moveCaretForPress(typedReceiver, args[0], deps);
966
- return Reflect.apply(predecessor, receiver, args);
967
- },
968
- );
969
- }
970
-
971
- /**
972
- * The shape `parseWheelEvent` produces, checked rather than assumed — this runs
973
- * on whatever Pi hands `routeWheel`, and anything unrecognised must fall
974
- * through to Pi's own routing, never throw.
975
- */
976
- function isWheelEvent(event: unknown): event is WheelEventLike {
977
- if (typeof event !== "object" || event === null) return false;
978
- const candidate = event as Partial<WheelEventLike>;
979
- return (
980
- typeof candidate.direction === "number" &&
981
- typeof candidate.x === "number" &&
982
- typeof candidate.y === "number"
983
- );
984
- }
985
-
986
722
  /**
987
723
  * Scrolls the input box for this notch, or reports that the notch was not the
988
724
  * input box's — in which case Pi routes it as it always did.
989
725
  *
990
726
  * Pi does not scroll the editor: the editor's `scrollOffset` is re-derived from
991
- * the caret on every render, pulled back whenever the caret would fall outside
992
- * the visible window (`components/editor.js:392-401`). So a scroll here is an
993
- * offset *and* a caret move, which is what `scrollEditorBy` does — the same
994
- * function that has been in daily use since pi-powerline-footer, so a notch
995
- * 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.
996
729
  *
997
730
  * The window it scrolls within is Pi's own `max(5, rows * 0.3)`, taken from the
998
731
  * terminal rather than from the box's rect: the rect includes Starline's border
@@ -1001,25 +734,20 @@ function isWheelEvent(event: unknown): event is WheelEventLike {
1001
734
  * it from, so the notch falls through.
1002
735
  */
1003
736
  function scrollEditorForWheel(
1004
- receiver: MouseCapablePrototype,
1005
- event: unknown,
737
+ receiver: MouseCapableReceiver,
738
+ event: { direction: number; x: number; y: number },
1006
739
  deps: InstallMouseDeps,
1007
740
  ): boolean {
1008
741
  try {
1009
742
  if (!deps.getConfig().mouse.wheelRouting) return false;
1010
- if (!isWheelEvent(event)) return false;
1011
743
  // An overlay is composited over a layout that still contains the editor,
1012
744
  // so the box is still "under" a pointer aimed at the dialog on top of it.
1013
- if (receiver.hasOverlay()) return false;
745
+ if (receiver.hasOverlay?.()) return false;
1014
746
  const editor = activeEditor();
1015
747
  if (!editor) return false;
1016
748
  const rows = receiver.terminal?.rows;
1017
749
  if (typeof rows !== "number") return false;
1018
- if (
1019
- wheelTarget(receiver.currentLayout?.root, editor.component, event.x, event.y) !== "editor"
1020
- ) {
1021
- return false;
1022
- }
750
+ if (!pointerOverEditorBox(receiver, editor.component, event.x, event.y)) return false;
1023
751
  const lines = event.direction * Math.max(1, receiver.wheelScrollLines ?? 1);
1024
752
  return scrollEditorBy(editor.scrollable, lines, rows);
1025
753
  } catch {
@@ -1030,98 +758,184 @@ function scrollEditorForWheel(
1030
758
  }
1031
759
  }
1032
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
+
1033
771
  /**
1034
- * Installs `editorWheelScroll` on `routeWheel`.
1035
- *
1036
- * A thin wrapper with one job: a notch that landed on a draft taller than the
1037
- * input box scrolls the box and is consumed; everything else calls through and
1038
- * scrolls the transcript exactly as before. "Everything else" includes a notch
1039
- * over the transcript, a notch over an overlay, a draft that fits entirely, an
1040
- * editor this module cannot read, and `mouse.wheelRouting` being off.
1041
- *
1042
- * Reaching the top or bottom of a draft that *does* scroll is deliberately not
1043
- * in that list: the notch stays with the box. Chaining on to the transcript at
1044
- * the boundary would make the box feel like it slipped out from under the
1045
- * 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.
1046
783
  */
1047
- function installEditorWheelScroll(
1048
- prototype: MouseCapablePrototype,
784
+ export function installMouseFeaturesOn(
785
+ api: MouseEventsApi,
786
+ ctx: ExtensionContext,
1049
787
  deps: InstallMouseDeps,
1050
788
  ): () => void {
1051
- return installPrototypePatch(
1052
- prototype,
1053
- "routeWheel",
1054
- "mouse-wheel",
1055
- ({ predecessor, receiver, args }) => {
1056
- const typedReceiver = receiver as MouseCapablePrototype;
1057
- if (!scrollEditorForWheel(typedReceiver, args[0], deps)) {
1058
- return Reflect.apply(predecessor, receiver, args);
1059
- }
1060
- // Pi's own `routeWheel` repaints at the end of every notch; consuming
1061
- // the event means reaching that repaint is now this wrapper's job.
1062
- typedReceiver.requestRender();
1063
- return undefined;
1064
- },
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
+ ),
1065
855
  );
1066
- }
1067
856
 
1068
- /**
1069
- * Probes Pi, warns once about whatever this build cannot support, and
1070
- * installs the mouse features that are available. Returns a disposer that
1071
- * removes every patch this call installed.
1072
- */
1073
- export function installMouse(prototype: object, deps: InstallMouseDeps): () => void {
1074
- const available = probeCapabilities(prototype);
1075
- const enabled = enabledFeatures(available);
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
+ );
1076
876
 
1077
- const warning = disabledFeatureWarning(enabled);
1078
- if (warning && !hasWarned) {
1079
- hasWarned = true;
1080
- console.warn(warning);
1081
- }
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
+ );
1082
888
 
1083
- const typedPrototype = prototype as MouseCapablePrototype;
1084
- const cleanups: Array<() => void> = [];
1085
- // Frame-free selection is deliberately not installed, and is not a feature
1086
- // in `capabilities.ts` either. It rewrote the clipboard text to drop a tool
1087
- // box's border, which meant *inferring* where a frame was from the rendered
1088
- // rows — and a frame is not a structural concept in Pi. It is a visual
1089
- // convention `pi-toolbox` paints, so every discriminator (rule-capped
1090
- // edges, `setExpanded`, blank-row trimming, verticals) was a guess that a
1091
- // new component could falsify: `BashExecutionComponent` is rule-capped and
1092
- // expandable and draws no frame, so any box-drawn table inside bash output
1093
- // lost its borders.
1094
- //
1095
- // `transcriptCleanCopy` is the narrower successor that lesson produced: it
1096
- // removes only the exact markers two named renderers are known to paint
1097
- // (Starline's rail + full-width rule pairs, pi-toolbox's equal-width
1098
- // rounded-corner pairs), never inferred ones — a square-cornered markdown
1099
- // table, a blockquote gutter and bash output all pass through verbatim,
1100
- // and a selection with no recognised marker falls back to Pi's own copy
1101
- // untouched. The full frame-geometry version still wants what is written
1102
- // below: `pi-toolbox` publishing which rows it drew a border on, at which
1103
- // columns, so Starline reads a fact instead of inferring one.
1104
- if (
1105
- enabled.has("selectionPendingMode") ||
1106
- enabled.has("editorBufferCopy") ||
1107
- enabled.has("editorClickToCaret") ||
1108
- enabled.has("transcriptCleanCopy")
1109
- ) {
1110
- cleanups.push(installCopying(typedPrototype, deps, enabled));
1111
- }
1112
- if (enabled.has("pathAwareWords")) {
1113
- cleanups.push(installPathAwareWords(typedPrototype, deps));
1114
- }
1115
- if (enabled.has("clickToExpandTools") || enabled.has("editorClickToCaret")) {
1116
- cleanups.push(installSelectionMouse(typedPrototype, deps, enabled));
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
+ );
1117
911
  }
1118
- if (enabled.has("editorWheelScroll")) {
1119
- 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
+ );
1120
930
  }
1121
931
 
1122
- if (cleanups.length === 0) return () => {};
1123
932
  return () => {
1124
- externalEditorHint = null;
1125
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;
1126
940
  };
1127
941
  }