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.
- package/README.md +389 -11
- package/dist/src/cli/cli.js +115 -6
- package/dist/src/cli/main.js +0 -0
- 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 +13 -6
- 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/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 +545 -116
- package/dist/src/pi/extension-sources/tool-approval.js +155 -14
- package/dist/src/pi/extensions.js +55 -12
- 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 +11 -7
- package/dist/src/pi/extension-sources/demo-mode.js +0 -110
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
if (
|
|
156
|
+
const current = state;
|
|
157
|
+
if (current === undefined) {
|
|
35
158
|
return;
|
|
36
159
|
}
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
362
|
+
return {
|
|
363
|
+
tokens: data.contextTokens ?? null,
|
|
364
|
+
contextWindow: data.contextWindow ?? 0,
|
|
365
|
+
percent: data.contextPercent
|
|
366
|
+
};
|
|
136
367
|
}
|
|
137
368
|
|
|
138
|
-
function
|
|
139
|
-
|
|
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
|
|
143
|
-
|
|
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
|
|
147
|
-
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", " · "));
|
|
148
402
|
}
|
|
149
403
|
|
|
150
|
-
function
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
160
|
-
if (
|
|
161
|
-
return
|
|
487
|
+
function messageText(message: unknown): string {
|
|
488
|
+
if (!isRecord(message)) {
|
|
489
|
+
return "";
|
|
162
490
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
|
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
|
+
}
|