localpi 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +389 -11
  2. package/dist/src/cli/cli.js +115 -6
  3. package/dist/src/cli/main.js +0 -0
  4. package/dist/src/llm/openai.js +65 -4
  5. package/dist/src/localpi/acp.js +118 -0
  6. package/dist/src/localpi/catalog.js +79 -7
  7. package/dist/src/localpi/catppuccin.js +64 -0
  8. package/dist/src/localpi/llama-server.js +72 -39
  9. package/dist/src/localpi/model-profile.js +4 -0
  10. package/dist/src/localpi/options.js +129 -9
  11. package/dist/src/localpi/provider-registry.js +51 -3
  12. package/dist/src/localpi/runtime-connection.js +11 -8
  13. package/dist/src/localpi/runtime.js +12 -7
  14. package/dist/src/localpi/settings-state.js +13 -3
  15. package/dist/src/pi/app.js +13 -6
  16. package/dist/src/pi/extension-sources/continue-on-truncation.js +55 -0
  17. package/dist/src/pi/extension-sources/settings-file.js +31 -0
  18. package/dist/src/pi/extension-sources/status-line.js +424 -0
  19. package/dist/src/pi/extension-sources/thinking-control.js +4 -47
  20. package/dist/src/pi/extension-sources/token-status.js +545 -116
  21. package/dist/src/pi/extension-sources/tool-approval.js +155 -14
  22. package/dist/src/pi/extensions.js +55 -12
  23. package/dist/src/pi/skills.js +24 -0
  24. package/dist/src/pi/theme.js +107 -0
  25. package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +39 -11
  26. package/docs/2026-09-23-acp-mode-plan.md +111 -0
  27. package/docs/2026-09-24-continue-on-truncation-plan.md +115 -0
  28. package/docs/design-principles.md +114 -0
  29. package/docs/implementation-plan.md +33 -0
  30. package/docs/runtime-specification.md +98 -4
  31. package/package.json +11 -7
  32. package/dist/src/pi/extension-sources/demo-mode.js +0 -110
