localpi 0.5.2 → 0.6.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/README.md +377 -10
- package/dist/src/cli/cli.js +112 -5
- package/dist/src/llm/openai.js +65 -4
- package/dist/src/localpi/acp.js +118 -0
- package/dist/src/localpi/catalog.js +79 -7
- package/dist/src/localpi/catppuccin.js +64 -0
- package/dist/src/localpi/llama-server.js +72 -39
- package/dist/src/localpi/model-profile.js +4 -0
- package/dist/src/localpi/options.js +129 -9
- package/dist/src/localpi/provider-registry.js +51 -3
- package/dist/src/localpi/runtime-connection.js +11 -8
- package/dist/src/localpi/runtime.js +12 -7
- package/dist/src/localpi/settings-state.js +13 -3
- package/dist/src/pi/app.js +11 -5
- package/dist/src/pi/extension-sources/continue-on-truncation.js +55 -0
- package/dist/src/pi/extension-sources/settings-file.js +31 -0
- package/dist/src/pi/extension-sources/startup-model-selector.js +18 -23
- package/dist/src/pi/extension-sources/status-line.js +424 -0
- package/dist/src/pi/extension-sources/thinking-control.js +4 -47
- package/dist/src/pi/extension-sources/token-status.js +544 -119
- package/dist/src/pi/extension-sources/tool-approval.js +155 -14
- package/dist/src/pi/extensions.js +37 -10
- package/dist/src/pi/skills.js +24 -0
- package/dist/src/pi/theme.js +107 -0
- package/docs/2026-06-16-startup-model-and-thinking-control-plan.md +39 -11
- package/docs/2026-09-23-acp-mode-plan.md +111 -0
- package/docs/2026-09-24-continue-on-truncation-plan.md +115 -0
- package/docs/design-principles.md +114 -0
- package/docs/implementation-plan.md +33 -0
- package/docs/runtime-specification.md +98 -4
- package/package.json +4 -2
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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";
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
type StatsMode = "off" | "line" | "full";
|
|
6
10
|
|
|
7
11
|
type Usage = {
|
|
8
|
-
input?: number;
|
|
9
|
-
output?: number;
|
|
10
|
-
cacheRead?: number;
|
|
11
|
-
cacheWrite?: number;
|
|
12
|
-
|
|
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;
|
|
13
21
|
};
|
|
14
22
|
|
|
15
23
|
type TurnState = {
|
|
@@ -17,165 +25,582 @@ type TurnState = {
|
|
|
17
25
|
firstOutputAt?: number;
|
|
18
26
|
outputText: string;
|
|
19
27
|
estimatedOutputTokens: number;
|
|
20
|
-
|
|
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;
|
|
21
51
|
};
|
|
22
52
|
|
|
53
|
+
type StatsBridge = { lastTurn?: TurnSummary | undefined };
|
|
54
|
+
|
|
55
|
+
type ContextUsage = {
|
|
56
|
+
tokens: number | null;
|
|
57
|
+
contextWindow: number;
|
|
58
|
+
percent: number | null;
|
|
59
|
+
};
|
|
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
|
+
|
|
23
96
|
export default function localpiTokenStatus(pi: ExtensionAPI): void {
|
|
24
|
-
let
|
|
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;
|
|
102
|
+
|
|
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
|
+
});
|
|
25
132
|
|
|
26
|
-
pi.on("turn_start", () => {
|
|
27
|
-
|
|
133
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
134
|
+
stopTimers();
|
|
135
|
+
state = {
|
|
28
136
|
startedAt: Date.now(),
|
|
29
137
|
outputText: "",
|
|
30
138
|
estimatedOutputTokens: 0,
|
|
31
|
-
|
|
139
|
+
lastRenderAt: 0
|
|
32
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
|
+
}
|
|
33
153
|
});
|
|
34
154
|
|
|
35
155
|
pi.on("message_update", (event, ctx) => {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
156
|
+
const current = state;
|
|
157
|
+
if (current === undefined) {
|
|
38
158
|
return;
|
|
39
159
|
}
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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;
|
|
46
168
|
}
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (now - state.lastStatusAt < 250) {
|
|
52
|
-
return;
|
|
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();
|
|
53
173
|
}
|
|
54
|
-
|
|
55
|
-
ctx.ui.setStatus("localpi-perf", ctx.ui.theme.fg("dim", statusText(state, now)));
|
|
174
|
+
render(ctx, Date.now());
|
|
56
175
|
});
|
|
57
176
|
|
|
58
177
|
pi.on("turn_end", (event, ctx) => {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
lastStatusAt: 0
|
|
64
|
-
};
|
|
65
|
-
currentTurn = undefined;
|
|
66
|
-
|
|
67
|
-
if (!ctx.hasUI || event.message.role !== "assistant") {
|
|
178
|
+
const current = state ?? emptyState();
|
|
179
|
+
state = undefined;
|
|
180
|
+
stopTimers();
|
|
181
|
+
if (!ctx.hasUI) {
|
|
68
182
|
return;
|
|
69
183
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const context = includeContext ? ctx.getContextUsage() : undefined;
|
|
81
|
-
const contextText = !includeContext
|
|
82
|
-
? undefined
|
|
83
|
-
: context && context.percent !== null
|
|
84
|
-
? \`ctx \${Math.round(context.percent)}%/\${Math.round(context.contextWindow / 1000)}k\`
|
|
85
|
-
: "ctx ?";
|
|
86
|
-
|
|
87
|
-
ctx.ui.setStatus(
|
|
88
|
-
"localpi-perf",
|
|
89
|
-
ctx.ui.theme.fg(
|
|
90
|
-
"dim",
|
|
91
|
-
[
|
|
92
|
-
\`gen \${(output / decodeSeconds).toFixed(1)} tok/s\`,
|
|
93
|
-
prefillText,
|
|
94
|
-
\`out \${output}\`,
|
|
95
|
-
\`in \${input}\`,
|
|
96
|
-
cacheRead > 0 ? \`cache \${cacheRead}\` : undefined,
|
|
97
|
-
cacheWrite > 0 ? \`cw \${cacheWrite}\` : undefined,
|
|
98
|
-
\`\${elapsedSeconds.toFixed(1)}s\`,
|
|
99
|
-
contextText
|
|
100
|
-
]
|
|
101
|
-
.filter(Boolean)
|
|
102
|
-
.join(" | ")
|
|
103
|
-
)
|
|
104
|
-
);
|
|
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);
|
|
105
194
|
});
|
|
106
195
|
|
|
107
196
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
108
|
-
|
|
109
|
-
|
|
197
|
+
stopTimers();
|
|
198
|
+
state = undefined;
|
|
199
|
+
if (!ctx.hasUI) {
|
|
200
|
+
return;
|
|
110
201
|
}
|
|
202
|
+
ctx.ui.setWorkingMessage();
|
|
111
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 };
|
|
266
|
+
}
|
|
267
|
+
|
|
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
|
+
);
|
|
112
280
|
}
|
|
113
281
|
|
|
114
|
-
function
|
|
115
|
-
const
|
|
282
|
+
function workingParts(state: TurnState, now: number, usage: ContextUsage | undefined): Segment[][] {
|
|
283
|
+
const parts: Segment[][] = [];
|
|
116
284
|
if (state.firstOutputAt === undefined) {
|
|
117
|
-
|
|
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
|
+
]);
|
|
295
|
+
}
|
|
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" }]];
|
|
118
305
|
}
|
|
119
|
-
const
|
|
120
|
-
const prefillSeconds = secondsBetween(state.startedAt, state.firstOutputAt);
|
|
306
|
+
const percent = Math.min(100, Math.max(0, Math.round((progress.processed / progress.total) * 100)));
|
|
121
307
|
return [
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
];
|
|
127
320
|
}
|
|
128
321
|
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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) {
|
|
136
360
|
return undefined;
|
|
137
361
|
}
|
|
138
|
-
|
|
139
|
-
|
|
362
|
+
return {
|
|
363
|
+
tokens: data.contextTokens ?? null,
|
|
364
|
+
contextWindow: data.contextWindow ?? 0,
|
|
365
|
+
percent: data.contextPercent
|
|
366
|
+
};
|
|
140
367
|
}
|
|
141
368
|
|
|
142
|
-
function
|
|
143
|
-
|
|
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
|
+
];
|
|
144
389
|
}
|
|
145
390
|
|
|
146
|
-
function
|
|
147
|
-
|
|
391
|
+
function contextColor(percent: number): string {
|
|
392
|
+
if (percent >= 95) {
|
|
393
|
+
return "error";
|
|
394
|
+
}
|
|
395
|
+
return percent >= 80 ? "warning" : "accent";
|
|
148
396
|
}
|
|
149
397
|
|
|
150
|
-
function
|
|
151
|
-
return
|
|
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", " · "));
|
|
152
402
|
}
|
|
153
403
|
|
|
154
|
-
function
|
|
155
|
-
|
|
404
|
+
function statsBridge(): StatsBridge {
|
|
405
|
+
const holder = globalThis as unknown as { localpiStats?: StatsBridge };
|
|
406
|
+
holder.localpiStats ??= {};
|
|
407
|
+
return holder.localpiStats;
|
|
156
408
|
}
|
|
157
409
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
|
162
486
|
|
|
163
|
-
function
|
|
164
|
-
if (
|
|
165
|
-
return
|
|
487
|
+
function messageText(message: unknown): string {
|
|
488
|
+
if (!isRecord(message)) {
|
|
489
|
+
return "";
|
|
166
490
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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;
|
|
173
515
|
}
|
|
174
|
-
|
|
175
|
-
|
|
516
|
+
const candidate = { total, processed: numberOrUndefined(slot["n_prompt_tokens_processed"]) ?? 0 };
|
|
517
|
+
if (best === undefined || candidate.total > best.total) {
|
|
518
|
+
best = candidate;
|
|
176
519
|
}
|
|
177
520
|
}
|
|
178
|
-
return
|
|
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>;
|
|
179
596
|
}
|
|
180
597
|
`;
|
|
181
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
|
+
}
|