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