@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.
- package/FORK-CHANGELOG.md +19 -0
- package/README.md +68 -181
- package/extensions/starline/config.ts +156 -326
- package/extensions/starline/context-live.ts +33 -0
- package/extensions/starline/editor-cursor.ts +3 -0
- package/extensions/starline/extension-status.ts +9 -5
- package/extensions/starline/footer.ts +331 -528
- package/extensions/starline/fork-editor-cursor.ts +14 -0
- package/extensions/starline/format.ts +151 -20
- package/extensions/starline/git-live.ts +20 -0
- package/extensions/starline/git-probe.ts +40 -0
- package/extensions/starline/git-segments.ts +175 -0
- package/extensions/starline/git.ts +71 -0
- package/extensions/starline/icons.ts +17 -0
- package/extensions/starline/index.ts +188 -91
- package/extensions/starline/live-session.ts +164 -0
- package/extensions/starline/live-tps.ts +165 -0
- package/extensions/starline/mouse/bubble-dismiss.ts +82 -0
- package/extensions/starline/mouse/cursor-marker.ts +69 -0
- package/extensions/starline/mouse/editor-hit-test.ts +6 -3
- package/extensions/starline/mouse/external-layer.ts +42 -0
- package/extensions/starline/mouse/fork-native-clipboard.ts +57 -0
- package/extensions/starline/mouse/fork-pill-mouse.ts +19 -0
- package/extensions/starline/mouse/fork-pointer-coordination.ts +10 -0
- package/extensions/starline/mouse/fork-transcript-copy.ts +54 -0
- package/extensions/starline/mouse/fork-wheel-compat.ts +22 -0
- package/extensions/starline/mouse/hint.ts +4 -4
- package/extensions/starline/mouse/index.ts +54 -24
- package/extensions/starline/mouse/pointer-events.ts +73 -0
- package/extensions/starline/mouse/pointer-shape.ts +138 -0
- package/extensions/starline/mouse/transcript-copy.ts +76 -33
- package/extensions/starline/paste-collapse.ts +52 -13
- package/extensions/starline/pill-actions.ts +590 -0
- package/extensions/starline/pill-anchors.ts +26 -0
- package/extensions/starline/pill-api.ts +80 -0
- package/extensions/starline/pill-bubble.ts +607 -0
- package/extensions/starline/pill-config.ts +67 -9
- package/extensions/starline/pill.ts +102 -20
- package/extensions/starline/project-probe.ts +79 -0
- package/extensions/starline/prototype-patch-registry.ts +6 -1
- package/extensions/starline/queue-rows.ts +43 -0
- package/extensions/starline/settings-command.ts +124 -177
- package/extensions/starline/subagent-fleet.ts +246 -0
- package/extensions/starline/turn-clock.ts +81 -0
- package/extensions/starline/ui.ts +297 -156
- package/extensions/starline/working-indicator.ts +189 -0
- package/package.json +6 -5
- package/types/pill-api.d.ts +115 -0
- package/extensions/starline/editor-metadata-format.ts +0 -259
- 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
|
+
}
|