@xzzpig/pi-starline 0.1.0 → 0.2.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.
@@ -5,32 +5,22 @@
5
5
  * what it would do to it. `installMouse` probes what the running Pi build
6
6
  * exposes, logs once if something is missing, and installs each feature gated
7
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
8
+ * six features across four patches. Two of those methods carry two features
9
9
  * each, and in every case the two share a single patch, because the patch
10
10
  * registry holds one behaviour per adapter key and a second registration would
11
11
  * silently replace the first:
12
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.
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.
34
24
  *
35
25
  * `clickToExpandTools`, one patch:
36
26
  * - `handleSelectionMouseEvent` — watched for a left-button press that landed
@@ -63,7 +53,7 @@
63
53
  * interrupt and exit chords cannot be swallowed by it from either direction.
64
54
  * Every backspace it does not act on falls through and deletes one character.
65
55
  *
66
- * `editorBufferCopy`, sharing the `copySelectionToClipboard` patch:
56
+ * `editorBufferCopy`, sharing the `copyActiveSelectionToClipboard` patch:
67
57
  * - A selection lying inside the input box is copied from the *draft* rather
68
58
  * than from the rendered rows. That is the one place this module builds
69
59
  * clipboard bytes itself, and the reason is that the rendered rows are not
@@ -80,15 +70,10 @@
80
70
  * was clean before changes.
81
71
  *
82
72
  * Everywhere else the clipboard gets exactly what Pi's own
83
- * `copySelectionToClipboard` puts there — see `installMouse` for why frame-free
73
+ * `copyActiveSelectionToClipboard` puts there — see `installMouse` for why frame-free
84
74
  * selection is not part of this.
85
75
  */
86
- import {
87
- getKeybindings,
88
- matchesKey,
89
- sliceByColumn,
90
- stripTerminalSequences,
91
- } from "@earendil-works/pi-tui";
76
+ import { getKeybindings, sliceByColumn, stripTerminalSequences } from "@earendil-works/pi-tui";
92
77
  import type { PolishedTuiConfig } from "../config";
93
78
  import { installPrototypePatch } from "../prototype-patch-registry";
