@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,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `pi-subagents` is running right now, read through its public RPC.
|
|
3
|
+
*
|
|
4
|
+
* `pi-subagents` publishes no status pill of its own; it draws FleetView and a
|
|
5
|
+
* widget. What it does publish is a versioned, in-process RPC on `pi.events`
|
|
6
|
+
* (`subagents:rpc:v1`, see its `docs/extension-api.md`): an untargeted `status`
|
|
7
|
+
* request answers from in-memory state with a bounded `fleet` record — the active
|
|
8
|
+
* runs, foreground and background, each with its agent, model and start time.
|
|
9
|
+
* That is a contract, not an internal, so Starline reads it instead of anything
|
|
10
|
+
* the package keeps privately.
|
|
11
|
+
*
|
|
12
|
+
* The count is refreshed when something can have changed it: the package's own
|
|
13
|
+
* lifecycle events, and Pi's tool events for the `subagent` tool (a foreground run
|
|
14
|
+
* starts with no event of its own). Never on a timer. Without `pi-subagents` the
|
|
15
|
+
* requests simply go unanswered, the count stays 0 and the pill never appears.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { SubagentsFormat } from "./config";
|
|
19
|
+
|
|
20
|
+
const REQUEST_EVENT = "subagents:rpc:v1:request";
|
|
21
|
+
const READY_EVENT = "subagents:rpc:v1:ready";
|
|
22
|
+
const REPLY_EVENT_PREFIX = "subagents:rpc:v1:reply:";
|
|
23
|
+
|
|
24
|
+
/** `pi-subagents` events after which the set of active runs may differ. */
|
|
25
|
+
const CHANGE_EVENTS = [
|
|
26
|
+
"subagent:async-started",
|
|
27
|
+
"subagent:async-complete",
|
|
28
|
+
"subagent:foreground-complete",
|
|
29
|
+
"subagent:process-terminal",
|
|
30
|
+
"subagent:child-status",
|
|
31
|
+
"subagent:control-event",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** The tool whose start and end bracket a foreground run. */
|
|
35
|
+
export const SUBAGENT_TOOL_NAME = "subagent";
|
|
36
|
+
|
|
37
|
+
/** Long enough for a loaded event loop, short enough not to pile up listeners. */
|
|
38
|
+
const REFRESH_TIMEOUT_MS = 2_000;
|
|
39
|
+
/** Bursts of child events collapse into one request. */
|
|
40
|
+
const REFRESH_DELAY_MS = 150;
|
|
41
|
+
|
|
42
|
+
export type EventBusLike = {
|
|
43
|
+
on(channel: string, handler: (data: unknown) => void): () => void;
|
|
44
|
+
emit(channel: string, data: unknown): void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type FleetEntry = {
|
|
48
|
+
agent: string;
|
|
49
|
+
role?: string;
|
|
50
|
+
model?: string;
|
|
51
|
+
effort?: string;
|
|
52
|
+
/** Epoch milliseconds. */
|
|
53
|
+
startedAt: number;
|
|
54
|
+
tokens: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type FleetSnapshot = {
|
|
58
|
+
totalActive: number;
|
|
59
|
+
entries: FleetEntry[];
|
|
60
|
+
/** Active runs beyond the entries the package chose to list. */
|
|
61
|
+
omitted: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const EMPTY_FLEET: FleetSnapshot = { totalActive: 0, entries: [], omitted: 0 };
|
|
65
|
+
|
|
66
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
67
|
+
return typeof value === "object" && value !== null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function optionalText(value: unknown): string | undefined {
|
|
71
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function count(value: unknown): number {
|
|
75
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read a `status` reply's `data.fleet`. Anything unexpected is dropped rather than
|
|
80
|
+
* trusted: the DTO is versioned, and the package's docs ask clients to ignore
|
|
81
|
+
* fields they do not know.
|
|
82
|
+
*/
|
|
83
|
+
export function parseFleet(reply: unknown): FleetSnapshot | undefined {
|
|
84
|
+
if (!isRecord(reply) || reply.success !== true || !isRecord(reply.data)) return undefined;
|
|
85
|
+
const fleet = reply.data.fleet;
|
|
86
|
+
if (!isRecord(fleet) || fleet.version !== 1 || !Array.isArray(fleet.entries)) return undefined;
|
|
87
|
+
|
|
88
|
+
const entries: FleetEntry[] = [];
|
|
89
|
+
for (const raw of fleet.entries) {
|
|
90
|
+
if (!isRecord(raw)) continue;
|
|
91
|
+
const agent = optionalText(raw.agent);
|
|
92
|
+
const startedAt = raw.startedAt;
|
|
93
|
+
if (!agent || typeof startedAt !== "number" || !Number.isFinite(startedAt)) continue;
|
|
94
|
+
entries.push({
|
|
95
|
+
agent,
|
|
96
|
+
role: optionalText(raw.role),
|
|
97
|
+
model: optionalText(raw.model),
|
|
98
|
+
effort: optionalText(raw.effort),
|
|
99
|
+
startedAt,
|
|
100
|
+
tokens: isRecord(raw.tokens) ? count(raw.tokens.total) : 0,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
totalActive: Math.max(count(fleet.totalActive), entries.length),
|
|
105
|
+
entries,
|
|
106
|
+
omitted: count(fleet.omitted),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type SubagentFleet = {
|
|
111
|
+
/** The last answer, or the empty fleet before any. */
|
|
112
|
+
snapshot(): FleetSnapshot;
|
|
113
|
+
/** Whether `pi-subagents` has ever answered or announced itself. */
|
|
114
|
+
available(): boolean;
|
|
115
|
+
/** Ask again soon; bursts collapse into one request. */
|
|
116
|
+
refresh(): void;
|
|
117
|
+
/** Ask now and wait for the answer, for a bubble that wants fresh numbers. */
|
|
118
|
+
fetch(timeoutMs?: number): Promise<FleetSnapshot | undefined>;
|
|
119
|
+
dispose(): void;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Track the fleet on `bus`. `onChange` runs when the number of active runs
|
|
124
|
+
* changes, which is all the pill shows; the bubble fetches its own fresh copy.
|
|
125
|
+
*/
|
|
126
|
+
export function createSubagentFleet(
|
|
127
|
+
bus: EventBusLike,
|
|
128
|
+
options: { onChange: () => void; delayMs?: number },
|
|
129
|
+
): SubagentFleet {
|
|
130
|
+
const delayMs = options.delayMs ?? REFRESH_DELAY_MS;
|
|
131
|
+
let snapshot = EMPTY_FLEET;
|
|
132
|
+
let available = false;
|
|
133
|
+
let disposed = false;
|
|
134
|
+
let scheduled: ReturnType<typeof setTimeout> | undefined;
|
|
135
|
+
let inFlight = false;
|
|
136
|
+
let again = false;
|
|
137
|
+
let nextRequest = 0;
|
|
138
|
+
const pending = new Set<() => void>();
|
|
139
|
+
|
|
140
|
+
const fetch = (timeoutMs = REFRESH_TIMEOUT_MS): Promise<FleetSnapshot | undefined> =>
|
|
141
|
+
new Promise((resolve) => {
|
|
142
|
+
if (disposed) {
|
|
143
|
+
resolve(undefined);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
nextRequest += 1;
|
|
147
|
+
const requestId = `starline-fleet-${process.pid}-${Date.now()}-${nextRequest}`;
|
|
148
|
+
let unsubscribe: () => void = () => {};
|
|
149
|
+
const finish = (result: FleetSnapshot | undefined) => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
unsubscribe();
|
|
152
|
+
pending.delete(cancel);
|
|
153
|
+
if (result) {
|
|
154
|
+
available = true;
|
|
155
|
+
const changed = result.totalActive !== snapshot.totalActive;
|
|
156
|
+
snapshot = result;
|
|
157
|
+
if (changed) options.onChange();
|
|
158
|
+
}
|
|
159
|
+
resolve(result);
|
|
160
|
+
};
|
|
161
|
+
const cancel = () => finish(undefined);
|
|
162
|
+
const timer = setTimeout(cancel, timeoutMs);
|
|
163
|
+
pending.add(cancel);
|
|
164
|
+
try {
|
|
165
|
+
unsubscribe = bus.on(`${REPLY_EVENT_PREFIX}${requestId}`, (reply) =>
|
|
166
|
+
finish(parseFleet(reply)),
|
|
167
|
+
);
|
|
168
|
+
bus.emit(REQUEST_EVENT, { version: 1, requestId, method: "status", params: {} });
|
|
169
|
+
} catch {
|
|
170
|
+
finish(undefined);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const run = () => {
|
|
175
|
+
scheduled = undefined;
|
|
176
|
+
if (disposed) return;
|
|
177
|
+
inFlight = true;
|
|
178
|
+
void fetch().finally(() => {
|
|
179
|
+
inFlight = false;
|
|
180
|
+
if (again) {
|
|
181
|
+
again = false;
|
|
182
|
+
refresh();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const refresh = () => {
|
|
188
|
+
if (disposed) return;
|
|
189
|
+
if (inFlight) {
|
|
190
|
+
again = true;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (scheduled === undefined) scheduled = setTimeout(run, delayMs);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const unsubscribers: Array<() => void> = [];
|
|
197
|
+
try {
|
|
198
|
+
unsubscribers.push(
|
|
199
|
+
bus.on(READY_EVENT, () => {
|
|
200
|
+
available = true;
|
|
201
|
+
refresh();
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
for (const channel of CHANGE_EVENTS) unsubscribers.push(bus.on(channel, refresh));
|
|
205
|
+
} catch {
|
|
206
|
+
// A bus that refuses listeners leaves the pill off, which is the same as no
|
|
207
|
+
// pi-subagents at all.
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
snapshot: () => snapshot,
|
|
212
|
+
available: () => available,
|
|
213
|
+
refresh,
|
|
214
|
+
fetch,
|
|
215
|
+
dispose() {
|
|
216
|
+
disposed = true;
|
|
217
|
+
if (scheduled !== undefined) clearTimeout(scheduled);
|
|
218
|
+
for (const unsubscribe of unsubscribers) unsubscribe();
|
|
219
|
+
for (const cancel of [...pending]) cancel();
|
|
220
|
+
snapshot = EMPTY_FLEET;
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The fleet the footer and the pill action read. One per Starline instance, set
|
|
227
|
+
* at session start like the other session-scoped singletons.
|
|
228
|
+
*/
|
|
229
|
+
let activeFleet: SubagentFleet | undefined;
|
|
230
|
+
|
|
231
|
+
export function setActiveSubagentFleet(fleet: SubagentFleet | undefined): void {
|
|
232
|
+
activeFleet?.dispose();
|
|
233
|
+
activeFleet = fleet;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function getActiveSubagentFleet(): SubagentFleet | undefined {
|
|
237
|
+
return activeFleet;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The pill's text: how many are running, or nothing so the pill is not drawn. */
|
|
241
|
+
export function subagentsSegmentText(format: SubagentsFormat = "agents"): string {
|
|
242
|
+
const total = activeFleet?.snapshot().totalActive ?? 0;
|
|
243
|
+
if (total <= 0) return "";
|
|
244
|
+
if (format === "count") return `${total}`;
|
|
245
|
+
return `${total} agent${total === 1 ? "" : "s"}`;
|
|
246
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long the current agent run has been going, and how long the last one took.
|
|
3
|
+
*
|
|
4
|
+
* The pill answers "is it working" with the spinner, but not "for how long" —
|
|
5
|
+
* and that is the question a silent tool asks two minutes in, when the only
|
|
6
|
+
* thing on screen is a frame that has been turning since whenever. The same
|
|
7
|
+
* number is worth keeping once the run ends: what the turn cost in wall time is
|
|
8
|
+
* what you compare the next one against, and a value that vanishes the moment
|
|
9
|
+
* the answer arrives is the one moment it was useful.
|
|
10
|
+
*
|
|
11
|
+
* The clock is set by the two run boundaries Pi already emits, `agent_start` and
|
|
12
|
+
* `agent_end`, and read on every footer render. It is deliberately not a timer:
|
|
13
|
+
* the working spinner drives the TUI's own render loop while a run is going
|
|
14
|
+
* (`Loader` ticks at 80 ms and calls `requestRender`), so a per-second number
|
|
15
|
+
* would only be a second thing to keep in sync. Nothing ticks when idle, and the
|
|
16
|
+
* number is not moving then either.
|
|
17
|
+
*
|
|
18
|
+
* `now` is a parameter rather than a `Date.now()` call inside, so the tests can
|
|
19
|
+
* move the wall clock without fake timers.
|
|
20
|
+
*/
|
|
21
|
+
export class TurnClock {
|
|
22
|
+
private startedAt: number | undefined;
|
|
23
|
+
private lastMs: number | undefined;
|
|
24
|
+
|
|
25
|
+
/** A run began: start counting from here, and drop the previous run's total. */
|
|
26
|
+
begin(now: number = Date.now()): void {
|
|
27
|
+
this.startedAt = now;
|
|
28
|
+
// The pill shows one number. While a run is going it is the live one, so
|
|
29
|
+
// keeping the old total around would only be a value nothing draws and every
|
|
30
|
+
// test has to account for.
|
|
31
|
+
this.lastMs = undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The run ended. The total it reached stays available as `last`, so the pill
|
|
36
|
+
* keeps reading after the spinner stops.
|
|
37
|
+
*/
|
|
38
|
+
end(now: number = Date.now()): void {
|
|
39
|
+
const elapsed = this.elapsedMs(now);
|
|
40
|
+
if (elapsed !== undefined) this.lastMs = elapsed;
|
|
41
|
+
this.startedAt = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True while a run is in progress. */
|
|
45
|
+
running(): boolean {
|
|
46
|
+
return this.startedAt !== undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Milliseconds since the run started, or undefined when no run is going.
|
|
51
|
+
*
|
|
52
|
+
* Undefined rather than 0 on purpose: 0 is a legal elapsed time, and the
|
|
53
|
+
* footer needs to tell "just started" from "not working".
|
|
54
|
+
*/
|
|
55
|
+
elapsedMs(now: number = Date.now()): number | undefined {
|
|
56
|
+
const startedAt = this.startedAt;
|
|
57
|
+
if (startedAt === undefined) return undefined;
|
|
58
|
+
return Math.max(0, now - startedAt);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Both numbers the pill may draw, in one read. */
|
|
62
|
+
timing(now: number = Date.now()): TurnTiming {
|
|
63
|
+
const timing: TurnTiming = {};
|
|
64
|
+
const running = this.elapsedMs(now);
|
|
65
|
+
if (running !== undefined) timing.running = running;
|
|
66
|
+
if (this.lastMs !== undefined) timing.last = this.lastMs;
|
|
67
|
+
return timing;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Forget everything. A session is over or changed, so nothing on screen is ours. */
|
|
71
|
+
clear(): void {
|
|
72
|
+
this.startedAt = undefined;
|
|
73
|
+
this.lastMs = undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What the pill may draw: the live count while working, and the last total. */
|
|
78
|
+
export type TurnTiming = {
|
|
79
|
+
running?: number;
|
|
80
|
+
last?: number;
|
|
81
|
+
};
|