@xzzpig/pi-starline 0.5.1 → 0.7.1

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 (50) hide show
  1. package/FORK-CHANGELOG.md +19 -0
  2. package/README.md +68 -181
  3. package/extensions/starline/config.ts +156 -326
  4. package/extensions/starline/context-live.ts +33 -0
  5. package/extensions/starline/editor-cursor.ts +3 -0
  6. package/extensions/starline/extension-status.ts +9 -5
  7. package/extensions/starline/footer.ts +331 -528
  8. package/extensions/starline/fork-editor-cursor.ts +14 -0
  9. package/extensions/starline/format.ts +151 -20
  10. package/extensions/starline/git-live.ts +20 -0
  11. package/extensions/starline/git-probe.ts +40 -0
  12. package/extensions/starline/git-segments.ts +175 -0
  13. package/extensions/starline/git.ts +71 -0
  14. package/extensions/starline/icons.ts +17 -0
  15. package/extensions/starline/index.ts +188 -91
  16. package/extensions/starline/live-session.ts +164 -0
  17. package/extensions/starline/live-tps.ts +165 -0
  18. package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
  19. package/extensions/starline/mouse/cursor-marker.ts +69 -0
  20. package/extensions/starline/mouse/editor-hit-test.ts +6 -3
  21. package/extensions/starline/mouse/external-layer.ts +42 -0
  22. package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
  23. package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
  24. package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
  25. package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
  26. package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
  27. package/extensions/starline/mouse/hint.ts +4 -4
  28. package/extensions/starline/mouse/index.ts +54 -24
  29. package/extensions/starline/mouse/pointer-events.ts +73 -0
  30. package/extensions/starline/mouse/pointer-shape.ts +138 -0
  31. package/extensions/starline/mouse/transcript-copy.ts +76 -33
  32. package/extensions/starline/paste-collapse.ts +52 -13
  33. package/extensions/starline/pill-actions.ts +590 -0
  34. package/extensions/starline/pill-anchors.ts +26 -0
  35. package/extensions/starline/pill-api.ts +80 -0
  36. package/extensions/starline/pill-bubble.ts +607 -0
  37. package/extensions/starline/pill-config.ts +67 -9
  38. package/extensions/starline/pill.ts +102 -20
  39. package/extensions/starline/project-probe.ts +79 -0
  40. package/extensions/starline/prototype-patch-registry.ts +6 -1
  41. package/extensions/starline/queue-rows.ts +43 -0
  42. package/extensions/starline/settings-command.ts +124 -177
  43. package/extensions/starline/subagent-fleet.ts +246 -0
  44. package/extensions/starline/turn-clock.ts +81 -0
  45. package/extensions/starline/ui.ts +297 -156
  46. package/extensions/starline/working-indicator.ts +189 -0
  47. package/package.json +6 -5
  48. package/types/pill-api.d.ts +115 -0
  49. package/extensions/starline/editor-metadata-format.ts +0 -259
  50. package/extensions/starline/footer-format.ts +0 -279