@@ -1,12 +1,23 @@
1
- export function tokenStatusExtensionSource() {
2
- return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import { settingsFileSource } from "./settings-file.js";
2
+ export function tokenStatusExtensionSource(config) {
3
+ const initialModeSource = JSON.stringify(config.mode);
4
+ const slotsUrlSource = JSON.stringify(slotsUrl(config));
5
+ return `import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname } from "node:path";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+
9
+ type StatsMode = "off" | "line" | "full";
3
10
 
4
11
  type Usage = {
5
- input?: number;
6
- output?: number;
7
- cacheRead?: number;
8
- cacheWrite?: number;
9
- totalTokens?: number;
12
+ input?: number | undefined;
13
+ output?: number | undefined;
14
+ cacheRead?: number | undefined;
15
+ cacheWrite?: number | undefined;
16
+ };
17
+
18
+ type PrefillProgress = {
19
+ processed: number;
20
+ total: number;
10
21
  };
11
22
 
12
23
  type TurnState = {
@@ -14,164 +25,582 @@ type TurnState = {
14
25
  firstOutputAt?: number;
15
26
  outputText: string;
16
27
  estimatedOutputTokens: number;
17
- lastStatusAt: number;
28
+ prefill?: PrefillProgress;
29
+ lastRenderAt: number;
30
+ };
31
+
32
+ type StatsEntry = {
33
+ rate?: number | undefined;
34
+ output?: number | undefined;
35
+ input?: number | undefined;
36
+ cacheRead?: number | undefined;
37
+ cacheWrite?: number | undefined;
38
+ prefillSeconds?: number | undefined;
39
+ elapsedSeconds?: number | undefined;
40
+ contextTokens?: number | undefined;
41
+ contextWindow?: number | undefined;
42
+ contextPercent?: number | undefined;
43
+ };
44
+
45
+ // The status line extension renders the footer, and Pi loads every extension into one process, so
46
+ // the last completed turn travels through this typed global. A missing bridge only hides the rate.
47
+ type TurnSummary = {
48
+ rate?: number | undefined;
49
+ output?: number | undefined;
50
+ elapsedSeconds?: number | undefined;
51
+ };
52
+
53
+ type StatsBridge = { lastTurn?: TurnSummary | undefined };
54
+
55
+ type ContextUsage = {
56
+ tokens: number | null;
57
+ contextWindow: number;
58
+ percent: number | null;
18
59
  };
19
60
 
61
+ type ThemeLike = {
62
+ fg(color: string, text: string): string;
63
+ bold(text: string): string;
64
+ };
65
+
66
+ type Segment = {
67
+ text: string;
68
+ color: string;
69
+ };
70
+
71
+ type StatsContext = {
72
+ readonly hasUI: boolean;
73
+ readonly ui: {
74
+ setWorkingMessage(message?: string): void;
75
+ notify(message: string, type?: "info" | "warning" | "error"): void;
76
+ theme: ThemeLike;
77
+ };
78
+ getContextUsage(): ContextUsage | undefined;
79
+ };
80
+
81
+ // Stats mode is remembered per localpi launch. /stats updates both this session and the setting.
82
+ // Pi owns Pi's own footer facts; localpi adds the live working line, one transcript entry per turn,
83
+ // and the rate of the last completed turn in the status line.
84
+ ${settingsFileSource(config.settingsPath)}
85
+ const initialMode: StatsMode = ${initialModeSource};
86
+ // llama.cpp exposes live prefill progress on /slots. Other engines have no equivalent endpoint.
87
+ const slotsUrl: string | undefined = ${slotsUrlSource};
88
+
89
+ const entryType = "localpi-stats";
90
+ const modes: readonly StatsMode[] = ["off", "line", "full"];
91
+ const renderIntervalMs = 200;
92
+ const slotsIntervalMs = 300;
93
+ const slotsTimeoutMs = 1500;
94
+ const renderThrottleMs = 100;
95
+
20
96
  export default function localpiTokenStatus(pi: ExtensionAPI): void {
21
- let currentTurn: TurnState | undefined;
97
+ let mode: StatsMode = initialMode;
98
+ let state: TurnState | undefined;
99
+ let renderTimer: ReturnType<typeof setInterval> | undefined;
100
+ let slotsTimer: ReturnType<typeof setInterval> | undefined;
101
+ let slotsAvailable = slotsUrl !== undefined;
22
102
 
23
- pi.on("turn_start", () => {
24
- currentTurn = {
103
+ pi.registerEntryRenderer(entryType, (entry, _options, theme) => {
104
+ const data = readEntry(entry.data);
105
+ return {
106
+ render: (width: number) => [entryLine(data, theme, width)],
107
+ invalidate: () => undefined
108
+ };
109
+ });
110
+
111
+ pi.registerCommand("stats", {
112
+ description: "Set localpi stats mode",
113
+ getArgumentCompletions: (prefix: string) => {
114
+ const trimmed = prefix.trim().toLowerCase();
115
+ const matches = modes.filter((value) => value.startsWith(trimmed));
116
+ return matches.length === 0 ? null : matches.map((value) => ({ value, label: value }));
117
+ },
118
+ handler: async (args: string, ctx) => {
119
+ const requested = parseMode(args);
120
+ const next = requested === undefined ? await promptMode(mode, ctx) : requested;
121
+ if (next === undefined) {
122
+ return;
123
+ }
124
+ mode = next;
125
+ await persistMode(next);
126
+ if (ctx.hasUI) {
127
+ ctx.ui.setWorkingMessage();
128
+ }
129
+ ctx.ui.notify("stats: " + next, "info");
130
+ }
131
+ });
132
+
133
+ pi.on("turn_start", (_event, ctx) => {
134
+ stopTimers();
135
+ state = {
25
136
  startedAt: Date.now(),
26
137
  outputText: "",
27
138
  estimatedOutputTokens: 0,
28
- lastStatusAt: 0
139
+ lastRenderAt: 0
29
140
  };
141
+ if (!ctx.hasUI || mode === "off") {
142
+ return;
143
+ }
144
+ render(ctx, Date.now());
145
+ renderTimer = setInterval(() => {
146
+ render(ctx, Date.now());
147
+ }, renderIntervalMs);
148
+ if (mode === "full" && slotsAvailable) {
149
+ slotsTimer = setInterval(() => {
150
+ void pollPrefill();
151
+ }, slotsIntervalMs);
152
+ }
30
153
  });
31
154
 
32
155
  pi.on("message_update", (event, ctx) => {
33
- const state = currentTurn;
34
- if (!ctx.hasUI || state === undefined) {
156
+ const current = state;
157
+ if (current === undefined) {
35
158
  return;
36
159
  }
37
- const now = Date.now();
38
- const update = textUpdateFromUnknown(event.assistantMessageEvent ?? event.message ?? event);
39
- if (update.kind === "delta") {
40
- state.outputText += update.text;
41
- } else if (update.text.length > state.outputText.length) {
42
- state.outputText = update.text;
160
+ const delta = textDelta(event.assistantMessageEvent);
161
+ if (delta === undefined) {
162
+ const snapshot = messageText(event.message);
163
+ if (snapshot.length > current.outputText.length) {
164
+ current.outputText = snapshot;
165
+ }
166
+ } else {
167
+ current.outputText += delta;
43
168
  }
44
- state.estimatedOutputTokens = Math.ceil(state.outputText.length / 4);
45
- if (state.firstOutputAt === undefined && state.outputText.length > 0) {
46
- state.firstOutputAt = now;
169
+ current.estimatedOutputTokens = Math.ceil(current.outputText.length / 4);
170
+ if (current.firstOutputAt === undefined && current.outputText.length > 0) {
171
+ current.firstOutputAt = Date.now();
172
+ stopSlotsTimer();
47
173
  }
48
- if (now - state.lastStatusAt < 250) {
49
- return;
50
- }
51
- state.lastStatusAt = now;
52
- ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state, now)));
174
+ render(ctx, Date.now());
53
175
  });
54
176
 
55
177
  pi.on("turn_end", (event, ctx) => {
56
- const state = currentTurn ?? {
57
- startedAt: Date.now(),
58
- outputText: "",
59
- estimatedOutputTokens: 0,
60
- lastStatusAt: 0
61
- };
62
- currentTurn = undefined;
63
-
64
- if (!ctx.hasUI || event.message.role !== "assistant") {
178
+ const current = state ?? emptyState();
179
+ state = undefined;
180
+ stopTimers();
181
+ if (!ctx.hasUI) {
65
182
  return;
66
183
  }
67
-
68
- const usage = event.message.usage as Usage | undefined;
69
- const output = usage?.output ?? state.estimatedOutputTokens;
70
- const input = usage?.input ?? 0;
71
- const cacheRead = usage?.cacheRead ?? 0;
72
- const cacheWrite = usage?.cacheWrite ?? 0;
73
- const now = Date.now();
74
- const elapsedSeconds = elapsed(state, now);
75
- const decodeSeconds = generationElapsed(state, now);
76
- const prefillText = prefillStatusText(state, input, cacheWrite);
77
- const context = ctx.getContextUsage();
78
- const contextText =
79
- context && context.percent !== null
80
- ? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\`
81
- : "ctx ?";
82
-
83
- ctx.ui.setStatus(
84
- "localpi-perf",
85
- ctx.ui.theme.fg(
86
- "dim",
87
- [
88
- \`gen \${(output / decodeSeconds).toFixed(1)} tok/s\`,
89
- prefillText,
90
- \`out \${output}\`,
91
- \`in \${input}\`,
92
- cacheRead > 0 ? \`cache \${cacheRead}\` : undefined,
93
- cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined,
94
- \`\${elapsedSeconds.toFixed(1)}s\`,
95
- contextText
96
- ]
97
- .filter(Boolean)
98
- .join(" | ")
99
- )
100
- );
184
+ ctx.ui.setWorkingMessage();
185
+ if (event.message.role !== "assistant") {
186
+ return;
187
+ }
188
+ const data = turnEntry(current, usageOf(event.message), contextUsage(ctx), Date.now());
189
+ publishTurn(data);
190
+ if (mode !== "full") {
191
+ return;
192
+ }
193
+ pi.appendEntry(entryType, data);
101
194
  });
102
195
 
103
196
  pi.on("session_shutdown", (_event, ctx) => {
104
- if (ctx.hasUI) {
105
- ctx.ui.setStatus("localpi-perf", "");
197
+ stopTimers();
198
+ state = undefined;
199
+ if (!ctx.hasUI) {
200
+ return;
106
201
  }
202
+ ctx.ui.setWorkingMessage();
107
203
  });
204
+
205
+ function stopTimers(): void {
206
+ stopRenderTimer();
207
+ stopSlotsTimer();
208
+ }
209
+
210
+ function stopRenderTimer(): void {
211
+ if (renderTimer === undefined) {
212
+ return;
213
+ }
214
+ clearInterval(renderTimer);
215
+ renderTimer = undefined;
216
+ }
217
+
218
+ function stopSlotsTimer(): void {
219
+ if (slotsTimer === undefined) {
220
+ return;
221
+ }
222
+ clearInterval(slotsTimer);
223
+ slotsTimer = undefined;
224
+ }
225
+
226
+ function render(ctx: StatsContext, now: number): void {
227
+ const current = state;
228
+ if (current === undefined || !ctx.hasUI || mode === "off") {
229
+ return;
230
+ }
231
+ if (now - current.lastRenderAt < renderThrottleMs) {
232
+ return;
233
+ }
234
+ current.lastRenderAt = now;
235
+ ctx.ui.setWorkingMessage(workingLine(current, now, contextUsage(ctx), ctx.ui.theme));
236
+ }
237
+
238
+ async function pollPrefill(): Promise<void> {
239
+ const current = state;
240
+ if (current === undefined || slotsUrl === undefined || !slotsAvailable) {
241
+ return;
242
+ }
243
+ if (current.firstOutputAt !== undefined) {
244
+ stopSlotsTimer();
245
+ return;
246
+ }
247
+ try {
248
+ const response = await fetch(slotsUrl, { signal: AbortSignal.timeout(slotsTimeoutMs) });
249
+ if (!response.ok) {
250
+ throw new Error("llama.cpp slots request failed");
251
+ }
252
+ const progress = prefillProgress(await response.json());
253
+ if (progress !== undefined) {
254
+ current.prefill = progress;
255
+ }
256
+ } catch {
257
+ // The endpoint is missing or slow. Fall back to elapsed-time prefill display for this session.
258
+ slotsAvailable = false;
259
+ stopSlotsTimer();
260
+ }
261
+ }
262
+ }
263
+
264
+ function emptyState(): TurnState {
265
+ return { startedAt: Date.now(), outputText: "", estimatedOutputTokens: 0, lastRenderAt: 0 };
108
266
  }
109
267
 
110
- function statusText(state: TurnState, now: number): string {
111
- const elapsedSeconds = elapsed(state, now);
268
+ function workingLine(
269
+ state: TurnState,
270
+ now: number,
271
+ usage: ContextUsage | undefined,
272
+ theme: ThemeLike
273
+ ): string {
274
+ return (
275
+ theme.bold("Working") +
276
+ theme.fg("dim", " (") +
277
+ renderParts(workingParts(state, now, usage), theme) +
278
+ theme.fg("dim", ")")
279
+ );
280
+ }
281
+
282
+ function workingParts(state: TurnState, now: number, usage: ContextUsage | undefined): Segment[][] {
283
+ const parts: Segment[][] = [];
112
284
  if (state.firstOutputAt === undefined) {
113
- return \`prefill \${elapsedSeconds.toFixed(1)}s | out ~\${state.estimatedOutputTokens}\`;
285
+ parts.push(...prefillParts(state, now));
286
+ } else {
287
+ const seconds = secondsBetween(state.firstOutputAt, now);
288
+ parts.push([{ text: formatElapsed(seconds), color: "dim" }]);
289
+ parts.push([
290
+ { text: formatTokenCount(state.estimatedOutputTokens) + " out", color: "dim" }
291
+ ]);
292
+ parts.push([
293
+ { text: formatRate(state.estimatedOutputTokens / seconds) + " tok/s", color: "accent" }
294
+ ]);
114
295
  }
115
- const decodeSeconds = generationElapsed(state, now);
116
- const prefillSeconds = secondsBetween(state.startedAt, state.firstOutputAt);
296
+ parts.push(...contextParts(usage));
297
+ return parts;
298
+ }
299
+
300
+ function prefillParts(state: TurnState, now: number): Segment[][] {
301
+ const seconds = secondsBetween(state.startedAt, now);
302
+ const progress = state.prefill;
303
+ if (progress === undefined || progress.total <= 0) {
304
+ return [[{ text: "prefill " + formatElapsed(seconds), color: "dim" }]];
305
+ }
306
+ const percent = Math.min(100, Math.max(0, Math.round((progress.processed / progress.total) * 100)));
117
307
  return [
118
- \`gen \${(state.estimatedOutputTokens / decodeSeconds).toFixed(1)} tok/s\`,
119
- \`out ~\${state.estimatedOutputTokens}\`,
120
- \`prefill \${prefillSeconds.toFixed(1)}s\`,
121
- \`total \${elapsedSeconds.toFixed(1)}s\`
122
- ].join(" | ");
308
+ [
309
+ { text: "prefill ", color: "dim" },
310
+ { text: percent + "%", color: "accent" }
311
+ ],
312
+ [
313
+ {
314
+ text: formatTokenCount(progress.processed) + "/" + formatTokenCount(progress.total) + " tok",
315
+ color: "dim"
316
+ }
317
+ ],
318
+ [{ text: formatElapsed(seconds), color: "dim" }]
319
+ ];
123
320
  }
124
321
 
125
- function prefillStatusText(
126
- state: TurnState,
127
- input: number,
128
- cacheWrite: number
129
- ): string | undefined {
130
- const tokens = prefillTokenCount(input, cacheWrite);
131
- if (state.firstOutputAt === undefined || tokens <= 0) {
322
+ function entryLine(data: StatsEntry, theme: ThemeLike, width: number): string {
323
+ const parts = entryParts(data);
324
+ const plain = parts.map((part) => part.map((segment) => segment.text).join("")).join(" · ");
325
+ if (width > 0 && plain.length > width) {
326
+ return theme.fg("dim", plain.slice(0, Math.max(0, width - 1)) + "…");
327
+ }
328
+ return renderParts(parts, theme);
329
+ }
330
+
331
+ function entryParts(data: StatsEntry): Segment[][] {
332
+ const parts: Segment[][] = [];
333
+ if (data.elapsedSeconds !== undefined) {
334
+ parts.push([{ text: formatElapsed(data.elapsedSeconds), color: "dim" }]);
335
+ }
336
+ if (data.output !== undefined) {
337
+ parts.push([{ text: formatTokenCount(data.output) + " out", color: "dim" }]);
338
+ }
339
+ if (data.rate !== undefined) {
340
+ parts.push([{ text: formatRate(data.rate) + " tok/s", color: "accent" }]);
341
+ }
342
+ if (data.input !== undefined && data.input > 0) {
343
+ parts.push([{ text: formatTokenCount(data.input) + " in", color: "dim" }]);
344
+ }
345
+ if (data.cacheRead !== undefined && data.cacheRead > 0) {
346
+ parts.push([{ text: "cache " + formatTokenCount(data.cacheRead), color: "dim" }]);
347
+ }
348
+ if (data.cacheWrite !== undefined && data.cacheWrite > 0) {
349
+ parts.push([{ text: "cache write " + formatTokenCount(data.cacheWrite), color: "dim" }]);
350
+ }
351
+ if (data.prefillSeconds !== undefined) {
352
+ parts.push([{ text: "prefill " + formatElapsed(data.prefillSeconds), color: "dim" }]);
353
+ }
354
+ parts.push(...contextParts(entryContext(data)));
355
+ return parts;
356
+ }
357
+
358
+ function entryContext(data: StatsEntry): ContextUsage | undefined {
359
+ if (data.contextPercent === undefined) {
132
360
  return undefined;
133
361
  }
134
- const seconds = secondsBetween(state.startedAt, state.firstOutputAt);
135
- return \`prefill \${(tokens / seconds).toFixed(1)} tok/s\`;
362
+ return {
363
+ tokens: data.contextTokens ?? null,
364
+ contextWindow: data.contextWindow ?? 0,
365
+ percent: data.contextPercent
366
+ };
136
367
  }
137
368
 
138
- function prefillTokenCount(input: number, cacheWrite: number): number {
139
- return Math.max(input + cacheWrite, 0);
369
+ function contextParts(usage: ContextUsage | undefined): Segment[][] {
370
+ if (usage === undefined || usage.percent === null) {
371
+ return [];
372
+ }
373
+ const color = contextColor(usage.percent);
374
+ const measured = usage.tokens !== null && usage.contextWindow > 0;
375
+ const text = measured
376
+ ? formatTokenCount(usage.tokens as number) +
377
+ "/" +
378
+ formatTokenCount(usage.contextWindow) +
379
+ " (" +
380
+ Math.round(usage.percent) +
381
+ "%)"
382
+ : Math.round(usage.percent) + "%";
383
+ return [
384
+ [
385
+ { text: "ctx ", color: "dim" },
386
+ { text, color }
387
+ ]
388
+ ];
140
389
  }
141
390
 
142
- function generationElapsed(state: TurnState, now: number): number {
143
- return secondsBetween(state.firstOutputAt ?? state.startedAt, now);
391
+ function contextColor(percent: number): string {
392
+ if (percent >= 95) {
393
+ return "error";
394
+ }
395
+ return percent >= 80 ? "warning" : "accent";
144
396
  }
145
397
 
146
- function elapsed(state: TurnState, now: number): number {
147
- return secondsBetween(state.startedAt, now);
398
+ function renderParts(parts: readonly Segment[][], theme: ThemeLike): string {
399
+ return parts
400
+ .map((part) => part.map((segment) => theme.fg(segment.color, segment.text)).join(""))
401
+ .join(theme.fg("dim", " · "));
148
402
  }
149
403
 
150
- function secondsBetween(start: number, end: number): number {
151
- return Math.max((end - start) / 1000, 0.001);
404
+ function statsBridge(): StatsBridge {
405
+ const holder = globalThis as unknown as { localpiStats?: StatsBridge };
406
+ holder.localpiStats ??= {};
407
+ return holder.localpiStats;
152
408
  }
153
409
 
154
- type TextUpdate = {
155
- kind: "delta" | "snapshot";
156
- text: string;
157
- };
410
+ function publishTurn(entry: StatsEntry): void {
411
+ statsBridge().lastTurn = {
412
+ rate: entry.rate,
413
+ output: entry.output,
414
+ elapsedSeconds: entry.elapsedSeconds
415
+ };
416
+ }
417
+
418
+ function turnEntry(
419
+ state: TurnState,
420
+ usage: Usage | undefined,
421
+ context: ContextUsage | undefined,
422
+ now: number
423
+ ): StatsEntry {
424
+ const firstOutputAt = state.firstOutputAt;
425
+ const output = usage?.output ?? state.estimatedOutputTokens;
426
+ return withoutUndefined({
427
+ rate: firstOutputAt === undefined ? undefined : output / secondsBetween(firstOutputAt, now),
428
+ output,
429
+ input: usage?.input,
430
+ cacheRead: usage?.cacheRead,
431
+ cacheWrite: usage?.cacheWrite,
432
+ prefillSeconds:
433
+ firstOutputAt === undefined ? undefined : secondsBetween(state.startedAt, firstOutputAt),
434
+ elapsedSeconds: secondsBetween(state.startedAt, now),
435
+ contextTokens: context?.tokens ?? undefined,
436
+ contextWindow: context?.contextWindow,
437
+ contextPercent: context?.percent ?? undefined
438
+ });
439
+ }
440
+
441
+ function contextUsage(ctx: StatsContext): ContextUsage | undefined {
442
+ return ctx.getContextUsage();
443
+ }
444
+
445
+ function readEntry(data: unknown): StatsEntry {
446
+ if (!isRecord(data)) {
447
+ return {};
448
+ }
449
+ return withoutUndefined({
450
+ rate: numberOrUndefined(data["rate"]),
451
+ output: numberOrUndefined(data["output"]),
452
+ input: numberOrUndefined(data["input"]),
453
+ cacheRead: numberOrUndefined(data["cacheRead"]),
454
+ cacheWrite: numberOrUndefined(data["cacheWrite"]),
455
+ prefillSeconds: numberOrUndefined(data["prefillSeconds"]),
456
+ elapsedSeconds: numberOrUndefined(data["elapsedSeconds"]),
457
+ contextTokens: numberOrUndefined(data["contextTokens"]),
458
+ contextWindow: numberOrUndefined(data["contextWindow"]),
459
+ contextPercent: numberOrUndefined(data["contextPercent"])
460
+ });
461
+ }
462
+
463
+ function usageOf(message: unknown): Usage | undefined {
464
+ if (!isRecord(message) || !isRecord(message["usage"])) {
465
+ return undefined;
466
+ }
467
+ const usage = message["usage"] as Record<string, unknown>;
468
+ return {
469
+ input: numberOrUndefined(usage["input"]),
470
+ output: numberOrUndefined(usage["output"]),
471
+ cacheRead: numberOrUndefined(usage["cacheRead"]),
472
+ cacheWrite: numberOrUndefined(usage["cacheWrite"])
473
+ };
474
+ }
475
+
476
+ function textDelta(value: unknown): string | undefined {
477
+ if (!isRecord(value)) {
478
+ return undefined;
479
+ }
480
+ const type = value["type"];
481
+ if (type !== "text_delta" && type !== "thinking_delta" && type !== "toolcall_delta") {
482
+ return undefined;
483
+ }
484
+ return typeof value["delta"] === "string" ? value["delta"] : undefined;
485
+ }
158
486
 
159
- function textUpdateFromUnknown(value: unknown): TextUpdate {
160
- if (typeof value === "string") {
161
- return { kind: "snapshot", text: value };
487
+ function messageText(message: unknown): string {
488
+ if (!isRecord(message)) {
489
+ return "";
162
490
  }
163
- if (value && typeof value === "object") {
164
- const object = value as Record<string, unknown>;
165
- const delta = object["delta"];
166
- const text = object["text"] ?? object["content"];
167
- if (typeof delta === "string") {
168
- return { kind: "delta", text: delta };
491
+ const content = message["content"];
492
+ if (typeof content === "string") {
493
+ return content;
494
+ }
495
+ if (!Array.isArray(content)) {
496
+ return "";
497
+ }
498
+ return content
499
+ .map((part) => (isRecord(part) && typeof part["text"] === "string" ? part["text"] : ""))
500
+ .join("");
501
+ }
502
+
503
+ function prefillProgress(slots: unknown): PrefillProgress | undefined {
504
+ if (!Array.isArray(slots)) {
505
+ return undefined;
506
+ }
507
+ let best: PrefillProgress | undefined;
508
+ for (const slot of slots) {
509
+ if (!isRecord(slot) || slot["is_processing"] !== true) {
510
+ continue;
511
+ }
512
+ const total = numberOrUndefined(slot["n_prompt_tokens"]);
513
+ if (total === undefined || total <= 0) {
514
+ continue;
169
515
  }
170
- if (typeof text === "string") {
171
- return { kind: "snapshot", text };
516
+ const candidate = { total, processed: numberOrUndefined(slot["n_prompt_tokens_processed"]) ?? 0 };
517
+ if (best === undefined || candidate.total > best.total) {
518
+ best = candidate;
172
519
  }
173
520
  }
174
- return { kind: "snapshot", text: "" };
521
+ return best;
522
+ }
523
+
524
+ function parseMode(value: string): StatsMode | undefined {
525
+ const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
526
+ return modes.find((mode) => mode === normalized);
527
+ }
528
+
529
+ async function promptMode(current: StatsMode, ctx: StatsContext): Promise<StatsMode | undefined> {
530
+ const selectable = ctx as StatsContext & {
531
+ ui: { select(title: string, options: string[]): Promise<string | undefined> };
532
+ };
533
+ const selected = await selectable.ui.select(
534
+ "Stats mode",
535
+ modes.map((mode) => (mode === current ? mode + " (current)" : mode))
536
+ );
537
+ return selected === undefined ? undefined : parseMode(selected);
538
+ }
539
+
540
+ async function persistMode(mode: StatsMode): Promise<void> {
541
+ const settings = await readSettings();
542
+ settings["stats"] = mode;
543
+ await writeSettings(settings);
544
+ }
545
+
546
+ function formatElapsed(seconds: number): string {
547
+ const value = Math.max(0, seconds);
548
+ if (value < 10) {
549
+ return value.toFixed(1) + "s";
550
+ }
551
+ const total = Math.floor(value);
552
+ const minutes = Math.floor(total / 60);
553
+ const rest = total % 60;
554
+ return minutes > 0 ? minutes + "m" + String(rest).padStart(2, "0") + "s" : total + "s";
555
+ }
556
+
557
+ function formatRate(rate: number | undefined): string {
558
+ if (rate === undefined || !Number.isFinite(rate)) {
559
+ return "—";
560
+ }
561
+ const oneDecimal = Number(rate.toFixed(1));
562
+ return oneDecimal < 100 ? oneDecimal.toFixed(1) : String(Math.round(oneDecimal));
563
+ }
564
+
565
+ function formatTokenCount(tokens: number): string {
566
+ const value = Math.max(0, tokens);
567
+ if (value < 1000) {
568
+ return String(Math.round(value));
569
+ }
570
+ if (value < 1000000) {
571
+ return compact(value / 1000, "k");
572
+ }
573
+ return compact(value / 1000000, "M");
574
+ }
575
+
576
+ function compact(value: number, suffix: string): string {
577
+ const decimals = value < 10 ? 1 : 0;
578
+ return value.toFixed(decimals).replace(/\\.0$/u, "") + suffix;
579
+ }
580
+
581
+ function secondsBetween(start: number, end: number): number {
582
+ return Math.max((end - start) / 1000, 0.001);
583
+ }
584
+
585
+ function numberOrUndefined(value: unknown): number | undefined {
586
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
587
+ }
588
+
589
+ function isRecord(value: unknown): value is Record<string, unknown> {
590
+ return value !== null && typeof value === "object" && !Array.isArray(value);
591
+ }
592
+
593
+ function withoutUndefined<T extends Record<string, unknown>>(value: T): Partial<T> {
594
+ const entries = Object.entries(value).filter(([, entry]) => entry !== undefined);
595
+ return Object.fromEntries(entries) as Partial<T>;
175
596
  }
176
597
  `;
177
598
  }
599
+ function slotsUrl(config) {
600
+ if (config.engine !== "llama-cpp" || config.baseUrl === undefined) {
601
+ return undefined;
602
+ }
603
+ const root = config.baseUrl.replace(/\/+$/u, "").replace(/\/v1$/u, "");
604
+ const model = config.modelId === undefined ? "" : `?model=${encodeURIComponent(config.modelId)}`;
605
+ return `${root}/slots${model}`;
606
+ }