94
79
  import {
@@ -107,37 +92,17 @@ import { activeEditor, wheelTarget } from "./editor-mouse";
107
92
  import { scrollEditorBy } from "./editor-scroll";
108
93
  import { editorVisualRowCount } from "./editor-text-cursor";
109
94
  import { type BoxLike, scrollContentLinesFor } from "./hit-test";
110
- import { externalEditorName, SelectionPendingState, selectionHintText } from "./selection-state";
95
+ import { externalEditorName, selectionHintText } from "./selection-state";
111
96
  import { type ExpandTarget, expandKeyText, expandTargetAt, keyTextFor } from "./tool-box";
112
97
  import { cleanTranscriptRows } from "./transcript-copy";
113
- import { wordRangeAt } from "./word-select";
114
98
 
99
+ /** Pi's interrupt chord — refused by the range-delete branch, never consumed. */
115
100
  const CTRL_C = "\x03";
116
101
  /** Pi's exit chord, and `tui.editor.deleteCharForward`'s second default key. */
117
102
  const CTRL_D = "\x04";
118
103
 
119
- /**
120
- * Whether `data` is the ctrl+c chord, in whatever encoding the terminal sent
121
- * it. Pi 0.84 pushes the Kitty keyboard protocol (and falls back to xterm
122
- * modifyOtherKeys) on every capable terminal, and under either protocol the
123
- * chord arrives as an escape sequence — `\x1b[99;5u` or `\x1b[27;5;99~` —
124
- * never as the bare `\x03` a plain legacy terminal sends. The pending copy
125
- * must recognise all three, or ctrl+c falls through to Pi's own binding
126
- * (`app.clear`, clear the editor) exactly when it was about to copy. Pi's
127
- * own key matcher is the authority here: it parses every protocol this
128
- * build negotiates, and its `ctrl+c` arm accepts the raw control character
129
- * too, so a legacy terminal keeps working unchanged.
130
- */
131
- function isCtrlC(data: unknown): boolean {
132
- if (typeof data !== "string" || data.length === 0) return false;
133
- try {
134
- return matchesKey(data, "ctrl+c");
135
- } catch {
136
- // A pi-tui build that has moved `matchesKey` must not break the copy:
137
- // fall back to the raw byte, which is what this branch has always read.
138
- return data === CTRL_C;
139
- }
140
- }
104
+ /** The binding Pi's selection copy lives on (`app.message.copy`, default ctrl+x). */
105
+ const COPY_KEYBINDING = "app.message.copy";
141
106
 
142
107
  /** Opens the draft in `$EDITOR` — the hint an editor selection's hint carries. */
143
108
  const EXTERNAL_EDITOR_KEYBINDING = "app.editor.external";
@@ -171,11 +136,23 @@ type MouseCapablePrototype = {
171
136
  row: number,
172
137
  selection: SelectionBounds,
173
138
  ): SelectionColumns;
174
- copySelectionToClipboard(this: unknown): void;
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;
175
154
  handleViewportInput(this: unknown, data: string): { consume: boolean } | undefined;
176
155
  flash(this: unknown, message: string, durationMs?: number): void;
177
- getWordSelection(this: unknown, point: SelectionPoint): SelectionBounds | undefined;
178
- getSelectionSourceLine(this: unknown, point: SelectionPoint): string;
179
156
  hasOverlay(this: unknown): boolean;
180
157
  /**
181
158
  * `TuiAltScreen.routeWheel(event)` (`tui-alt-screen.js:375`). `event` is
@@ -203,7 +180,7 @@ type MouseCapablePrototype = {
203
180
  selectionFocus?: unknown;
204
181
  /**
205
182
  * `TuiBase.terminal` (`tui.js:101`); `rows` is a getter on it, and `write` is
206
- * how `copySelectionToClipboard` reaches the clipboard — it emits OSC 52
183
+ * how `copyActiveSelectionToClipboard` reaches the clipboard — it emits OSC 52
207
184
  * through the terminal rather than through any renderer method.
208
185
  */
209
186
  terminal?: { rows?: number; write?: (data: string) => void };
@@ -234,15 +211,72 @@ export type InstallMouseDeps = {
234
211
  let hasWarned = false;
235
212
 
236
213
  /**
237
- * Reader for the pending-selection state of whichever `installMouse` call is
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.
219
+ */
220
+ let activeReceiver: MouseCapablePrototype | undefined;
221
+
222
+ /**
223
+ * Reader for the derived selection hint of whichever `installMouse` call is
238
224
  * currently active, mirroring `pasteExpandHintText`'s pattern in
239
- * `../paste-collapse.ts`. `ui.ts` composes this with the paste hint
240
- * on every render; there is nothing to wire when no mouse install is active.
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.
227
+ *
228
+ * 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.
241
233
  */
242
- let activeState: SelectionPendingState | undefined;
234
+ let activeHint:
235
+ | { getConfig: () => PolishedTuiConfig; bufferCopy: boolean; cleanCopy: boolean }
236
+ | undefined;
243
237
 
244
238
  export function activeSelectionHintText(): string | null {
245
- return activeState ? selectionHintText(activeState, externalEditorName()) : null;
239
+ if (!activeHint || !activeReceiver) return null;
240
+ const { getConfig, bufferCopy, cleanCopy } = activeHint;
241
+ try {
242
+ // Pi auto-copies on release; a selection that is already on the
243
+ // clipboard needs no hint. `getCopyOnSelect()` is the live value, so a
244
+ // 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();
248
+ if (!bounds) return null;
249
+ const pending = pendingSelectionText(
250
+ activeReceiver,
251
+ getConfig(),
252
+ bounds,
253
+ bufferCopy,
254
+ cleanCopy,
255
+ );
256
+ return selectionHintText(
257
+ pending.text.length,
258
+ copyKeyText(),
259
+ // An editor selection cannot grow past the visible window — there is
260
+ // no drag-scroll — so the hint for one points at the external editor,
261
+ // the way to act on the whole draft. "" (unbound) shows no suffix.
262
+ pending.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
263
+ externalEditorName(),
264
+ );
265
+ } catch {
266
+ // The renderer is best effort; a receiver that has moved on offers no
267
+ // hint rather than breaking the metadata row.
268
+ return null;
269
+ }
270
+ }
271
+
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);
246
280
  }
