@xzzpig/pi-starline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. package/package.json +60 -0
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Where the draft's text is on screen, and how to read it back out.
3
+ *
4
+ * Two features share this module because they share one arithmetic problem:
5
+ * turning a screen cell into a position in the editor's own buffer.
6
+ * `editorClickToCaret` needs it for a single press; `editorBufferCopy` needs it
7
+ * for the two ends of a selection.
8
+ *
9
+ * ## Why the box's rectangle is not the text
10
+ *
11
+ * `editorBoxFor` (`editor-mouse.ts`) finds the box covering the editor's rows,
12
+ * but those rows are a *frame*: Starline's `renderPolishedFrame` draws a rule,
13
+ * an optional padding row, the text rows each prefixed with the rail, another
14
+ * padding row, the metadata row and a closing rule — and pi-tui's own
15
+ * `Editor.render` supplies the rules and, when focused, the reverse-video
16
+ * cursor cell inside them. So the first text row is `rect.y + 1 + paddingY`,
17
+ * and the first text *column* is past the rail, which is what the
18
+ * `editorTextColumn` helper (moved here from the fixed editor) measures.
19
+ *
20
+ * The bottom of the text cannot be read off the rectangle at all: the box's
21
+ * height also covers the autocomplete list, which appears and disappears under
22
+ * the frame. It comes instead from the editor itself — how many visual lines it
23
+ * has, less how far it is scrolled, capped by Pi's own window of
24
+ * `max(5, rows * 0.3)`.
25
+ *
26
+ * ## Why positions are logical, not visual
27
+ *
28
+ * A screen row is a *visual* line — the editor's word wrapping has already been
29
+ * applied to it. The buffer knows those rows are one logical line. Mapping
30
+ * through to logical `{ line, column }` before slicing is what keeps a copied
31
+ * wrapped paragraph one paragraph instead of a stack of hard-wrapped fragments,
32
+ * and it is the half of `editorBufferCopy` that shows up on every draft rather
33
+ * than only on tall ones.
34
+ */
35
+
36
+ import type { PolishedTuiConfig } from "../config";
37
+ import { editorTextColumn } from "./editor-hit-test";
38
+ import { activeEditor, editorBoxFor, pointerOverEditor } from "./editor-mouse";
39
+ import { editorVisibleLines } from "./editor-scroll";
40
+ import {
41
+ editorScrollOffset,
42
+ editorVisualRowCount,
43
+ resolveEditorTextPointAt,
44
+ } from "./editor-text-cursor";
45
+ import type { BoxLike } from "./hit-test";
46
+
47
+ /** A position in the editor's own buffer: which logical line, and where in it. */
48
+ export type CaretPosition = { line: number; column: number };
49
+
50
+ /** Where the editor's text rows are on screen, and what they are showing. */
51
+ export type EditorViewport = {
52
+ /** Screen row of the first text row. */
53
+ contentTop: number;
54
+ /** How many text rows the box is showing right now. */
55
+ contentRows: number;
56
+ /** Screen column of the box's left edge. */
57
+ left: number;
58
+ /** Columns the rail or prompt takes before the text starts. */
59
+ textColumn: number;
60
+ /** The absolute visual row drawn at `contentTop`. */
61
+ scrollOffset: number;
62
+ /** Screen rows the box itself occupies, borders and metadata included. */
63
+ boxTop: number;
64
+ boxBottom: number;
65
+ };
66
+
67
+ export type EditorViewportOptions = {
68
+ /** `config.editorPaddingY` — 0 or 1 blank rows inside the frame. */
69
+ paddingY: number;
70
+ /** `editorTextColumn(...)`, passed in so the mapping stays a pure function. */
71
+ textColumn: number;
72
+ /** The terminal's row count, for Pi's own visible-line formula. */
73
+ terminalRows: number;
74
+ };
75
+
76
+ export function editorViewport(
77
+ editor: unknown,
78
+ box: BoxLike | undefined,
79
+ options: EditorViewportOptions,
80
+ ): EditorViewport | undefined {
81
+ if (!box) return undefined;
82
+ const visualRows = editorVisualRowCount(editor);
83
+ // Zero means either an empty map or an editor this module cannot read; both
84
+ // leave nothing to map a click onto.
85
+ if (visualRows === 0) return undefined;
86
+
87
+ const scrollOffset = Math.min(editorScrollOffset(editor), visualRows - 1);
88
+ const contentTop = box.rect.y + 1 + (options.paddingY > 0 ? 1 : 0);
89
+ const contentRows = Math.min(
90
+ visualRows - scrollOffset,
91
+ editorVisibleLines(options.terminalRows),
92
+ // A dock squeezed against the top of the viewport draws fewer rows than
93
+ // the editor rendered. Never claim a row the box does not cover.
94
+ box.rect.y + box.rect.height - contentTop,
95
+ );
96
+ if (contentRows <= 0) return undefined;
97
+
98
+ return {
99
+ contentTop,
100
+ contentRows,
101
+ left: box.rect.x,
102
+ textColumn: Math.max(0, options.textColumn),
103
+ scrollOffset,
104
+ boxTop: box.rect.y,
105
+ boxBottom: box.rect.y + box.rect.height - 1,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The buffer position under a screen cell, or undefined when the cell is not
111
+ * one of the editor's text rows.
112
+ *
113
+ * Only the row is rejected out of range; the column is clamped. A press on the
114
+ * rail lands on column 0 rather than on a negative one, and a press past the
115
+ * end of a line lands on its end — both are what every editor does with a click
116
+ * in the margin, and both are what a selection's own ends need when the drag
117
+ * ran off the side of the box.
118
+ */
119
+ export function caretPositionAt(
120
+ editor: unknown,
121
+ viewport: EditorViewport,
122
+ x: number,
123
+ y: number,
124
+ ): CaretPosition | undefined {
125
+ const row = y - viewport.contentTop;
126
+ if (row < 0 || row >= viewport.contentRows) return undefined;
127
+ const column = Math.max(0, x - viewport.left - viewport.textColumn);
128
+ const point = resolveEditorTextPointAt(editor, viewport.scrollOffset + row, column);
129
+ return point ? { line: point.line, column: point.index } : undefined;
130
+ }
131
+
132
+ /** The editor's buffer, however this editor exposes it. */
133
+ function editorLines(value: unknown): readonly string[] | undefined {
134
+ if (typeof value !== "object" || value === null) return undefined;
135
+ const editor = value as {
136
+ getLines?: () => string[];
137
+ state?: { lines?: string[] };
138
+ };
139
+ try {
140
+ if (typeof editor.getLines === "function") {
141
+ const lines = editor.getLines();
142
+ if (Array.isArray(lines)) return lines;
143
+ }
144
+ } catch {
145
+ // An editor whose accessor throws is one we fall back to reading directly.
146
+ }
147
+ const lines = editor.state?.lines;
148
+ return Array.isArray(lines) ? lines : undefined;
149
+ }
150
+
151
+ function comparePositions(a: CaretPosition, b: CaretPosition): number {
152
+ return a.line === b.line ? a.column - b.column : a.line - b.line;
153
+ }
154
+
155
+ function clamp(value: number, min: number, max: number): number {
156
+ return Math.max(min, Math.min(value, max));
157
+ }
158
+
159
+ /**
160
+ * The draft's own text between two buffer positions, whole lines in between.
161
+ *
162
+ * `to` may come before `from` — a drag upwards produces exactly that — so the
163
+ * two ends are ordered here rather than at every call site. Lines past the end
164
+ * of the buffer contribute nothing: this runs on positions resolved from an
165
+ * earlier frame, and the draft may have changed since.
166
+ */
167
+ export function editorSelectionText(
168
+ editor: unknown,
169
+ from: CaretPosition,
170
+ to: CaretPosition,
171
+ ): string {
172
+ const lines = editorLines(editor);
173
+ if (!lines || lines.length === 0) return "";
174
+
175
+ const [start, end] = comparePositions(from, to) <= 0 ? [from, to] : [to, from];
176
+ if (start.line >= lines.length) return "";
177
+
178
+ const last = Math.min(end.line, lines.length - 1);
179
+ const rows: string[] = [];
180
+ for (let line = Math.max(0, start.line); line <= last; line++) {
181
+ const text = lines[line] ?? "";
182
+ const begin = line === start.line ? clamp(start.column, 0, text.length) : 0;
183
+ const finish = line === end.line ? clamp(end.column, 0, text.length) : text.length;
184
+ rows.push(text.slice(begin, Math.max(begin, finish)));
185
+ }
186
+ return rows.join("\n");
187
+ }
188
+
189
+ /** The shape of `TuiAltScreen.getSelectionBounds()`, as much as is read here. */
190
+ export type SelectionBoundsLike = {
191
+ start: { scrollView?: unknown; row: number; col: number };
192
+ end: { scrollView?: unknown; row: number; col: number; boundary?: boolean };
193
+ };
194
+
195
+ /**
196
+ * The buffer position for one end of a screen selection, with one concession
197
+ * `caretPositionAt` does not make: a drag that ran off the text rows but is
198
+ * still inside the box clamps to the text instead of declining. Releasing on
199
+ * the bottom border (or the metadata row) means "through the end of the
200
+ * visible text", and releasing on the top border means "from its start" —
201
+ * the column on a border row selects no text, so it is discarded. A row
202
+ * outside the box altogether — the transcript above, the footer below —
203
+ * still declines: that selection is not purely the editor's, and Pi copies
204
+ * it as screen text exactly as before.
205
+ *
206
+ * The end column follows Pi's own rule in `getSelectionColumns`: a character
207
+ * selection covers the glyph under `end.col`, so the exclusive end is one
208
+ * column further right, while a word or line selection arrives with
209
+ * `boundary: true` and already points past its last glyph. Converting a column
210
+ * rather than an index is what keeps that correct for wide glyphs —
211
+ * `displayColumnToStringIndex` walks by display width, so asking for the column
212
+ * after a two-cell glyph lands after the glyph, exactly as
213
+ * `getGraphemeCellRange` would.
214
+ */
215
+ function selectionEndPosition(
216
+ editor: unknown,
217
+ viewport: EditorViewport,
218
+ point: { row: number; col: number; boundary?: boolean },
219
+ isEnd: boolean,
220
+ ): CaretPosition | undefined {
221
+ const lastRow = viewport.contentTop + viewport.contentRows - 1;
222
+ let row = point.row;
223
+ let col = isEnd ? (point.boundary ? point.col : point.col + 1) : point.col;
224
+ if (row < viewport.contentTop) {
225
+ if (row < viewport.boxTop) return undefined;
226
+ row = viewport.contentTop;
227
+ col = 0;
228
+ } else if (row > lastRow) {
229
+ if (row > viewport.boxBottom) return undefined;
230
+ row = lastRow;
231
+ col = viewport.left + viewport.textColumn + Number.MAX_SAFE_INTEGER;
232
+ }
233
+ return caretPositionAt(editor, viewport, col, row);
234
+ }
235
+
236
+ /**
237
+ * The buffer range a screen selection covers, or undefined when the selection
238
+ * is not the editor's to interpret. "Not the editor's" is: a selection
239
+ * anchored in a scroll view (the transcript owns those), one reaching outside
240
+ * the box itself (see `selectionEndPosition` for the clamp applied inside
241
+ * it), or one over an editor this module cannot read. Each falls through to
242
+ * Pi untouched.
243
+ */
244
+ export function editorSelectionRange(
245
+ editor: unknown,
246
+ viewport: EditorViewport,
247
+ bounds: SelectionBoundsLike,
248
+ ): { from: CaretPosition; to: CaretPosition } | undefined {
249
+ if (bounds.start.scrollView || bounds.end.scrollView) return undefined;
250
+ const from = selectionEndPosition(editor, viewport, bounds.start, false);
251
+ if (!from) return undefined;
252
+ const to = selectionEndPosition(editor, viewport, bounds.end, true);
253
+ if (!to) return undefined;
254
+ return { from, to };
255
+ }
256
+
257
+ /**
258
+ * The slice of Pi's renderer this module reads. Both entries are plain instance
259
+ * fields, not methods, so neither is a probed capability — see
260
+ * `capabilities.ts`.
261
+ */
262
+ export type CaretReceiver = {
263
+ currentLayout?: { root: BoxLike };
264
+ terminal?: { rows?: number };
265
+ };
266
+
267
+ /**
268
+ * The live editor, its box and the mapping into it, resolved fresh.
269
+ *
270
+ * Nothing here is cached. The box moves whenever the transcript grows, the
271
+ * scroll offset moves whenever the caret does, and the row count moves whenever
272
+ * the draft is edited — an answer kept even until the next event would be about
273
+ * rows that have moved.
274
+ */
275
+ export function activeEditorViewport(
276
+ receiver: CaretReceiver,
277
+ config: PolishedTuiConfig,
278
+ ): { editor: unknown; viewport: EditorViewport } | undefined {
279
+ const active = activeEditor();
280
+ if (!active) return undefined;
281
+ const rows = receiver.terminal?.rows;
282
+ if (typeof rows !== "number") return undefined;
283
+ const box = editorBoxFor(receiver.currentLayout?.root, active.component);
284
+ if (!box) return undefined;
285
+ const viewport = editorViewport(active.scrollable, box, {
286
+ paddingY: config.editorPaddingY,
287
+ textColumn: editorTextColumn({
288
+ copyFriendly: config.features.copyFriendly,
289
+ railIcon: config.icons.rail,
290
+ promptIcon: config.icons.editorPrompt,
291
+ }),
292
+ terminalRows: rows,
293
+ });
294
+ return viewport ? { editor: active.scrollable, viewport } : undefined;
295
+ }
296
+
297
+ /**
298
+ * Put the caret where the pointer is. Returns false when the press was not the
299
+ * input box's, which is every press this feature must leave alone.
300
+ *
301
+ * The point has to be *painted* by the box, not merely inside its rectangle,
302
+ * which is why this asks `pointerOverEditor` — the same question the wheel asks
303
+ * — rather than testing the rect: a dock squeezed out of the viewport must not
304
+ * claim rows it is not drawing.
305
+ */
306
+ export function moveEditorCaretTo(
307
+ receiver: CaretReceiver,
308
+ config: PolishedTuiConfig,
309
+ x: number,
310
+ y: number,
311
+ ): boolean {
312
+ try {
313
+ const active = activeEditor();
314
+ if (!active) return false;
315
+ if (!pointerOverEditor(receiver.currentLayout?.root, active.component, x, y)) return false;
316
+ const resolved = activeEditorViewport(receiver, config);
317
+ if (!resolved) return false;
318
+ const position = caretPositionAt(resolved.editor, resolved.viewport, x, y);
319
+ if (!position) return false;
320
+ return setEditorCaret(resolved.editor, position);
321
+ } catch {
322
+ // Everything above reads Pi's layout and an editor this module did not
323
+ // necessarily build. Anything it trips over means the press was an
324
+ // ordinary press.
325
+ return false;
326
+ }
327
+ }
328
+
329
+ type CaretWritableEditor = {
330
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
331
+ preferredVisualCol?: number | null;
332
+ snappedFromCursorCol?: number | null;
333
+ };
334
+
335
+ /**
336
+ * Move the caret to a buffer position.
337
+ *
338
+ * The sticky column is cleared alongside it, the same way `scrollEditorBy`
339
+ * clears it: `preferredVisualCol` and `snappedFromCursorCol` describe where the
340
+ * caret used to be, and left in place they make the next up or down press jump
341
+ * back there instead of moving from where the click just put it.
342
+ */
343
+ function setEditorCaret(value: unknown, position: CaretPosition): boolean {
344
+ if (typeof value !== "object" || value === null) return false;
345
+ const editor = value as CaretWritableEditor;
346
+ const lines = editor.state?.lines;
347
+ if (!editor.state || !Array.isArray(lines)) return false;
348
+ if (position.line < 0 || position.line >= lines.length) return false;
349
+ editor.state.cursorLine = position.line;
350
+ editor.state.cursorCol = clamp(position.column, 0, (lines[position.line] ?? "").length);
351
+ editor.preferredVisualCol = null;
352
+ editor.snappedFromCursorCol = null;
353
+ return true;
354
+ }
355
+
356
+ /**
357
+ * The slice of Pi's `Editor` the range delete drives.
358
+ *
359
+ * Every entry is a real method on `Editor`
360
+ * (`node_modules/@earendil-works/pi-tui/dist/components/editor.js`), probed
361
+ * before use because the editor may be somebody else's.
362
+ */
363
+ type EditableEditor = {
364
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
365
+ snappedFromCursorCol?: number | null;
366
+ /** `getText()` (editor.js:826) — the join of `state.lines`. */
367
+ getText?: () => string;
368
+ /** `setCursorCol(col)` (editor.js:1158); also clears `preferredVisualCol`. */
369
+ setCursorCol?: (col: number) => void;
370
+ /** `handleForwardDelete()` (editor.js:1410) — one grapheme, or one line join. */
371
+ handleForwardDelete?: () => void;
372
+ /** `pushUndoSnapshot()` (editor.js:1702). */
373
+ pushUndoSnapshot?: () => void;
374
+ };
375
+
376
+ /**
377
+ * Delete a buffer range through the editor's own editing methods.
378
+ *
379
+ * ## Why `handleForwardDelete` in a loop
380
+ *
381
+ * Pi's `Editor` has no range delete. Its editing surface is
382
+ * `handleBackspace`, `handleForwardDelete`, `deleteToStartOfLine`,
383
+ * `deleteToEndOfLine`, `deleteWordBackwards` and `deleteWordForward`, and none
384
+ * of them takes two positions. So a range is deleted by putting the caret at
385
+ * its start and deleting forward until the range's worth of characters is gone.
386
+ *
387
+ * `handleForwardDelete` is the right one of the six. It is grapheme-aware, so a
388
+ * flag or a combining sequence goes in one piece; it joins the next line when
389
+ * the caret is at the end of one, so a multi-line range needs no special case;
390
+ * and — unlike the `deleteTo…` and `deleteWord…` family — it does **not** push
391
+ * to the kill ring, so deleting a selection cannot silently clobber what the
392
+ * user has yanked.
393
+ *
394
+ * ## Why one undo snapshot
395
+ *
396
+ * Each of those calls would push its own snapshot, leaving `ctrl+z` to walk the
397
+ * range back a letter at a time. So one snapshot is pushed up front through the
398
+ * editor's own `pushUndoSnapshot`, and the editor's own is shadowed for the
399
+ * duration of the loop and put back in a `finally` — the same shadow-and-restore
400
+ * shape `copyWithNotice` uses on `flash` and `installPasteCollapse` uses on
401
+ * `handlePaste`. This is a plain assignment on Starline's own editor instance,
402
+ * never on anything reached through Pi's renderer Proxy.
403
+ *
404
+ * The loop is bounded twice over: by the character count of the range, and by a
405
+ * no-progress check, so an editor that stops deleting cannot spin.
406
+ */
407
+ function deleteEditorRange(value: unknown, start: CaretPosition, length: number): boolean {
408
+ if (typeof value !== "object" || value === null) return false;
409
+ const editor = value as EditableEditor;
410
+ const lines = editor.state?.lines;
411
+ if (!editor.state || !Array.isArray(lines)) return false;
412
+ if (typeof editor.getText !== "function") return false;
413
+ if (typeof editor.setCursorCol !== "function") return false;
414
+ if (typeof editor.handleForwardDelete !== "function") return false;
415
+ if (start.line < 0 || start.line >= lines.length) return false;
416
+
417
+ const target = editor.getText().length - length;
418
+ editor.pushUndoSnapshot?.call(editor);
419
+
420
+ const hadOwnSnapshot = Object.hasOwn(editor, "pushUndoSnapshot");
421
+ const ownSnapshot = editor.pushUndoSnapshot;
422
+ editor.pushUndoSnapshot = () => {};
423
+ try {
424
+ editor.state.cursorLine = start.line;
425
+ editor.setCursorCol.call(editor, clamp(start.column, 0, (lines[start.line] ?? "").length));
426
+ // The sticky column belongs to wherever the caret used to be.
427
+ editor.snappedFromCursorCol = null;
428
+
429
+ let guard = length + 1;
430
+ while (guard-- > 0) {
431
+ const before = editor.getText().length;
432
+ if (before <= target) break;
433
+ editor.handleForwardDelete.call(editor);
434
+ // Nothing left to delete forward (the end of the last line) — stop
435
+ // rather than spin.
436
+ if (editor.getText().length >= before) break;
437
+ }
438
+ } finally {
439
+ if (hadOwnSnapshot) editor.pushUndoSnapshot = ownSnapshot;
440
+ else delete editor.pushUndoSnapshot;
441
+ }
442
+ return true;
443
+ }
444
+
445
+ /**
446
+ * Delete the draft text a screen selection covers, and report whether it did.
447
+ *
448
+ * `false` means the selection was not the editor's to delete — a selection in
449
+ * the transcript, one reaching outside the text rows, one over an editor this
450
+ * module cannot drive, or one that resolves to no characters. Every one of those
451
+ * has to leave the key to Pi, so an ordinary backspace goes on deleting one
452
+ * character.
453
+ */
454
+ export function deleteEditorSelection(
455
+ receiver: CaretReceiver,
456
+ config: PolishedTuiConfig,
457
+ bounds: SelectionBoundsLike,
458
+ ): boolean {
459
+ try {
460
+ const resolved = activeEditorViewport(receiver, config);
461
+ if (!resolved) return false;
462
+ const range = editorSelectionRange(resolved.editor, resolved.viewport, bounds);
463
+ if (!range) return false;
464
+ const text = editorSelectionText(resolved.editor, range.from, range.to);
465
+ if (text.length === 0) return false;
466
+ const start = comparePositions(range.from, range.to) <= 0 ? range.from : range.to;
467
+ return deleteEditorRange(resolved.editor, start, text.length);
468
+ } catch {
469
+ // Driving somebody else's editor is best effort. Anything this trips over
470
+ // leaves the key to Pi.
471
+ return false;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * The draft's own text for a selection that lies inside the input box, or
477
+ * undefined when the selection is somebody else's.
478
+ *
479
+ * One function for both callers: the copy itself, and the pending-mode hint
480
+ * that has to promise the same character count the copy will deliver.
481
+ */
482
+ export function editorSelectionTextFor(
483
+ receiver: CaretReceiver,
484
+ config: PolishedTuiConfig,
485
+ bounds: SelectionBoundsLike,
486
+ ): string | undefined {
487
+ try {
488
+ const resolved = activeEditorViewport(receiver, config);
489
+ if (!resolved) return undefined;
490
+ const range = editorSelectionRange(resolved.editor, resolved.viewport, bounds);
491
+ if (!range) return undefined;
492
+ return editorSelectionText(resolved.editor, range.from, range.to);
493
+ } catch {
494
+ // undefined is "not the editor's", which is the right answer for a
495
+ // selection this module could not read: Pi copies it as it always did.
496
+ return undefined;
497
+ }
498
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Locate the editor box inside the rendered cluster, and map a click in it to
3
+ * editor-text coordinates.
4
+ *
5
+ * The cluster is a picture by the time it reaches here — status, widgets, the
6
+ * framed editor and the footer, already rendered to lines. Finding the box
7
+ * means recognising its two horizontal rules; everything between them is laid
8
+ * out by renderPolishedFrame, so the row arithmetic mirrors what it emitted.
9
+ *
10
+ * @internal
11
+ */
12
+
13
+ import { visibleWidth } from "@earendil-works/pi-tui";
14
+
15
+ const RULE_GLYPHS = new Set(["─", "━", "═"]);
16
+
17
+ function stripAnsi(line: string): string {
18
+ return line.replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "");
19
+ }
20
+
21
+ /** A full-width run of box-drawing rule, which is how the editor frames itself. */
22
+ export function isEditorRule(line: string): boolean {
23
+ const plain = stripAnsi(line).trim();
24
+ if (plain.length < 4) return false;
25
+ for (const char of plain) {
26
+ if (!RULE_GLYPHS.has(char)) return false;
27
+ }
28
+ return true;
29
+ }
30
+
31
+ export type EditorBoxGeometry = {
32
+ /** Cluster line index of the first editor text row. */
33
+ firstTextRow: number;
34
+ /** Cluster line index of the last editor text row. */
35
+ lastTextRow: number;
36
+ /** Visible column where editor text starts, past the rail or prompt. */
37
+ textColumn: number;
38
+ };
39
+
40
+ /**
41
+ * Find the editor box in the cluster.
42
+ *
43
+ * Scanning up from the bottom finds the editor's own rules first: everything
44
+ * below it (the footer) is text, and its content rows all carry the rail, so
45
+ * they cannot be mistaken for a rule.
46
+ *
47
+ * Layout between the rules, per renderPolishedFrame:
48
+ * rule, [pad], ...text, [pad], metadata, rule
49
+ */
50
+ export function findEditorBox(
51
+ clusterLines: string[],
52
+ paddingY: number,
53
+ textColumn: number,
54
+ ): EditorBoxGeometry | null {
55
+ let bottom = -1;
56
+ for (let index = clusterLines.length - 1; index >= 0; index--) {
57
+ if (isEditorRule(clusterLines[index] ?? "")) {
58
+ bottom = index;
59
+ break;
60
+ }
61
+ }
62
+ if (bottom < 0) return null;
63
+
64
+ let top = -1;
65
+ for (let index = bottom - 1; index >= 0; index--) {
66
+ if (isEditorRule(clusterLines[index] ?? "")) {
67
+ top = index;
68
+ break;
69
+ }
70
+ }
71
+ if (top < 0) return null;
72
+
73
+ const pad = paddingY > 0 ? 1 : 0;
74
+ const firstTextRow = top + 1 + pad;
75
+ // Below the text: the padding row, then the metadata row, then the rule.
76
+ const lastTextRow = bottom - 2 - pad;
77
+ if (lastTextRow < firstTextRow) return null;
78
+
79
+ return { firstTextRow, lastTextRow, textColumn };
80
+ }
81
+
82
+ /**
83
+ * Map a cluster row/column onto the editor's visual grid, or null when the
84
+ * point is outside the text area. Columns are visible columns, so the caller
85
+ * still has to resolve wide characters against the actual line.
86
+ */
87
+ export function hitTestEditorBox(
88
+ box: EditorBoxGeometry,
89
+ clusterRow: number,
90
+ clusterCol: number,
91
+ ): { visualRow: number; visualCol: number } | null {
92
+ if (clusterRow < box.firstTextRow || clusterRow > box.lastTextRow) return null;
93
+ const visualCol = clusterCol - box.textColumn;
94
+ if (visualCol < 0) return null;
95
+ return { visualRow: clusterRow - box.firstTextRow, visualCol };
96
+ }
97
+
98
+ /**
99
+ * Visible column where the editor's own text begins, past the chrome that
100
+ * renderPolishedFrame prefixes to every content row.
101
+ */
102
+ export function editorTextColumn(options: {
103
+ copyFriendly: boolean;
104
+ railIcon: string;
105
+ promptIcon: string;
106
+ }): number {
107
+ if (options.copyFriendly) {
108
+ return options.promptIcon ? visibleWidth(options.promptIcon) + 1 : 0;
109
+ }
110
+ // The rail is always drawn, and always followed by a space, even when the
111
+ // glyph itself is empty.
112
+ return visibleWidth(options.railIcon) + 1;
113
+ }
114
+
115
+ /**
116
+ * Index into `line` at `targetCol` visible columns past `fromIndex`.
117
+ * Walks by code point so wide glyphs and astral characters land correctly.
118
+ */
119
+ export function displayColumnToStringIndex(
120
+ line: string,
121
+ fromIndex: number,
122
+ targetCol: number,
123
+ ): number {
124
+ let col = 0;
125
+ let index = Math.max(0, Math.min(fromIndex, line.length));
126
+ while (index < line.length && col < targetCol) {
127
+ const codePoint = line.codePointAt(index) ?? 0;
128
+ const char = String.fromCodePoint(codePoint);
129
+ col += Math.max(1, visibleWidth(char));
130
+ index += char.length;
131
+ }
132
+ return index;
133
+ }