@@ -0,0 +1,607 @@
1
+ /**
2
+ * The popover a pill opens: a rounded outline in the pill's colour, sitting on
3
+ * the pill, its bottom edge opening over the pill like a folder tab.
4
+ *
5
+ * This is the popover every pill action gets, not the queue's private panel: an
6
+ * action hands over its content and the anchor the footer recorded for the pill,
7
+ * and gets an outline whose bottom edge breaks exactly over that pill, so the pill
8
+ * reads as the bubble's handle. The title sits in the top edge.
9
+ *
10
+ * An outline, not a filled block. A terminal colours whole cells, so a rounded
11
+ * corner drawn over a fill leaves the fill's square corner showing around the
12
+ * curve. With nothing filled the box-drawing corners are simply round, and the
13
+ * inside keeps the terminal's own background — which on a translucent terminal
14
+ * shows the blurred window behind it. Colour is spent on the outline, the title,
15
+ * and the selected row, which is filled with the pill's colour like a menu
16
+ * highlight.
17
+ *
18
+ * Pi's overlay positioning is what makes the placement possible without layout
19
+ * work: `anchor: "bottom-left"` + `offsetX` puts the box at a column we choose,
20
+ * and `offsetY: -1` keeps it off the footer row, so its bottom edge sits right
21
+ * above the pill.
22
+ */
23
+
24
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
25
+ import {
26
+ Key,
27
+ matchesKey,
28
+ type OverlayOptions,
29
+ truncateToWidth,
30
+ visibleWidth,
31
+ wrapTextWithAnsi,
32
+ } from "@earendil-works/pi-tui";
33
+ import type {
34
+ PillAnchor,
35
+ PillBubbleContent,
36
+ PillBubbleHandle,
37
+ PillBubbleSource,
38
+ } from "../../types/pill-api";
39
+ import { requestPointerShape } from "./mouse/pointer-shape";
40
+ import { contrastTextSgr } from "./pill";
41
+ import { resolveBackgroundSgr, type ThemeLike, toForegroundSgr } from "./style";
42
+
43
+ // The public shapes live in the published types file, so the protocol other
44
+ // extensions compile against and the one Starline implements cannot drift.
45
+ export type {
46
+ PillAnchor,
47
+ PillBubbleContent,
48
+ PillBubbleHandle,
49
+ PillBubbleLine,
50
+ PillBubbleSource,
51
+ } from "../../types/pill-api";
52
+
53
+ const RESET = "\x1b[0m";
54
+ const BOLD = "\x1b[1m";
55
+ const DIM = "\x1b[2m";
56
+ const UNDERLINE = "\x1b[4m";
57
+ /** Ends bold, dim and underline (SGR 22 + 24) without touching the colours. */
58
+ const STYLE_OFF = "\x1b[22m\x1b[24m";
59
+ const DEFAULT_BG = "\x1b[49m";
60
+
61
+ const BOX = {
62
+ topLeft: "╭",
63
+ topRight: "╮",
64
+ bottomLeft: "╰",
65
+ bottomRight: "╯",
66
+ horizontal: "─",
67
+ vertical: "│",
68
+ /** The edge turning down into the opening over the pill, on each side. */
69
+ openLeft: "┘",
70
+ openRight: "└",
71
+ };
72
+
73
+ /** A bubble narrow enough to read as a bubble, wide enough for a sentence. */
74
+ const MIN_BUBBLE_WIDTH = 24;
75
+ const MAX_BUBBLE_WIDTH = 64;
76
+ /**
77
+ * Columns of edge kept left of the opening, so the tab shape reads as a tab and
78
+ * not as a box with a missing corner.
79
+ */
80
+ const TAB_INSET = 3;
81
+
82
+ /** A click target's row range in the rendered bubble. */
83
+ type BubbleTarget = { startLine: number; endLine: number; onSelect: () => void };
84
+
85
+ /**
86
+ * The target a bubble opens with selected: the first one marked `current`, as an
87
+ * index into the targets (the object lines, in order).
88
+ */
89
+ export function currentTargetIndex(content: PillBubbleContent): number | undefined {
90
+ let index = 0;
91
+ for (const line of content.body) {
92
+ if (typeof line === "string") continue;
93
+ if (line.current) return index;
94
+ index += 1;
95
+ }
96
+ return undefined;
97
+ }
98
+
99
+ type BubbleMouseEvent = {
100
+ type?: string;
101
+ x: number;
102
+ y: number;
103
+ width?: number;
104
+ };
105
+
106
+ type BubbleMouseResult = { handled?: boolean };
107
+
108
+ /** Where the bottom edge opens, in columns of the bubble: [start, end). */
109
+ export type BubbleTab = { start: number; end: number };
110
+
111
+ function clamp(value: number, min: number, max: number): number {
112
+ return Math.max(min, Math.min(max, value));
113
+ }
114
+
115
+ /**
116
+ * The bubble's width: its longest line plus the border and padding, kept between
117
+ * the bounds so a one-word pill still gets a readable bubble and a wall of text
118
+ * does not become a full-width panel. The title rides in the top edge, which
119
+ * needs two more columns than a body line (`╭─ title ─╮`). With a pill to sit on
120
+ * it is at least wide enough for the opening over that pill.
121
+ */
122
+ export function pillBubbleWidth(
123
+ content: PillBubbleContent,
124
+ terminalWidth: number,
125
+ anchor?: PillAnchor,
126
+ ): number {
127
+ const body = content.body.map((line) => (typeof line === "string" ? line : line.text));
128
+ const longest = Math.max(
129
+ 0,
130
+ ...[...body, content.hint ?? ""].map((line) => visibleWidth(line) + 4),
131
+ content.title ? visibleWidth(content.title) + 6 : 0,
132
+ );
133
+ const pill =
134
+ anchor?.startColumn !== undefined && anchor.endColumn !== undefined
135
+ ? anchor.endColumn - anchor.startColumn + TAB_INSET * 2 + 2
136
+ : 0;
137
+ const ceiling = Math.max(8, Math.min(MAX_BUBBLE_WIDTH, terminalWidth - 4));
138
+ return Math.min(Math.max(MIN_BUBBLE_WIDTH, longest, pill), ceiling);
139
+ }
140
+
141
+ /**
142
+ * Where to put the bubble, whether it sits on its pill, and where its bottom
143
+ * edge opens.
144
+ *
145
+ * With an anchor the bubble starts a few columns left of the pill and is pushed
146
+ * left only when it would run off the screen; it stays on the footer row either
147
+ * way. The opening is the pill's own columns, and is dropped when the pill is too
148
+ * close to the bubble's edge for the tab to have a side, leaving a closed bottom.
149
+ * With no anchor (a `/starline` command while the pill is not on screen) it is
150
+ * centred and closed.
151
+ */
152
+ export function pillBubblePlacement(
153
+ anchor: PillAnchor | undefined,
154
+ width: number,
155
+ fallbackWidth: number,
156
+ ): { leftColumn: number; options: OverlayOptions; attached: boolean; tab?: BubbleTab } {
157
+ const terminalWidth = Math.max(1, Math.floor(anchor?.terminalWidth ?? fallbackWidth));
158
+ const rightmost = Math.max(0, terminalWidth - width);
159
+ const start = anchor?.startColumn;
160
+ const leftColumn = anchor
161
+ ? clamp(
162
+ Math.round(start !== undefined ? start - TAB_INSET : anchor.centerColumn - width / 2),
163
+ 0,
164
+ rightmost,
165
+ )
166
+ : Math.floor(rightmost / 2);
167
+
168
+ let tab: BubbleTab | undefined;
169
+ if (start !== undefined && anchor?.endColumn !== undefined) {
170
+ const opening = { start: start - leftColumn, end: anchor.endColumn - leftColumn };
171
+ // The edge needs a corner and a turn on each side of the opening.
172
+ if (opening.start >= 2 && opening.end <= width - 2 && opening.end > opening.start) {
173
+ tab = opening;
174
+ }
175
+ }
176
+
177
+ return {
178
+ leftColumn,
179
+ // Bottom-left anchors the row to the bottom; the -1 keeps the footer row
180
+ // itself visible, so the bubble's bottom edge lands right on top of the pill.
181
+ options: {
182
+ width,
183
+ anchor: "bottom-left",
184
+ offsetX: leftColumn,
185
+ offsetY: -1,
186
+ },
187
+ attached: anchor !== undefined,
188
+ tab,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Build the bubble's lines and the click targets that go with them, in one pass
194
+ * so a target can never describe a row the renderer did not draw.
195
+ *
196
+ * This is the only function that decides what the bubble looks like; placement,
197
+ * targets, refresh and the protocol do not depend on any of it.
198
+ */
199
+ function buildBubble(input: {
200
+ content: PillBubbleContent;
201
+ width: number;
202
+ background: string;
203
+ foreground?: string;
204
+ /** Sitting on its pill. Kept for callers; the opening is what shows it. */
205
+ attached?: boolean;
206
+ /** Columns of the bottom edge left open over the pill. */
207
+ tab?: BubbleTab;
208
+ /** Index into the targets of the row drawn as selected. */
209
+ selected?: number;
210
+ }): { lines: string[]; targets: BubbleTarget[] } {
211
+ const width = Math.max(8, Math.floor(input.width));
212
+ const innerWidth = Math.max(1, width - 4);
213
+ const background = input.background;
214
+ const foreground = input.foreground || contrastTextSgr(background);
215
+ // The outline is the pill's colour as a foreground on the terminal's own
216
+ // background; nothing inside the box is filled.
217
+ const line = `${DEFAULT_BG}${toForegroundSgr(background) ?? ""}`;
218
+ const side = `${line}${BOX.vertical}${RESET}`;
219
+
220
+ const row = (text: string, style = "") =>
221
+ // The style starts after the left padding and ends before the right, so an
222
+ // underline covers the words and not the margin around them.
223
+ `${side} ${style}${text}${STYLE_OFF}${" ".repeat(Math.max(0, innerWidth - visibleWidth(text)))} ${side}`;
224
+ const selectedRow = (text: string) =>
225
+ `${side}${background}${foreground} ${text}${" ".repeat(
226
+ Math.max(0, innerWidth - visibleWidth(text)),
227
+ )} ${RESET}${side}`;
228
+
229
+ const title = input.content.title
230
+ ? truncateToWidth(input.content.title, Math.max(1, width - 6), "…")
231
+ : "";
232
+ const lines: string[] = [
233
+ title
234
+ ? `${line}${BOX.topLeft}${BOX.horizontal} ${BOLD}${title}${STYLE_OFF} ${BOX.horizontal.repeat(
235
+ Math.max(0, width - visibleWidth(title) - 5),
236
+ )}${BOX.topRight}${RESET}`
237
+ : `${line}${BOX.topLeft}${BOX.horizontal.repeat(width - 2)}${BOX.topRight}${RESET}`,
238
+ ];
239
+ const targets: BubbleTarget[] = [];
240
+
241
+ for (const entry of input.content.body) {
242
+ const text = typeof entry === "string" ? entry : entry.text;
243
+ const startLine = lines.length;
244
+ const isSelected = typeof entry !== "string" && targets.length === input.selected;
245
+ // A clickable line is underlined so it reads as a target, and filled in the
246
+ // pill's colour while it is the selected one.
247
+ for (const wrapped of wrapTextWithAnsi(text, innerWidth)) {
248
+ if (isSelected) lines.push(selectedRow(wrapped));
249
+ else lines.push(row(wrapped, typeof entry === "string" ? "" : UNDERLINE));
250
+ }
251
+ if (typeof entry !== "string" && lines.length > startLine) {
252
+ targets.push({ startLine, endLine: lines.length - 1, onSelect: entry.onSelect });
253
+ }
254
+ }
255
+
256
+ if (input.content.hint) lines.push(row(input.content.hint, DIM));
257
+
258
+ const tab = input.tab;
259
+ let bottom = BOX.bottomLeft;
260
+ for (let column = 1; column < width - 1; column += 1) {
261
+ if (tab && column === tab.start - 1) bottom += BOX.openLeft;
262
+ else if (tab && column >= tab.start && column < tab.end) bottom += " ";
263
+ else if (tab && column === tab.end) bottom += BOX.openRight;
264
+ else bottom += BOX.horizontal;
265
+ }
266
+ lines.push(`${line}${bottom}${BOX.bottomRight}${RESET}`);
267
+
268
+ return { lines, targets };
269
+ }
270
+
271
+ /**
272
+ * Render the bubble's lines. Kept as a plain string[] because that is what a
273
+ * component's `render` returns; `showPillBubble` uses the internal build so the
274
+ * click targets line up with the rows.
275
+ */
276
+ export function renderPillBubble(input: {
277
+ content: PillBubbleContent;
278
+ width: number;
279
+ background: string;
280
+ foreground?: string;
281
+ attached?: boolean;
282
+ tab?: BubbleTab;
283
+ selected?: number;
284
+ }): string[] {
285
+ return buildBubble(input).lines;
286
+ }
287
+
288
+ /**
289
+ * The open bubble, so opening another one replaces it rather than stacking two
290
+ * overlays on the same footer.
291
+ */
292
+ let activeBubble: { handle: PillBubbleHandle; key?: string } | undefined;
293
+
294
+ /**
295
+ * Where the open bubble drew itself, in screen cells. A bubble is dismissed by a
296
+ * click outside it, and "outside" needs its rectangle: Pi's overlay placement puts
297
+ * it on the footer row whatever the content, so the rectangle is the only thing
298
+ * that says which cells belong to it.
299
+ */
300
+ export type PillBubbleGeometry = {
301
+ /** First column the bubble covers, 0-based. */
302
+ leftColumn: number;
303
+ /** Columns it covers. */
304
+ width: number;
305
+ /** Rows it drew, which is what the overlay was handed. */
306
+ height: number;
307
+ /** Rows between the bubble's last row and the terminal's last row (the footer). */
308
+ bottomOffset: number;
309
+ };
310
+
311
+ let activeBubbleGeometry: PillBubbleGeometry | undefined;
312
+
313
+ /** Geometry of the bubble on screen now, or undefined when none is open. */
314
+ export function activePillBubbleGeometry(): PillBubbleGeometry | undefined {
315
+ return activeBubbleGeometry;
316
+ }
317
+
318
+ /** Dismiss the open bubble, if any. */
319
+ export function closePillBubble(): void {
320
+ activeBubble?.handle.close();
321
+ }
322
+
323
+ /** Whether the open bubble is the one the pill `key` opened. */
324
+ export function isPillBubbleOpenFor(key: string): boolean {
325
+ return activeBubble !== undefined && activeBubble.key === key;
326
+ }
327
+
328
+ function closedHandle(): PillBubbleHandle {
329
+ return {
330
+ refresh() {},
331
+ close() {},
332
+ isOpen: () => false,
333
+ onClose(callback) {
334
+ callback();
335
+ },
336
+ };
337
+ }
338
+
339
+ /**
340
+ * How long a bubble takes to unfold from its pill, in milliseconds. 0 opens it
341
+ * at once. Set from the extension's config; tests leave it at 0 so a bubble is
342
+ * whole on its first render.
343
+ */
344
+ let openAnimationMs = 0;
345
+
346
+ export function setPillBubbleAnimation(milliseconds: number): void {
347
+ openAnimationMs = Math.max(0, Math.floor(milliseconds));
348
+ }
349
+
350
+ /** About one frame at 60 Hz. */
351
+ const ANIMATION_FRAME_MS = 16;
352
+
353
+ /**
354
+ * Open the bubble for a pill action and return its handle.
355
+ *
356
+ * `content` may be a function, read again on every `refresh()`, so a bubble can
357
+ * show numbers that change while it is open. Pi fixes an overlay's width when it
358
+ * opens, so a refresh whose content no longer fits reopens the overlay wider in
359
+ * the same place; it never narrows while open, which would make it jitter.
360
+ * Everything that ends the bubble — Escape, a click on a target, a second click on
361
+ * its pill, another bubble replacing it, the session ending — runs the `onClose`
362
+ * callbacks exactly once.
363
+ *
364
+ * Targets can be chosen with the mouse or the keyboard, and both move the same
365
+ * selection: hovering a target selects it, ↑/↓ step through the targets, and
366
+ * Enter runs the selected one (or closes the bubble when none is selected).
367
+ *
368
+ * The first overlay unfolds upward from the pill over `openAnimationMs`: the
369
+ * overlay is anchored at its bottom, so drawing only its last rows and adding one
370
+ * per frame grows it out of the pill. A reopen for width is not animated.
371
+ */
372
+ export function showPillBubble(
373
+ ctx: ExtensionContext,
374
+ source: PillBubbleSource,
375
+ anchor?: PillAnchor,
376
+ ): PillBubbleHandle {
377
+ if (!ctx.hasUI) return closedHandle();
378
+ // One bubble at a time: clicking a second pill swaps the popover instead of
379
+ // leaving the first one underneath it.
380
+ closePillBubble();
381
+
382
+ const read = (): PillBubbleContent => (typeof source === "function" ? source() : source);
383
+ const fallbackWidth = process.stdout.columns ?? 80;
384
+ const terminalWidth = anchor?.terminalWidth ?? fallbackWidth;
385
+
386
+ let content = read();
387
+ let width = pillBubbleWidth(content, terminalWidth, anchor);
388
+ /** Placement of the overlay on screen now, kept for the geometry a click reads. */
389
+ let leftColumn = 0;
390
+ let bottomOffset = 1;
391
+ let open = true;
392
+ /**
393
+ * Index of the selected target, shared by the mouse and the keyboard. It
394
+ * starts on the target marked `current`, and follows that mark through
395
+ * refreshes until the user moves it.
396
+ */
397
+ let selected = currentTargetIndex(content);
398
+ let selectionMoved = false;
399
+ let mounts = 0;
400
+ const closeCallbacks: Array<() => void> = [];
401
+ /** The overlay on screen now; replaced when a refresh needs a wider one. */
402
+ let overlay: { dismiss: () => void; requestRender: () => void } | undefined;
403
+
404
+ const finish = () => {
405
+ if (!open) return;
406
+ open = false;
407
+ activeBubbleGeometry = undefined;
408
+ if (activeBubble?.handle === handle) activeBubble = undefined;
409
+ for (const callback of closeCallbacks.splice(0)) {
410
+ try {
411
+ callback();
412
+ } catch {
413
+ // One bridge's cleanup failing must not stop the others.
414
+ }
415
+ }
416
+ };
417
+
418
+ const handle: PillBubbleHandle = {
419
+ refresh() {
420
+ if (!open) return;
421
+ try {
422
+ content = read();
423
+ } catch {
424
+ return;
425
+ }
426
+ if (!selectionMoved) selected = currentTargetIndex(content);
427
+ const needed = pillBubbleWidth(content, terminalWidth, anchor);
428
+ if (needed > width) {
429
+ width = needed;
430
+ mount();
431
+ } else {
432
+ overlay?.requestRender();
433
+ }
434
+ },
435
+ close() {
436
+ if (!open) return;
437
+ const current = overlay;
438
+ overlay = undefined;
439
+ finish();
440
+ current?.dismiss();
441
+ },
442
+ isOpen: () => open,
443
+ onClose(callback) {
444
+ if (open) closeCallbacks.push(callback);
445
+ else callback();
446
+ },
447
+ };
448
+ activeBubble = { handle, key: anchor?.key };
449
+
450
+ function mount(): void {
451
+ const previous = overlay;
452
+ const placement = pillBubblePlacement(anchor, width, fallbackWidth);
453
+ // Where the overlay lands is the placement's to decide, and its own rows are
454
+ // whatever it draws; a click expects both, so record them as they happen.
455
+ leftColumn = placement.leftColumn;
456
+ bottomOffset = Math.max(0, -(placement.options.offsetY ?? 0));
457
+ const animate = mounts === 0 && openAnimationMs > 0;
458
+ mounts += 1;
459
+ let instance: { dismiss: () => void; requestRender: () => void } | undefined;
460
+
461
+ void ctx.ui
462
+ .custom<void>(
463
+ (tui, theme, _keybindings, done) => {
464
+ let mounted = true;
465
+ let targets: BubbleTarget[] = [];
466
+ const repaint = () => {
467
+ if (mounted) (tui as { requestRender?: () => void }).requestRender?.();
468
+ };
469
+ const openedAt = Date.now();
470
+ let frames: ReturnType<typeof setInterval> | undefined;
471
+ const stopFrames = () => {
472
+ if (frames !== undefined) clearInterval(frames);
473
+ frames = undefined;
474
+ };
475
+ if (animate && typeof (tui as { requestRender?: unknown }).requestRender === "function") {
476
+ frames = setInterval(() => {
477
+ repaint();
478
+ if (Date.now() - openedAt >= openAnimationMs) stopFrames();
479
+ }, ANIMATION_FRAME_MS);
480
+ frames.unref?.();
481
+ }
482
+
483
+ instance = {
484
+ dismiss: () => {
485
+ if (!mounted) return;
486
+ mounted = false;
487
+ stopFrames();
488
+ done(undefined);
489
+ },
490
+ requestRender: repaint,
491
+ };
492
+ overlay = instance;
493
+ // The new overlay is up; only now take the narrower one down, so a
494
+ // refresh never shows a frame with no bubble at all.
495
+ previous?.dismiss();
496
+
497
+ const select = (index: number | undefined) => {
498
+ selectionMoved = true;
499
+ if (index === selected) return;
500
+ selected = index;
501
+ repaint();
502
+ };
503
+ const runSelected = () => {
504
+ const target = selected === undefined ? undefined : targets[selected];
505
+ handle.close();
506
+ target?.onSelect();
507
+ };
508
+
509
+ return {
510
+ render(renderWidth: number): string[] {
511
+ const built = buildBubble({
512
+ content,
513
+ width: renderWidth,
514
+ background:
515
+ anchor?.background ??
516
+ resolveBackgroundSgr(theme as ThemeLike, "theme", "borderMuted") ??
517
+ "",
518
+ foreground: anchor?.foreground,
519
+ attached: placement.attached,
520
+ tab: placement.tab,
521
+ selected,
522
+ });
523
+ let lines = built.lines;
524
+ let shift = 0;
525
+ if (frames !== undefined) {
526
+ const progress = Math.min(1, (Date.now() - openedAt) / openAnimationMs);
527
+ const shown = Math.max(1, Math.ceil(lines.length * progress));
528
+ shift = lines.length - shown;
529
+ lines = lines.slice(shift);
530
+ }
531
+ // In the overlay's own rows. Rows not drawn yet land above row 0,
532
+ // where no pointer can reach them, and every target keeps its
533
+ // index, so the keyboard's selection means the same row throughout.
534
+ targets = built.targets.map((target) => ({
535
+ ...target,
536
+ startLine: target.startLine - shift,
537
+ endLine: target.endLine - shift,
538
+ }));
539
+ // The rows that will be on screen, not the ones the content would draw:
540
+ // during the unfold the overlay is shorter than its content.
541
+ if (overlay === instance) {
542
+ activeBubbleGeometry = {
543
+ leftColumn,
544
+ width: Math.max(0, Math.floor(renderWidth)),
545
+ height: lines.length,
546
+ bottomOffset,
547
+ };
548
+ }
549
+ return lines;
550
+ },
551
+ invalidate() {},
552
+ handleInput(data: string): void {
553
+ if (matchesKey(data, Key.escape)) {
554
+ handle.close();
555
+ } else if (matchesKey(data, Key.enter)) {
556
+ runSelected();
557
+ } else if (matchesKey(data, Key.down) || matchesKey(data, Key.up)) {
558
+ if (targets.length === 0) return;
559
+ const step = matchesKey(data, Key.down) ? 1 : -1;
560
+ const from = selected ?? (step > 0 ? -1 : 0);
561
+ select((from + step + targets.length) % targets.length);
562
+ }
563
+ },
564
+ /**
565
+ * Overlays get mouse events with coordinates local to the overlay, so a
566
+ * target is just a row range. Hovering selects, a press is claimed and
567
+ * the callback runs on the click, the same split the footer uses.
568
+ */
569
+ handleMouse(event: BubbleMouseEvent): BubbleMouseResult | undefined {
570
+ if (!Number.isFinite(event.x) || !Number.isFinite(event.y)) return undefined;
571
+ const index = targets.findIndex(
572
+ (entry) => event.y >= entry.startLine && event.y <= entry.endLine,
573
+ );
574
+ if (index < 0) return undefined;
575
+ if (event.type === "move") {
576
+ requestPointerShape("pointer");
577
+ select(index);
578
+ return { handled: true };
579
+ }
580
+ if (event.type === "click") {
581
+ selected = index;
582
+ runSelected();
583
+ }
584
+ return { handled: true };
585
+ },
586
+ dispose(): void {
587
+ mounted = false;
588
+ stopFrames();
589
+ // Pi took this overlay down without us asking (the session ended,
590
+ // the UI was torn down). A replaced overlay is not the bubble closing.
591
+ if (overlay === instance) {
592
+ overlay = undefined;
593
+ finish();
594
+ }
595
+ },
596
+ };
597
+ },
598
+ { overlay: true, overlayOptions: placement.options },
599
+ )
600
+ .catch(() => {
601
+ if (overlay === instance || instance === undefined) finish();
602
+ });
603
+ }
604
+
605
+ mount();
606
+ return handle;
607
+ }