247
281
 
248
282
  /**
@@ -283,10 +317,10 @@ function refreshExternalEditorHint(
283
317
  }
284
318
 
285
319
  /**
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
320
+ * The exact text Pi's selection copy would produce, built the same way it
321
+ * builds it: per row, through the receiver's own `getSelectionColumns`, then
322
+ * `sliceByColumn` and `stripTerminalSequences` (both exported by pi-tui),
323
+ * joined with "\n" — see `copyActiveSelectionToClipboard` in
290
324
  * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`. Reusing Pi's
291
325
  * own helpers instead of re-deriving the column math is what keeps this exact
292
326
  * rather than an estimate. The scroll-view case needs the box behind
@@ -321,34 +355,6 @@ function selectionText(receiver: MouseCapablePrototype, bounds: SelectionBounds)
321
355
  return rows.join("\n");
322
356
  }
323
357
 
324
- /**
325
- * Runs `copySelectionToClipboard` (Pi's real one, reached through the
326
- * receiver so the `mouse-copy` patch's re-entrancy guard applies) with its
327
- * own "Copied!" flash suppressed when `copyNotice` is off.
328
- *
329
- * Pi's copy method flashes unconditionally — see
330
- * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`. Respecting
331
- * `copyNotice: false` therefore means shadowing `flash` on the receiver for
332
- * the duration of this one call and restoring it immediately after, the same
333
- * shadow-and-restore shape `installPasteCollapse` already uses on `handlePaste`.
334
- * `receiver` here is Pi's own instance (the wrapper runs inside Pi's own
335
- * method call, not through the extension-facing Proxy), so a plain
336
- * assignment is safe.
337
- */
338
- function copyWithNotice(receiver: MouseCapablePrototype, showNotice: boolean): void {
339
- if (showNotice) {
340
- receiver.copySelectionToClipboard();
341
- return;
342
- }
343
- const originalFlash = receiver.flash;
344
- receiver.flash = () => {};
345
- try {
346
- receiver.copySelectionToClipboard();
347
- } finally {
348
- receiver.flash = originalFlash;
349
- }
350
- }
351
-
352
358
  /**
353
359
  * Writes the draft's own text to the clipboard for a selection that lies inside
354
360
  * the input box, and reports whether it did — `false` means this selection was
@@ -360,14 +366,13 @@ function copyWithNotice(receiver: MouseCapablePrototype, showNotice: boolean): v
360
366
  * the frame fills each row out to, and a newline wherever the draft happened to
361
367
  * wrap. None of that is in the draft. The bytes go out the same way Pi's own
362
368
  * copy sends them — OSC 52 through `terminal.write`, see
363
- * `copySelectionToClipboard` in
369
+ * `copyActiveSelectionToClipboard` in
364
370
  * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js` — so nothing
365
371
  * downstream can tell the two copies apart.
366
372
  *
367
- * The flash is unconditional, exactly as Pi's is. `copyNotice: false` is
368
- * honoured by `copyWithNotice`, which shadows `flash` around the whole call;
369
- * checking the setting here as well would suppress the notice twice on one path
370
- * and not at all on the other.
373
+ * The flash is gated on `copyNotice` at the write itself, exactly once — Pi's
374
+ * own copy flashes unconditionally, so this check is the whole notice path for
375
+ * a clean copy.
371
376
  */
