@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,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What clicking a pill does.
|
|
3
|
+
*
|
|
4
|
+
* One table, keyed by segment. A segment with no entry stays a label:
|
|
5
|
+
* `handleMouse` returns undefined for it and the click falls through to Pi, so an
|
|
6
|
+
* inert pill swallows nothing. Every entry also answers to a `/starline`
|
|
7
|
+
* subcommand of the same name, which is what makes the keyboard path real rather
|
|
8
|
+
* than aspirational.
|
|
9
|
+
*
|
|
10
|
+
* An action receives the anchor of the pill it was called for, so anything it
|
|
11
|
+
* shows can be placed against that pill. The bubble itself lives in
|
|
12
|
+
* `pill-bubble.ts` and is shared: this file only decides what goes in it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
import { activeContextSnapshot } from "./context-live";
|
|
18
|
+
import { formatCount } from "./format";
|
|
19
|
+
import { activeGitStatus } from "./git-live";
|
|
20
|
+
import {
|
|
21
|
+
cycleThinkingLevel,
|
|
22
|
+
readPendingMessages,
|
|
23
|
+
readThinkingLevels,
|
|
24
|
+
setThinkingLevel,
|
|
25
|
+
} from "./live-session";
|
|
26
|
+
import { externalPillAction } from "./pill-api";
|
|
27
|
+
import {
|
|
28
|
+
type PillAnchor,
|
|
29
|
+
type PillBubbleContent,
|
|
30
|
+
type PillBubbleHandle,
|
|
31
|
+
showPillBubble,
|
|
32
|
+
} from "./pill-bubble";
|
|
33
|
+
import { PILL_SEGMENT_VARIABLES } from "./pill-config";
|
|
34
|
+
import { type FleetSnapshot, getActiveSubagentFleet } from "./subagent-fleet";
|
|
35
|
+
|
|
36
|
+
/** What an action gets: the session, and where the pill is if a click found it. */
|
|
37
|
+
export type PillActionContext = {
|
|
38
|
+
ctx: ExtensionContext;
|
|
39
|
+
/**
|
|
40
|
+
* Geometry and colours of the pill this action belongs to. Absent when the
|
|
41
|
+
* command path runs while the pill is not on screen (text footer, or the
|
|
42
|
+
* segment removed from `pill.segments`); bubbles then fall back to centred.
|
|
43
|
+
*/
|
|
44
|
+
pill?: PillAnchor;
|
|
45
|
+
/**
|
|
46
|
+
* How the action was reached. A click can offer a choice in the bubble; the
|
|
47
|
+
* `/starline` command has no pointer to make one with, so an action that
|
|
48
|
+
* offers choices does something direct instead.
|
|
49
|
+
*/
|
|
50
|
+
via: "click" | "command";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type PillAction = (context: PillActionContext) => void | Promise<void>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether the session is holding messages for the running turn.
|
|
57
|
+
*
|
|
58
|
+
* The public surface is a boolean. Guarded rather than assumed: a build without
|
|
59
|
+
* it, or a context that has gone stale after a session swap, must leave the pill
|
|
60
|
+
* out instead of taking the footer's render down with it.
|
|
61
|
+
*/
|
|
62
|
+
export function hasQueuedMessages(ctx: ExtensionContext): boolean {
|
|
63
|
+
try {
|
|
64
|
+
const read = (ctx as ExtensionContext & { hasPendingMessages?: () => boolean })
|
|
65
|
+
.hasPendingMessages;
|
|
66
|
+
return typeof read === "function" && read.call(ctx) === true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The queue pill's text: the icon and how many messages wait (`<icon> 3`). The
|
|
74
|
+
* count needs the live session; without it the pill still says `queued`. With no
|
|
75
|
+
* icon (ascii mode) the number keeps its word, `3 queued`, so a bare digit never
|
|
76
|
+
* stands in the bar unexplained.
|
|
77
|
+
*/
|
|
78
|
+
export function queuedSegmentText(ctx: ExtensionContext, icon = ""): string {
|
|
79
|
+
if (!hasQueuedMessages(ctx)) return "";
|
|
80
|
+
const pending = readPendingMessages();
|
|
81
|
+
const count = pending ? pending.steering.length + pending.followUp.length : 0;
|
|
82
|
+
if (count > 0) return icon ? `${icon} ${count}` : `${count} queued`;
|
|
83
|
+
return icon ? `${icon} queued` : "queued";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** How often an open bubble whose numbers move re-reads them. */
|
|
87
|
+
const LIVE_BUBBLE_INTERVAL_MS = 1_000;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Re-run `tick` every second while `handle` is open, then stop. The timer never
|
|
91
|
+
* keeps Pi alive on its own, and stops itself if the bubble has already gone.
|
|
92
|
+
*/
|
|
93
|
+
function whileOpen(handle: PillBubbleHandle, tick: () => void): void {
|
|
94
|
+
if (!handle.isOpen()) return;
|
|
95
|
+
const timer = setInterval(() => {
|
|
96
|
+
if (!handle.isOpen()) {
|
|
97
|
+
clearInterval(timer);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
tick();
|
|
101
|
+
}, LIVE_BUBBLE_INTERVAL_MS);
|
|
102
|
+
timer.unref?.();
|
|
103
|
+
handle.onClose(() => clearInterval(timer));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Cap on listed messages. More than a few turns a bubble into a wall, and the
|
|
108
|
+
* count in the title already says how many there are.
|
|
109
|
+
*/
|
|
110
|
+
const MAX_LISTED_MESSAGES = 3;
|
|
111
|
+
/** Roughly what fits in the widest bubble, bullet included, before wrapping. */
|
|
112
|
+
const MAX_MESSAGE_COLUMNS = 58;
|
|
113
|
+
|
|
114
|
+
function oneLine(message: string): string {
|
|
115
|
+
const collapsed = message.replace(/\s+/gu, " ").trim();
|
|
116
|
+
return truncateToWidth(collapsed, MAX_MESSAGE_COLUMNS, "…");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What the queue bubble says: the count and the messages when the live session
|
|
121
|
+
* is readable, and the behaviour when it is not.
|
|
122
|
+
*/
|
|
123
|
+
export function queuedBubbleContent(): PillBubbleContent {
|
|
124
|
+
const pending = readPendingMessages();
|
|
125
|
+
if (!pending) {
|
|
126
|
+
return {
|
|
127
|
+
title: "Queued messages",
|
|
128
|
+
body: [
|
|
129
|
+
"Messages you send while the agent is working wait until the current turn ends, then get delivered.",
|
|
130
|
+
"This build of Pi does not expose them to the extension, so the count and the text are unknown.",
|
|
131
|
+
],
|
|
132
|
+
hint: "esc to close",
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const messages = [...pending.steering, ...pending.followUp];
|
|
137
|
+
// The bubble stays open while the turn ends and the queue drains under it.
|
|
138
|
+
if (messages.length === 0) {
|
|
139
|
+
return { title: "Queued messages", body: ["Nothing is queued."], hint: "esc to close" };
|
|
140
|
+
}
|
|
141
|
+
const listed = messages.slice(0, MAX_LISTED_MESSAGES).map((message) => `· ${oneLine(message)}`);
|
|
142
|
+
if (messages.length > listed.length) {
|
|
143
|
+
listed.push(`· +${messages.length - listed.length} more`);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
title: `${messages.length} message${messages.length === 1 ? "" : "s"} queued`,
|
|
147
|
+
body: listed,
|
|
148
|
+
hint: "esc to close",
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function showQueuedMessages({ ctx, pill }: PillActionContext): void {
|
|
153
|
+
if (!hasQueuedMessages(ctx)) {
|
|
154
|
+
// The pill only exists while something is queued; the command does not.
|
|
155
|
+
if (ctx.hasUI) ctx.ui.notify("No messages are queued", "info");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Live: the queue drains as the turn ends, and new messages join it.
|
|
159
|
+
const handle = showPillBubble(ctx, queuedBubbleContent, pill);
|
|
160
|
+
whileOpen(handle, () => handle.refresh());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function thinkingUnavailable(ctx: ExtensionContext): PillBubbleContent {
|
|
164
|
+
const current = ctx.thinkingLevel ?? "";
|
|
165
|
+
return {
|
|
166
|
+
title: "Thinking",
|
|
167
|
+
body: current
|
|
168
|
+
? [`Current level: ${current}.`, "This session cannot change it from here."]
|
|
169
|
+
: ["This model has no thinking level to change."],
|
|
170
|
+
hint: "esc to close",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* What the thinking pill's bubble offers: every level the model supports, the
|
|
176
|
+
* current one marked and the others clickable. Picking one closes the bubble;
|
|
177
|
+
* the footer re-renders from `thinking_level_select`, so the pill itself is the
|
|
178
|
+
* confirmation.
|
|
179
|
+
*/
|
|
180
|
+
export function thinkingPickerContent(ctx: ExtensionContext): PillBubbleContent {
|
|
181
|
+
const read = readThinkingLevels();
|
|
182
|
+
if (!read) return thinkingUnavailable(ctx);
|
|
183
|
+
return {
|
|
184
|
+
title: "Thinking",
|
|
185
|
+
body: read.levels.map((level) =>
|
|
186
|
+
// Every level is a target, the current one too: the bubble opens with it
|
|
187
|
+
// selected, so ↑/↓ move from where you are. Choosing it changes nothing.
|
|
188
|
+
level === read.current
|
|
189
|
+
? { text: `● ${level}`, current: true, onSelect: () => {} }
|
|
190
|
+
: {
|
|
191
|
+
text: `○ ${level}`,
|
|
192
|
+
onSelect: () => {
|
|
193
|
+
if (!setThinkingLevel(level) && ctx.hasUI) {
|
|
194
|
+
ctx.ui.notify(`Could not switch thinking to ${level}`, "error");
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
),
|
|
199
|
+
hint: "↑↓ enter · click · esc",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* A click opens the level list; it changes nothing until a level is picked, so
|
|
205
|
+
* looking is free. The command has no pointer to pick with, so it steps to the
|
|
206
|
+
* next level and says where it landed.
|
|
207
|
+
*/
|
|
208
|
+
function showThinkingLevel({ ctx, pill, via }: PillActionContext): void {
|
|
209
|
+
if (via === "click") {
|
|
210
|
+
showPillBubble(ctx, thinkingPickerContent(ctx), pill);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const cycled = cycleThinkingLevel();
|
|
214
|
+
showPillBubble(
|
|
215
|
+
ctx,
|
|
216
|
+
cycled
|
|
217
|
+
? { title: "Thinking", body: [`${cycled.from} → ${cycled.to}`], hint: "esc to close" }
|
|
218
|
+
: thinkingUnavailable(ctx),
|
|
219
|
+
pill,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The parts of a Pi model the picker reads. */
|
|
224
|
+
export type PickableModel = { provider: string; id: string; name?: string };
|
|
225
|
+
|
|
226
|
+
/** A model offered by the picker, with the thinking level its scope pins, if any. */
|
|
227
|
+
export type ModelChoice = { model: PickableModel; thinkingLevel?: string };
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Switch the session's model. Registered by the extension entry, which holds `pi`;
|
|
231
|
+
* an action only gets the context, and `setModel` is not on it.
|
|
232
|
+
*/
|
|
233
|
+
export type ModelSwitcher = (choice: ModelChoice) => Promise<boolean>;
|
|
234
|
+
|
|
235
|
+
let modelSwitcher: ModelSwitcher | undefined;
|
|
236
|
+
|
|
237
|
+
export function setModelSwitcher(switcher: ModelSwitcher | undefined): void {
|
|
238
|
+
modelSwitcher = switcher;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Without a model scope the picker falls back to the whole catalogue, capped. */
|
|
242
|
+
const MAX_UNSCOPED_MODELS = 8;
|
|
243
|
+
|
|
244
|
+
const sameModel = (a: PickableModel | undefined, b: PickableModel | undefined) =>
|
|
245
|
+
a !== undefined && b !== undefined && a.provider === b.provider && a.id === b.id;
|
|
246
|
+
|
|
247
|
+
const modelLabel = (model: PickableModel) => `${model.id} · ${model.provider}`;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* What the model picker offers: the session's scoped models — the ones ctrl+p
|
|
251
|
+
* cycles, from `enabledModels` or `--models` — or, with no scope, the available
|
|
252
|
+
* models with auth, capped so the bubble stays a bubble. The current model is
|
|
253
|
+
* always in the list.
|
|
254
|
+
*/
|
|
255
|
+
export function modelChoices(ctx: ExtensionContext): { choices: ModelChoice[]; more: number } {
|
|
256
|
+
const current = ctx.model as PickableModel | undefined;
|
|
257
|
+
const scoped = Array.isArray(ctx.scopedModels) ? ctx.scopedModels : [];
|
|
258
|
+
if (scoped.length > 0) {
|
|
259
|
+
return {
|
|
260
|
+
choices: scoped.map((entry) => ({
|
|
261
|
+
model: entry.model as PickableModel,
|
|
262
|
+
thinkingLevel: entry.thinkingLevel,
|
|
263
|
+
})),
|
|
264
|
+
more: 0,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
let available: PickableModel[] = [];
|
|
268
|
+
try {
|
|
269
|
+
available = (ctx.modelRegistry?.getAvailable() ?? []) as PickableModel[];
|
|
270
|
+
} catch {
|
|
271
|
+
available = [];
|
|
272
|
+
}
|
|
273
|
+
const listed = available.slice(0, MAX_UNSCOPED_MODELS);
|
|
274
|
+
// Counted before the current model is put in front: it may not be one of the
|
|
275
|
+
// available ones at all, and it must not hide one that is.
|
|
276
|
+
const more = Math.max(0, available.length - listed.length);
|
|
277
|
+
if (current && !listed.some((model) => sameModel(model, current))) listed.unshift(current);
|
|
278
|
+
return { choices: listed.map((model) => ({ model })), more };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function switchModel(ctx: ExtensionContext, choice: ModelChoice): Promise<boolean> {
|
|
282
|
+
let switched = false;
|
|
283
|
+
try {
|
|
284
|
+
switched = (await modelSwitcher?.(choice)) === true;
|
|
285
|
+
} catch {
|
|
286
|
+
switched = false;
|
|
287
|
+
}
|
|
288
|
+
if (!switched && ctx.hasUI) {
|
|
289
|
+
ctx.ui.notify(`Could not switch to ${modelLabel(choice.model)}`, "error");
|
|
290
|
+
}
|
|
291
|
+
return switched;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The model pill's bubble: every choice, the current one marked. */
|
|
295
|
+
export function modelPickerContent(ctx: ExtensionContext): PillBubbleContent {
|
|
296
|
+
const { choices, more } = modelChoices(ctx);
|
|
297
|
+
const current = ctx.model as PickableModel | undefined;
|
|
298
|
+
if (choices.length === 0 || !modelSwitcher) {
|
|
299
|
+
return {
|
|
300
|
+
title: "Model",
|
|
301
|
+
body: [
|
|
302
|
+
current ? `Current model: ${modelLabel(current)}.` : "No model is selected.",
|
|
303
|
+
"Use /model to pick one.",
|
|
304
|
+
],
|
|
305
|
+
hint: "esc to close",
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// The current model is a target too, opened selected, so ↑/↓ start from it.
|
|
309
|
+
const body: PillBubbleContent["body"] = choices.map((choice) =>
|
|
310
|
+
sameModel(choice.model, current)
|
|
311
|
+
? { text: `● ${modelLabel(choice.model)}`, current: true, onSelect: () => {} }
|
|
312
|
+
: {
|
|
313
|
+
text: `○ ${modelLabel(choice.model)}`,
|
|
314
|
+
onSelect: () => void switchModel(ctx, choice),
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
if (more > 0) body.push(`+${more} more in /model`);
|
|
318
|
+
return { title: "Model", body, hint: "↑↓ enter · click · /model for all" };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A click opens the picker. The command steps to the next choice, the same way
|
|
323
|
+
* ctrl+p cycles scoped models, and reports where it landed.
|
|
324
|
+
*/
|
|
325
|
+
async function showModels({ ctx, pill, via }: PillActionContext): Promise<void> {
|
|
326
|
+
if (via === "click") {
|
|
327
|
+
showPillBubble(ctx, modelPickerContent(ctx), pill);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const { choices } = modelChoices(ctx);
|
|
331
|
+
const current = ctx.model as PickableModel | undefined;
|
|
332
|
+
const index = choices.findIndex((choice) => sameModel(choice.model, current));
|
|
333
|
+
const next = choices[(index + 1) % Math.max(1, choices.length)];
|
|
334
|
+
if (!next || choices.length < 2 || !(await switchModel(ctx, next))) {
|
|
335
|
+
showPillBubble(ctx, modelPickerContent(ctx), pill);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
showPillBubble(
|
|
339
|
+
ctx,
|
|
340
|
+
{
|
|
341
|
+
title: "Model",
|
|
342
|
+
body: [`${current ? current.id : "none"} → ${next.model.id}`],
|
|
343
|
+
hint: "esc to close",
|
|
344
|
+
},
|
|
345
|
+
pill,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** `45s`, `3m 12s`, `1h 05m`: what a glance at a running agent needs. */
|
|
350
|
+
export function formatElapsed(milliseconds: number): string {
|
|
351
|
+
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
352
|
+
if (seconds < 60) return `${seconds}s`;
|
|
353
|
+
const minutes = Math.floor(seconds / 60);
|
|
354
|
+
if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
355
|
+
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function formatTokens(tokens: number): string {
|
|
359
|
+
return tokens >= 1000 ? `${Math.round(tokens / 1000)}k tok` : `${tokens} tok`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* One line per running agent: who, on what model, for how long, at what cost.
|
|
364
|
+
* The model's provider prefix is dropped; the bubble is narrow and the name is
|
|
365
|
+
* what tells two runs apart.
|
|
366
|
+
*/
|
|
367
|
+
export function subagentsBubbleContent(fleet: FleetSnapshot, now = Date.now()): PillBubbleContent {
|
|
368
|
+
const listed = fleet.entries.map((entry) => {
|
|
369
|
+
const name = entry.role ? `${entry.role} (${entry.agent})` : entry.agent;
|
|
370
|
+
const parts = [name];
|
|
371
|
+
if (entry.model) parts.push(entry.model.split("/").at(-1) ?? entry.model);
|
|
372
|
+
parts.push(formatElapsed(now - entry.startedAt));
|
|
373
|
+
if (entry.tokens > 0) parts.push(formatTokens(entry.tokens));
|
|
374
|
+
return `· ${truncateToWidth(parts.join(" · "), MAX_MESSAGE_COLUMNS, "…")}`;
|
|
375
|
+
});
|
|
376
|
+
const unlisted = Math.max(fleet.omitted, fleet.totalActive - fleet.entries.length);
|
|
377
|
+
if (unlisted > 0) listed.push(`· +${unlisted} more`);
|
|
378
|
+
const total = fleet.totalActive;
|
|
379
|
+
return {
|
|
380
|
+
title: total > 0 ? `${total} subagent${total === 1 ? "" : "s"} running` : "Subagents",
|
|
381
|
+
body: total > 0 ? listed : ["Nothing is running."],
|
|
382
|
+
hint: "/subagents-fleet for details · esc to close",
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** How long a click waits for fresh numbers before showing the ones it has. */
|
|
387
|
+
const FLEET_FETCH_TIMEOUT_MS = 400;
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Ask pi-subagents for the current fleet, then show it. The pill's count may be
|
|
391
|
+
* a moment old and the elapsed times always are, so the bubble asks again; a
|
|
392
|
+
* slow answer falls back to the last one rather than keeping the click waiting.
|
|
393
|
+
*/
|
|
394
|
+
async function showSubagents({ ctx, pill }: PillActionContext): Promise<void> {
|
|
395
|
+
const fleet = getActiveSubagentFleet();
|
|
396
|
+
if (!fleet) return;
|
|
397
|
+
const fresh = await fleet.fetch(FLEET_FETCH_TIMEOUT_MS);
|
|
398
|
+
if (!fresh && !fleet.available()) {
|
|
399
|
+
if (ctx.hasUI) ctx.ui.notify("pi-subagents is not installed or not answering", "info");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
// Live: elapsed time and tokens move every second, and runs come and go. Each
|
|
403
|
+
// tick asks pi-subagents again; the content reads whatever the last answer was.
|
|
404
|
+
const handle = showPillBubble(ctx, () => subagentsBubbleContent(fleet.snapshot()), pill);
|
|
405
|
+
let asking = false;
|
|
406
|
+
whileOpen(handle, () => {
|
|
407
|
+
// A slow answer skips a tick rather than stacking requests behind it.
|
|
408
|
+
if (asking) return;
|
|
409
|
+
asking = true;
|
|
410
|
+
void fleet.fetch(LIVE_BUBBLE_INTERVAL_MS).then(() => {
|
|
411
|
+
asking = false;
|
|
412
|
+
handle.refresh();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* What the context pill says when clicked: the numbers the bar has no room for.
|
|
419
|
+
*
|
|
420
|
+
* The pill keeps the percentage; the window, the tokens used, the session's
|
|
421
|
+
* in/out counts and the cache rate are all here. Same source as the pill, so the
|
|
422
|
+
* two cannot disagree while a turn streams.
|
|
423
|
+
*/
|
|
424
|
+
export function contextBubbleContent(): PillBubbleContent {
|
|
425
|
+
const snapshot = activeContextSnapshot();
|
|
426
|
+
if (!snapshot) {
|
|
427
|
+
return { title: "Context", body: ["Nothing has been sent yet."], hint: "esc to close" };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const body: string[] = [];
|
|
431
|
+
const percent =
|
|
432
|
+
snapshot.percent === undefined || !Number.isFinite(snapshot.percent)
|
|
433
|
+
? undefined
|
|
434
|
+
: Math.max(0, Math.min(100, Math.round(snapshot.percent)));
|
|
435
|
+
if (snapshot.contextWindow) {
|
|
436
|
+
const windowLabel = formatCount(snapshot.contextWindow);
|
|
437
|
+
const used = percent === undefined ? `window ${windowLabel}` : `${percent}% of ${windowLabel}`;
|
|
438
|
+
body.push(
|
|
439
|
+
snapshot.tokens === undefined ? used : `${used} · ${formatCount(snapshot.tokens)} used`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (snapshot.tokenLabel) body.push(`session ${snapshot.tokenLabel}`);
|
|
443
|
+
if (snapshot.cacheLabel) body.push(`cache ${snapshot.cacheLabel}`);
|
|
444
|
+
|
|
445
|
+
const title = percent === undefined ? "Context" : `${percent}% context`;
|
|
446
|
+
return {
|
|
447
|
+
title,
|
|
448
|
+
body: body.length > 0 ? body : ["No usage yet."],
|
|
449
|
+
hint: "esc to close",
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function showContext({ ctx, pill }: PillActionContext): void {
|
|
454
|
+
if (!activeContextSnapshot()) {
|
|
455
|
+
if (ctx.hasUI) ctx.ui.notify("No context usage in this session", "info");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// Live: tokens grow while a turn streams, and the footer pushes a new
|
|
459
|
+
// snapshot every render.
|
|
460
|
+
const handle = showPillBubble(ctx, contextBubbleContent, pill);
|
|
461
|
+
whileOpen(handle, () => handle.refresh());
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** How many changed paths the bubble lists before summarising the rest. */
|
|
465
|
+
const MAX_LISTED_CHANGES = 12;
|
|
466
|
+
/** The order the bubble groups changes in: what is staged first, then the rest. */
|
|
467
|
+
const CHANGE_ORDER = ["M", "A", "R", "T", "D", "U", "?"];
|
|
468
|
+
|
|
469
|
+
function changeLabel(code: string): string {
|
|
470
|
+
switch (code) {
|
|
471
|
+
case "?":
|
|
472
|
+
return "untracked";
|
|
473
|
+
case "U":
|
|
474
|
+
return "conflicted";
|
|
475
|
+
case "A":
|
|
476
|
+
return "added";
|
|
477
|
+
case "R":
|
|
478
|
+
return "renamed";
|
|
479
|
+
case "D":
|
|
480
|
+
return "deleted";
|
|
481
|
+
case "T":
|
|
482
|
+
return "type changed";
|
|
483
|
+
default:
|
|
484
|
+
return "modified";
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* What the git pill says when clicked: the branch, what is ahead/behind, and the
|
|
490
|
+
* changed paths. Read-only on purpose — there is no branch switch here and no
|
|
491
|
+
* staging, because an agent may be running in the same tree.
|
|
492
|
+
*/
|
|
493
|
+
export function gitBubbleContent(): PillBubbleContent {
|
|
494
|
+
const status = activeGitStatus();
|
|
495
|
+
if (!status) {
|
|
496
|
+
return { title: "Git", body: ["No repository in this project."], hint: "esc to close" };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const body: string[] = [];
|
|
500
|
+
if (status.gitStateLabel) body.push(`Operation: ${status.gitStateLabel}`);
|
|
501
|
+
if (status.ahead > 0 || status.behind > 0) {
|
|
502
|
+
const parts: string[] = [];
|
|
503
|
+
if (status.ahead > 0) parts.push(`${status.ahead} ahead`);
|
|
504
|
+
if (status.behind > 0) parts.push(`${status.behind} behind`);
|
|
505
|
+
body.push(parts.join(", "));
|
|
506
|
+
}
|
|
507
|
+
if (status.stashed > 0) body.push(`${status.stashed} stash${status.stashed === 1 ? "" : "es"}`);
|
|
508
|
+
|
|
509
|
+
const changes = [...status.changes].sort(
|
|
510
|
+
(a, b) => CHANGE_ORDER.indexOf(a.code) - CHANGE_ORDER.indexOf(b.code),
|
|
511
|
+
);
|
|
512
|
+
for (const change of changes.slice(0, MAX_LISTED_CHANGES)) {
|
|
513
|
+
// The word rather than the porcelain letter: `?` alone is unreadable and
|
|
514
|
+
// the bubble has room for one small label per line.
|
|
515
|
+
body.push(
|
|
516
|
+
`${changeLabel(change.code)} · ${truncateToWidth(change.path, MAX_MESSAGE_COLUMNS, "…")}`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
if (changes.length > MAX_LISTED_CHANGES) {
|
|
520
|
+
body.push(`+${changes.length - MAX_LISTED_CHANGES} more`);
|
|
521
|
+
}
|
|
522
|
+
if (changes.length === 0 && body.length === 0) {
|
|
523
|
+
body.push("Working tree clean.");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// A detached HEAD has no branch to name it by, and the pill shows `HEAD` too.
|
|
527
|
+
const name = status.branch ?? (status.commit?.oid ? "HEAD" : "Git");
|
|
528
|
+
return {
|
|
529
|
+
title: changes.length > 0 ? `${name} · ${changes.length} changed` : name,
|
|
530
|
+
body,
|
|
531
|
+
hint: changes.length > 0 ? "read-only · esc to close" : "esc to close",
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function showGitChanges({ ctx, pill }: PillActionContext): void {
|
|
536
|
+
const status = activeGitStatus();
|
|
537
|
+
if (!status) {
|
|
538
|
+
if (ctx.hasUI) ctx.ui.notify("No git repository in this project", "info");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
// Live while open: the agent may touch files, and the next refresh re-reads
|
|
542
|
+
// them. Nothing here writes.
|
|
543
|
+
const handle = showPillBubble(ctx, gitBubbleContent, pill);
|
|
544
|
+
whileOpen(handle, () => handle.refresh());
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Segment key → action. Segments with no entry are labels: a click on one is not
|
|
549
|
+
* handled, so nothing is swallowed.
|
|
550
|
+
*/
|
|
551
|
+
export const PILL_ACTIONS: Readonly<Partial<Record<string, PillAction>>> = {
|
|
552
|
+
context: showContext,
|
|
553
|
+
git: showGitChanges,
|
|
554
|
+
model: showModels,
|
|
555
|
+
queue: showQueuedMessages,
|
|
556
|
+
subagents: showSubagents,
|
|
557
|
+
thinking: showThinkingLevel,
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The action for a pill span's key.
|
|
562
|
+
*
|
|
563
|
+
* A segment key Starline owns (a builtin segment) wins over a bridge, so a status
|
|
564
|
+
* cannot hijack `queue`. Everything else is looked up in the published API, which
|
|
565
|
+
* is how `extensionStatus:<key>` pills become clickable. No entry anywhere means
|
|
566
|
+
* the pill is a label and the click falls through.
|
|
567
|
+
*/
|
|
568
|
+
export function pillActionFor(key: string): PillAction | undefined {
|
|
569
|
+
if (key in PILL_SEGMENT_VARIABLES) return PILL_ACTIONS[key];
|
|
570
|
+
return externalPillAction(key);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Run an action from the mouse or from `/starline`, swallowing failure: a
|
|
575
|
+
* throwing action must not take down the render loop that dispatched the click,
|
|
576
|
+
* and the keyboard path is still available.
|
|
577
|
+
*/
|
|
578
|
+
export function runPillAction(
|
|
579
|
+
action: PillAction,
|
|
580
|
+
ctx: ExtensionContext,
|
|
581
|
+
pill?: PillAnchor,
|
|
582
|
+
via: PillActionContext["via"] = "click",
|
|
583
|
+
): void {
|
|
584
|
+
try {
|
|
585
|
+
const pending = action({ ctx, pill, via });
|
|
586
|
+
if (pending) void pending.catch(() => {});
|
|
587
|
+
} catch {
|
|
588
|
+
// Nothing to report: the action's own guards are what keep this rare.
|
|
589
|
+
}
|
|
590
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last anchors the footer rendered, so the keyboard path can point a bubble
|
|
3
|
+
* at the same pill a click would have hit.
|
|
4
|
+
*
|
|
5
|
+
* A module-level singleton like `working-indicator.ts`: there is one footer per
|
|
6
|
+
* session, it is the only writer, and actions only need "where is the pill for
|
|
7
|
+
* this segment right now". Renders happen at several widths while the layout
|
|
8
|
+
* measures, so the value is best-effort by design; the click path does not use it
|
|
9
|
+
* (it has the exact span that was hit).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PillAnchor } from "./pill-bubble";
|
|
13
|
+
|
|
14
|
+
let anchors = new Map<string, PillAnchor>();
|
|
15
|
+
|
|
16
|
+
export function setPillAnchors(next: ReadonlyMap<string, PillAnchor>): void {
|
|
17
|
+
anchors = new Map(next);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getPillAnchor(key: string): PillAnchor | undefined {
|
|
21
|
+
return anchors.get(key);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearPillAnchors(): void {
|
|
25
|
+
anchors = new Map();
|
|
26
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bridge other extensions use to make their pill clickable and to open the
|
|
3
|
+
* same speech bubble Starline uses.
|
|
4
|
+
*
|
|
5
|
+
* Starline renders other extensions' pills from `ctx.ui.setStatus()`; this is the
|
|
6
|
+
* other half: the owning extension says what a click means. A `pi-balance` pill
|
|
7
|
+
* can open a bubble with its 5h/7d numbers, a `pi-subagents` pill can list its
|
|
8
|
+
* runs, and neither has to render or position anything.
|
|
9
|
+
*
|
|
10
|
+
* The handshake is a global symbol rather than an import, for the same reason
|
|
11
|
+
* `@xzzpig/pi-mouse-events` publishes one: an extension cannot depend on a
|
|
12
|
+
* package Starline may be loaded twice from, and load order is not guaranteed.
|
|
13
|
+
* Register from a `session_start` handler, which runs after every extension in the
|
|
14
|
+
* process has been loaded; `/reload` re-runs it, so re-registering there is also
|
|
15
|
+
* what keeps the bridge alive across a reload.
|
|
16
|
+
*
|
|
17
|
+
* const api = globalThis[Symbol.for("pi-starline.pill-api.v1")];
|
|
18
|
+
* api?.register("pi-balance", ({ ctx, pill }) => {
|
|
19
|
+
* api.showBubble(ctx, { title: "Balance", body: ["5h: 42%", "7d: 12%"] }, pill);
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* The same action is reachable from the keyboard as `/starline <key>`. Types for
|
|
23
|
+
* other extensions are in `types/pill-api.d.ts`; the guide is `docs/pill-api.md`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { PillApi, PillApiAction } from "../../types/pill-api";
|
|
27
|
+
import { showPillBubble } from "./pill-bubble";
|
|
28
|
+
|
|
29
|
+
export const PILL_API_SYMBOL = Symbol.for("pi-starline.pill-api.v1");
|
|
30
|
+
|
|
31
|
+
// The published shapes: `types/pill-api.d.ts` is what other extensions compile
|
|
32
|
+
// against, so it is also what this file implements.
|
|
33
|
+
export type {
|
|
34
|
+
PillAnchor,
|
|
35
|
+
PillApi,
|
|
36
|
+
PillApiAction,
|
|
37
|
+
PillApiContext,
|
|
38
|
+
PillBubbleContent,
|
|
39
|
+
PillBubbleHandle,
|
|
40
|
+
PillBubbleLine,
|
|
41
|
+
PillBubbleSource,
|
|
42
|
+
} from "../../types/pill-api";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One action per key: a second registration replaces the first, which is what a
|
|
46
|
+
* `/reload` of the owning extension does. The disposer only removes its own
|
|
47
|
+
* action, so a stale handle cannot take a newer bridge down with it.
|
|
48
|
+
*/
|
|
49
|
+
const externalActions = new Map<string, PillApiAction>();
|
|
50
|
+
|
|
51
|
+
export function registerExternalPillAction(key: string, action: PillApiAction): () => void {
|
|
52
|
+
const name = key.trim();
|
|
53
|
+
if (!name) return () => {};
|
|
54
|
+
externalActions.set(name, action);
|
|
55
|
+
return () => {
|
|
56
|
+
if (externalActions.get(name) === action) externalActions.delete(name);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function externalPillAction(key: string): PillApiAction | undefined {
|
|
61
|
+
return externalActions.get(key);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Publish the API on `globalThis`, replacing any earlier object.
|
|
66
|
+
*
|
|
67
|
+
* The newest Starline wins. This runs when the extension loads, which is before
|
|
68
|
+
* any `session_start`, so bridges always register on the object published by the
|
|
69
|
+
* Starline that is actually running. Keeping an older object instead would, after
|
|
70
|
+
* a `/reload` that re-imports this module, leave bridges registering into the
|
|
71
|
+
* previous module's registry while the new footer reads its own.
|
|
72
|
+
*/
|
|
73
|
+
export function publishPillApi(): void {
|
|
74
|
+
const api: PillApi = {
|
|
75
|
+
version: 1,
|
|
76
|
+
register: registerExternalPillAction,
|
|
77
|
+
showBubble: (ctx, content, pill) => showPillBubble(ctx, content, pill),
|
|
78
|
+
};
|
|
79
|
+
(globalThis as Record<PropertyKey, unknown>)[PILL_API_SYMBOL] = api;
|
|
80
|
+
}
|