372
377
  function editorTextForSelection(
373
378
  receiver: MouseCapablePrototype,
@@ -399,7 +404,7 @@ function copyEditorSelection(receiver: MouseCapablePrototype, config: PolishedTu
399
404
  const write = receiver.terminal?.write;
400
405
  if (typeof write !== "function") return false;
401
406
  write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
402
- receiver.flash("Copied!");
407
+ if (config.mouse.copyNotice) receiver.flash("Copied!");
403
408
  return true;
404
409
  } catch {
405
410
  // Reading the editor and the layout is best effort. Anything this trips
@@ -500,7 +505,7 @@ function copyTranscriptSelection(
500
505
  const write = receiver.terminal?.write;
501
506
  if (typeof write !== "function") return false;
502
507
  write.call(receiver.terminal, `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
503
- receiver.flash("Copied!");
508
+ if (config.mouse.copyNotice) receiver.flash("Copied!");
504
509
  return true;
505
510
  } catch {
506
511
  // Cleaning reads the layout and the selection, both best effort.
@@ -524,8 +529,6 @@ function copyTranscriptSelection(
524
529
  * all. `tui.editor.deleteCharForward` binds `ctrl+d` by default, and ctrl+c and
525
530
  * ctrl+d are Pi's interrupt and exit chords; consuming either because a
526
531
  * selection happens to be live would break a global key to save a keystroke.
527
- * The ctrl+c branch above already returns before this is reached, but stating
528
- * it here means the guarantee does not depend on branch order.
529
532
  */
530
533
  function isRangeDeleteKey(data: unknown): boolean {
531
534
  if (typeof data !== "string" || data.length === 0) return false;
@@ -573,11 +576,11 @@ function deleteSelectedRange(receiver: MouseCapablePrototype, config: PolishedTu
573
576
  }
574
577
 
575
578
  /**
576
- * The text a copy of this selection would put on the clipboard, for the pending
577
- * hint's character count.
579
+ * The text a copy of this selection would put on the clipboard, for the hint's
580
+ * character count.
578
581
  *
579
582
  * It has to ask the same two paths the copy itself asks, in the same order, or
580
- * the hint promises a number ctrl+c does not deliver.
583
+ * the hint promises a number the copy does not deliver.
581
584
  */
582
585
  function pendingSelectionText(
583
586
  receiver: MouseCapablePrototype,
@@ -588,8 +591,8 @@ function pendingSelectionText(
588
591
  ): { text: string; inEditor: boolean } {
589
592
  const buffered = bufferCopy ? editorTextForSelection(receiver, config, bounds) : undefined;
590
593
  if (buffered !== undefined) return { text: buffered, inEditor: true };
591
- // The count the hint shows must be the number ctrl+c actually delivers, so
592
- // a selection the copy would clean is counted after cleaning.
594
+ // The count the hint shows must be the number the copy key actually
595
+ // delivers, so a selection the copy would clean is counted after cleaning.
593
596
  if (cleanCopy && config.mouse.transcriptCleanCopy) {
594
597
  try {
595
598
  const cleaned = transcriptSelectionText(receiver, config, bounds);
@@ -603,25 +606,24 @@ function pendingSelectionText(
603
606
  }
604
607
 
605
608
  /**
606
- * Installs the two features that live on `copySelectionToClipboard`.
607
- *
608
- * `selectionPendingMode` — arm on release, copy on ctrl+c — spans two patches:
609
- * the copy patch decides whether a release copies now or arms and waits, and
610
- * the `handleViewportInput` patch is what a waiting selection is eventually
611
- * released by.
612
- *
613
- * `editorBufferCopy` sits in front of the real copy on that same method. They
614
- * share one patch rather than taking a key each because
615
- * `installPrototypePatch` holds exactly one behaviour per adapter key: a second
616
- * registration under `mouse-copy` would silently replace the first, and two
617
- * different keys would leave the order they run in — and which of them gets to
618
- * consume the call — implicit. Here the precedence is written down: the buffer
619
- * copy answers first, and only when it declines does the pending mode or
620
- * predecessor see the call.
621
- *
622
- * Either feature can be off. With `selectionPendingMode` unavailable there is
623
- * no state and every call is a real copy; with `editorBufferCopy` unavailable
624
- * the clipboard is Pi's own bytes, exactly as before.
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.
625
627
  */
626
628
  function installCopying(
627
629
  prototype: MouseCapablePrototype,
@@ -631,81 +633,40 @@ function installCopying(
631
633
  const bufferCopy = features.has("editorBufferCopy");
632
634
  const cleanCopy = features.has("transcriptCleanCopy");
633
635
  const rangeDelete = features.has("editorClickToCaret");
634
- const pendingMode = features.has("selectionPendingMode");
635
- const state = pendingMode ? new SelectionPendingState() : undefined;
636
- const previousState = activeState;
637
- if (state) activeState = state;
638
-
639
- // Set while this module is driving `copySelectionToClipboard` itself (the
640
- // ctrl+c path below), so the `mouse-copy` patch performs the copy instead of
641
- // re-arming the state it is itself in the middle of clearing.
642
- let performingRealCopy = false;
636
+ const hint = features.has("selectionHint");
637
+ const previousHint = activeHint;
638
+ if (hint) activeHint = { getConfig: deps.getConfig, bufferCopy, cleanCopy };
643
639
 
644
640
  const cleanups: Array<() => void> = [];
645
641
 
646
- // Only installed when a feature that answers `copySelectionToClipboard` is
647
- // on. With just `editorClickToCaret` the method is not ours to touch, and a
648
- // Pi that has moved it must not make this install throw.
649
- if (pendingMode || bufferCopy || cleanCopy) {
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) {
650
647
  cleanups.push(
651
648
  installPrototypePatch(
652
649
  prototype,
653
- "copySelectionToClipboard",
650
+ "copyActiveSelectionToClipboard",
654
651
  "mouse-copy",
655
652
  ({ predecessor, receiver, args }) => {
656
653
  const typedReceiver = receiver as MouseCapablePrototype;
657
654
  const config = deps.getConfig();
658
- if (!state || config.mouse.copyOnSelect || performingRealCopy) {
659
- // The real copy, whether Pi triggered it directly on release
660
- // (`copyOnSelect: true`), this module is driving it itself for
661
- // ctrl+c below, or pending mode is not installed at all.
662
- if (bufferCopy && copyEditorSelection(typedReceiver, config)) return undefined;
663
- if (
664
- cleanCopy &&
665
- config.mouse.transcriptCleanCopy &&
666
- copyTranscriptSelection(typedReceiver, config)
667
- ) {
668
- return undefined;
669
- }
670
- return Reflect.apply(predecessor, receiver, args);
655
+ if (bufferCopy && copyEditorSelection(typedReceiver, config)) return true;
656
+ if (
657
+ cleanCopy &&
658
+ config.mouse.transcriptCleanCopy &&
659
+ copyTranscriptSelection(typedReceiver, config)
660
+ ) {
661
+ return true;
671
662
  }
672
- const bounds = typedReceiver.getSelectionBounds();
673
- if (!bounds) {
674
- // A collapsed or empty selection (e.g. a plain click after a prior
675
- // drag) still reaches this call — Pi runs it unconditionally on
676
- // every release and relies on its own `if (!selection) return;`
677
- // guard. Any stale arm from an earlier selection must not survive
678
- // this: left in place, it would make a later ctrl+c consume the
679
- // key for a no-op copy instead of falling through to interrupt.
680
- if (state.pending) {
681
- state.clear();
682
- typedReceiver.requestRender();
683
- }
684
- return undefined;
685
- }
686
- const pendingText = pendingSelectionText(
687
- typedReceiver,
688
- config,
689
- bounds,
690
- bufferCopy,
691
- cleanCopy,
692
- );
693
- state.arm(
694
- pendingText.text.length,
695
- // An editor selection cannot grow past the visible window —
696
- // there is no drag-scroll — so the hint for one points at the
697
- // external editor, the way to act on the whole draft. ""
698
- // (unbound) simply shows no suffix.
699
- pendingText.inEditor ? keyTextFor(EXTERNAL_EDITOR_KEYBINDING) : undefined,
700
- );
701
- typedReceiver.requestRender();
702
- return undefined;
663
+ return Reflect.apply(predecessor, receiver, args);
703
664
  },
704
665
  ),
705
666
  );
706
667
  }
707
668
 
708
- if (!state && !rangeDelete) {
669
+ if (!hint && !rangeDelete) {
709
670
  return () => {
710
671
  for (const cleanup of cleanups) cleanup();
711
672
  };
@@ -718,58 +679,27 @@ function installCopying(
718
679
  "mouse-viewport-input",
719
680
  ({ predecessor, receiver, args }) => {
720
681
  const data = args[0];
682
+ const typedReceiver = receiver as MouseCapablePrototype;
721
683
  // Keystrokes and mouse events both land here, so this is the one
722
684
  // place that sees every draft change; keep the outgrew-the-box
723
- // hint current with it.
724
- refreshExternalEditorHint(receiver as MouseCapablePrototype, deps.getConfig());
725
- // ---- Branch 1: ctrl+c, `selectionPendingMode`'s. ----------------
726
- //
727
- // This branch is FIRST, and deliberately so. The one outcome that
728
- // must never regress is a bare ctrl+c with nothing pending reaching
729
- // Pi and interrupting; evaluating its branch before any other means
730
- // no feature added to this method later can intercept it by being
731
- // installed in a different order. `isRangeDeleteKey` refuses ctrl+c
732
- // and ctrl+d outright as well, so the guarantee holds structurally
733
- // from both directions rather than by ordering alone.
734
- if (state && isCtrlC(data) && state.pending) {
735
- const typedReceiver = receiver as MouseCapablePrototype;
736
- // `state.pending` can be stale: Pi clears its own selection
737
- // through paths this module never sees (e.g. starting a new
738
- // drag overwrites `selectionAnchor`/`selectionFocus` directly,
739
- // with no call to `copySelectionToClipboard`). Re-read the real
740
- // bounds before deciding: a copy that would be a no-op must not
741
- // consume ctrl+c, or an in-flight interrupt gets swallowed for
742
- // nothing.
743
- if (!typedReceiver.getSelectionBounds()) {
744
- state.clear();
745
- typedReceiver.requestRender();
746
- return Reflect.apply(predecessor, receiver, args);
747
- }
748
- performingRealCopy = true;
749
- try {
750
- copyWithNotice(typedReceiver, deps.getConfig().mouse.copyNotice);
751
- } finally {
752
- performingRealCopy = false;
753
- }
754
- state.clear();
755
- typedReceiver.requestRender();
756
- return { consume: true };
757
- }
758
- // ---- Branch 2: backspace and delete, `editorClickToCaret`'s. ----
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. ------------
759
690
  //
760
- // The two branches key off disjoint input — ctrl+c above, backspace
761
- // and delete here — so they compose rather than compete. Range
762
- // delete consumes the key only when it really removed something;
763
- // every other backspace falls through and goes on deleting one
764
- // character, which is the behaviour it must not break.
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.
765
697
  if (rangeDelete && isRangeDeleteKey(data)) {
766
- const typedReceiver = receiver as MouseCapablePrototype;
767
698
  if (deleteSelectedRange(typedReceiver, deps.getConfig())) {
768
699
  // Pi's `handleInput` returns as soon as a listener consumes,
769
700
  // so the focused editor never sees the key and Pi never
770
701
  // reaches its own `requestImmediateRender` (`tui.js:620`).
771
702
  // Redrawing the changed draft is this branch's job.
772
- state?.clear();
773
703
  typedReceiver.requestRender();
774
704
  return { consume: true };
775
705
  }
@@ -781,47 +711,9 @@ function installCopying(
781
711
 
782
712
  return () => {
783
713
  for (const cleanup of cleanups) cleanup();
784
- if (activeState === state) activeState = previousState;
785
714
  };
786
715
  }
787
716
 
788
- /**
789
- * Installs `pathAwareWords` on `getWordSelection`.
790
- *
791
- * The receiver's own `getSelectionSourceLine` gives the raw line under the
792
- * point; `stripTerminalSequences` is applied the same way predecessor itself
793
- * applies it (see `getWordSelection` in
794
- * `node_modules/@earendil-works/pi-tui/dist/tui-alt-screen.js`), so
795
- * `wordRangeAt` sees the same plain text Pi's own segmenter would. `col` is
796
- * the only thing the wrapper changes on the way out — `row`, `scrollView`
797
- * and any other point field ride through unmodified, which is what keeps a
798
- * scroll-view selection landing in the right place.
799
- */
800
- function installPathAwareWords(
801
- prototype: MouseCapablePrototype,
802
- deps: InstallMouseDeps,
803
- ): () => void {
804
- return installPrototypePatch(
805
- prototype,
806
- "getWordSelection",
807
- "mouse-word-selection",
808
- ({ predecessor, receiver, args }) => {
809
- if (!deps.getConfig().mouse.pathAwareWords) {
810
- return Reflect.apply(predecessor, receiver, args);
811
- }
812
- const typedReceiver = receiver as MouseCapablePrototype;
813
- const point = args[0] as SelectionPoint;
814
- const line = stripTerminalSequences(typedReceiver.getSelectionSourceLine(point));
815
- const range = wordRangeAt(line, point.col);
816
- if (!range) return Reflect.apply(predecessor, receiver, args);
817
- return {
818
- start: { ...point, col: range.start },
819
- end: { ...point, col: range.end, boundary: true },
820
- };
821
- },
822
- );
823
- }
824
-
825
717
  /**
826
718
  * A left-button press: not a release, not a drag, not another button.
827
719
  *
@@ -1094,16 +986,13 @@ export function installMouse(prototype: object, deps: InstallMouseDeps): () => v
1094
986
  // below: `pi-toolbox` publishing which rows it drew a border on, at which
1095
987
  // columns, so Starline reads a fact instead of inferring one.
1096
988
  if (
1097
- enabled.has("selectionPendingMode") ||
989
+ enabled.has("selectionHint") ||
1098
990
  enabled.has("editorBufferCopy") ||
1099
991
  enabled.has("editorClickToCaret") ||
1100
992
  enabled.has("transcriptCleanCopy")
1101
993
  ) {
1102
994
  cleanups.push(installCopying(typedPrototype, deps, enabled));
1103
995
  }
1104
- if (enabled.has("pathAwareWords")) {
1105
- cleanups.push(installPathAwareWords(typedPrototype, deps));
1106
- }
1107
996
  if (enabled.has("clickToExpandTools") || enabled.has("editorClickToCaret")) {
1108
997
  cleanups.push(installSelectionMouse(typedPrototype, deps, enabled));
1109
998
  }
@@ -1114,6 +1003,8 @@ export function installMouse(prototype: object, deps: InstallMouseDeps): () => v
1114
1003
  if (cleanups.length === 0) return () => {};
1115
1004
  return () => {
1116
1005
  externalEditorHint = null;
1006
+ activeReceiver = undefined;
1007
+ activeHint = undefined;
1117
1008
  for (const cleanup of cleanups) cleanup();
1118
1009
  };
1119
1010
  }