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
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi renders its own footer in two lines and never shows which engine serves the
|
|
3
|
+
* model. Localpi replaces the footer with one line that carries the same facts
|
|
4
|
+
* plus the engine label next to the model. The live turn numbers stay in the
|
|
5
|
+
* working line, so no number appears on screen twice at the same time.
|
|
6
|
+
*/
|
|
7
|
+
export function statusLineExtensionSource(config) {
|
|
8
|
+
const enginesSource = JSON.stringify(config.engines ?? []);
|
|
9
|
+
return `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
type EngineEntry = {
|
|
12
|
+
provider: string;
|
|
13
|
+
engine: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Segment = {
|
|
17
|
+
text: string;
|
|
18
|
+
color: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The stats extension publishes the last completed turn to this typed global, so the status line
|
|
22
|
+
// can keep showing the rate after the working line disappears. A missing bridge hides the rate only.
|
|
23
|
+
type TurnSummary = { readonly rate?: number | undefined };
|
|
24
|
+
type StatsBridge = { lastTurn?: TurnSummary | undefined };
|
|
25
|
+
|
|
26
|
+
type UsageLike = {
|
|
27
|
+
input?: number;
|
|
28
|
+
output?: number;
|
|
29
|
+
cacheRead?: number;
|
|
30
|
+
cacheWrite?: number;
|
|
31
|
+
cost?: { total?: number };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type EntryLike = {
|
|
35
|
+
type?: string;
|
|
36
|
+
usage?: UsageLike;
|
|
37
|
+
message?: { role?: string; usage?: UsageLike };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ContextUsage = {
|
|
41
|
+
tokens: number | null;
|
|
42
|
+
contextWindow: number;
|
|
43
|
+
percent: number | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type StatusModel = {
|
|
47
|
+
readonly id?: string;
|
|
48
|
+
readonly provider?: string;
|
|
49
|
+
readonly reasoning?: boolean;
|
|
50
|
+
readonly contextWindow?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type StatusContext = {
|
|
54
|
+
readonly mode: string;
|
|
55
|
+
readonly cwd: string;
|
|
56
|
+
readonly model?: StatusModel | undefined;
|
|
57
|
+
readonly thinkingLevel?: string | undefined;
|
|
58
|
+
readonly sessionManager: {
|
|
59
|
+
getEntries(): readonly EntryLike[];
|
|
60
|
+
getCwd(): string;
|
|
61
|
+
getSessionName(): string | undefined;
|
|
62
|
+
};
|
|
63
|
+
getContextUsage(): ContextUsage | undefined;
|
|
64
|
+
isIdle?(): boolean;
|
|
65
|
+
readonly ui: { setFooter(factory: unknown): void };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type FooterData = {
|
|
69
|
+
// Pi types this as string | null, so accept every absent-branch shape.
|
|
70
|
+
getGitBranch(): string | null | undefined;
|
|
71
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
72
|
+
onBranchChange(callback: () => void): () => void;
|
|
73
|
+
};
|
|
74
|
+
type TuiLike = { requestRender(): void };
|
|
75
|
+
type ThemeLike = { fg(color: string, text: string): string };
|
|
76
|
+
|
|
77
|
+
type GroupKey = "location" | "totals" | "rate" | "context" | "statuses";
|
|
78
|
+
|
|
79
|
+
type Group = {
|
|
80
|
+
key: GroupKey;
|
|
81
|
+
segments: Segment[];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const engines: readonly EngineEntry[] = ${enginesSource};
|
|
85
|
+
const minPadding = 2;
|
|
86
|
+
const groupJoiner = " \\u00b7 ";
|
|
87
|
+
const segmentJoiner = " ";
|
|
88
|
+
// The rate is the least important group, so it goes first when the line is too narrow.
|
|
89
|
+
const dropOrder: readonly GroupKey[] = ["rate", "location", "statuses", "totals"];
|
|
90
|
+
|
|
91
|
+
export default function (pi: ExtensionAPI): void {
|
|
92
|
+
pi.on("session_start", (_event, ctx) => {
|
|
93
|
+
if (ctx.mode !== "tui") {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
ctx.ui.setFooter((tui: TuiLike, theme: ThemeLike, footerData: FooterData) => {
|
|
97
|
+
const unsubscribe = footerData.onBranchChange(() => {
|
|
98
|
+
tui.requestRender();
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
dispose: unsubscribe,
|
|
102
|
+
invalidate() {},
|
|
103
|
+
render(width: number): string[] {
|
|
104
|
+
return renderFooter(width, ctx, footerData, theme);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
111
|
+
if (ctx.mode !== "tui") {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
ctx.ui.setFooter(undefined);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderFooter(
|
|
119
|
+
width: number,
|
|
120
|
+
ctx: StatusContext,
|
|
121
|
+
footerData: FooterData,
|
|
122
|
+
theme: ThemeLike
|
|
123
|
+
): string[] {
|
|
124
|
+
const right = rightGroups(ctx);
|
|
125
|
+
const left = fit(width, leftGroups(ctx, footerData), right);
|
|
126
|
+
const leftBudget = width - minPadding - groupsWidth(right);
|
|
127
|
+
const fittedLeft = groupsWidth(left) > leftBudget ? truncateGroups(left, leftBudget) : left;
|
|
128
|
+
const leftWidth = groupsWidth(fittedLeft);
|
|
129
|
+
const rightBudget = width - leftWidth - minPadding;
|
|
130
|
+
const fittedRight = groupsWidth(right) > rightBudget ? truncateGroups(right, rightBudget) : right;
|
|
131
|
+
const rightWidth = groupsWidth(fittedRight);
|
|
132
|
+
const padding = " ".repeat(Math.max(minPadding, width - leftWidth - rightWidth));
|
|
133
|
+
return [paintGroups(fittedLeft, theme) + padding + paintGroups(fittedRight, theme)];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function leftGroups(ctx: StatusContext, footerData: FooterData): Group[] {
|
|
137
|
+
const groups: Group[] = [];
|
|
138
|
+
const location = locationText(ctx, footerData);
|
|
139
|
+
if (location.length > 0) {
|
|
140
|
+
groups.push({ key: "location", segments: [{ text: location, color: "dim" }] });
|
|
141
|
+
}
|
|
142
|
+
const totals = totalsGroup(ctx);
|
|
143
|
+
if (totals.length > 0) {
|
|
144
|
+
groups.push({ key: "totals", segments: totals });
|
|
145
|
+
}
|
|
146
|
+
const rate = rateGroup(ctx);
|
|
147
|
+
if (rate.length > 0) {
|
|
148
|
+
groups.push({ key: "rate", segments: rate });
|
|
149
|
+
}
|
|
150
|
+
const context = contextGroup(ctx);
|
|
151
|
+
if (context.length > 0) {
|
|
152
|
+
groups.push({ key: "context", segments: context });
|
|
153
|
+
}
|
|
154
|
+
const statuses = statusesGroup(footerData);
|
|
155
|
+
if (statuses.length > 0) {
|
|
156
|
+
groups.push({ key: "statuses", segments: statuses });
|
|
157
|
+
}
|
|
158
|
+
return groups;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fit(width: number, groups: Group[], right: Group[]): Group[] {
|
|
162
|
+
let current = groups;
|
|
163
|
+
for (const key of dropOrder) {
|
|
164
|
+
if (groupsWidth(current) + minPadding + groupsWidth(right) <= width) {
|
|
165
|
+
return current;
|
|
166
|
+
}
|
|
167
|
+
current = current.filter((group) => group.key !== key);
|
|
168
|
+
}
|
|
169
|
+
return current;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function locationText(ctx: StatusContext, footerData: FooterData): string {
|
|
173
|
+
const branch = footerData.getGitBranch();
|
|
174
|
+
const name = ctx.sessionManager.getSessionName();
|
|
175
|
+
let text = homeRelative(ctx.sessionManager.getCwd() || ctx.cwd);
|
|
176
|
+
// Pi types these as optional strings, but an absent branch or name can arrive as null.
|
|
177
|
+
if (typeof branch === "string" && branch.length > 0) {
|
|
178
|
+
text = text + " (" + branch + ")";
|
|
179
|
+
}
|
|
180
|
+
if (typeof name === "string" && name.length > 0) {
|
|
181
|
+
text = text + " \\u2022 " + name;
|
|
182
|
+
}
|
|
183
|
+
return text;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function homeRelative(cwd: string): string {
|
|
187
|
+
const home = process.env["HOME"];
|
|
188
|
+
if (home === undefined || home.length === 0) {
|
|
189
|
+
return cwd;
|
|
190
|
+
}
|
|
191
|
+
if (cwd === home) {
|
|
192
|
+
return "~";
|
|
193
|
+
}
|
|
194
|
+
return cwd.startsWith(home + "/") ? "~" + cwd.slice(home.length) : cwd;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function totalsGroup(ctx: StatusContext): Segment[] {
|
|
198
|
+
const totals = totalsOf(ctx.sessionManager.getEntries());
|
|
199
|
+
const segments: Segment[] = [];
|
|
200
|
+
if (totals.input > 0) {
|
|
201
|
+
segments.push({ text: "\\u2191" + formatTokens(totals.input), color: "dim" });
|
|
202
|
+
}
|
|
203
|
+
if (totals.output > 0) {
|
|
204
|
+
segments.push({ text: "\\u2193" + formatTokens(totals.output), color: "dim" });
|
|
205
|
+
}
|
|
206
|
+
if (totals.cacheRead > 0) {
|
|
207
|
+
segments.push({ text: "R" + formatTokens(totals.cacheRead), color: "dim" });
|
|
208
|
+
}
|
|
209
|
+
if (totals.cacheWrite > 0) {
|
|
210
|
+
segments.push({ text: "W" + formatTokens(totals.cacheWrite), color: "dim" });
|
|
211
|
+
}
|
|
212
|
+
if ((totals.cacheRead > 0 || totals.cacheWrite > 0) && totals.cacheHitRate !== undefined) {
|
|
213
|
+
segments.push({ text: "CH" + totals.cacheHitRate.toFixed(1) + "%", color: "dim" });
|
|
214
|
+
}
|
|
215
|
+
if (totals.cost > 0) {
|
|
216
|
+
segments.push({ text: "$" + totals.cost.toFixed(3), color: "dim" });
|
|
217
|
+
}
|
|
218
|
+
return segments;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function rateGroup(ctx: StatusContext): Segment[] {
|
|
222
|
+
// The working line shows the live rate while the model runs, so the footer keeps the rate of the
|
|
223
|
+
// last completed turn, and only while the model is idle.
|
|
224
|
+
if (ctx.isIdle?.() !== true) {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
const holder = globalThis as unknown as { localpiStats?: StatsBridge };
|
|
228
|
+
const rate = holder.localpiStats?.lastTurn?.rate;
|
|
229
|
+
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return [{ text: formatRate(rate), color: "muted" }];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function formatRate(rate: number): string {
|
|
236
|
+
const fixed = Number(rate.toFixed(1));
|
|
237
|
+
return (fixed < 100 ? fixed.toFixed(1) : String(Math.round(fixed))) + " tok/s";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function contextGroup(ctx: StatusContext): Segment[] {
|
|
241
|
+
// The working line shows the context while the model runs, so the footer shows it only when idle.
|
|
242
|
+
if (ctx.isIdle?.() === false) {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
const usage = ctx.getContextUsage();
|
|
246
|
+
const window = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
247
|
+
if (window <= 0) {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
const percent = usage?.percent;
|
|
251
|
+
const text =
|
|
252
|
+
percent === undefined || percent === null
|
|
253
|
+
? "?/" + formatTokens(window)
|
|
254
|
+
: percent.toFixed(1) + "%/" + formatTokens(window);
|
|
255
|
+
return [{ text, color: contextColor(percent) }];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function statusesGroup(footerData: FooterData): Segment[] {
|
|
259
|
+
const entries = Array.from(footerData.getExtensionStatuses().entries()).sort(([left], [right]) =>
|
|
260
|
+
left.localeCompare(right)
|
|
261
|
+
);
|
|
262
|
+
const text = entries
|
|
263
|
+
.map(([, value]) => (typeof value === "string" ? sanitize(value) : ""))
|
|
264
|
+
.filter((value) => value.length > 0)
|
|
265
|
+
.join(" \\u00b7 ");
|
|
266
|
+
return text.length === 0 ? [] : [{ text, color: "muted" }];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function rightGroups(ctx: StatusContext): Group[] {
|
|
270
|
+
const model = ctx.model;
|
|
271
|
+
const id = model?.id ?? "no-model";
|
|
272
|
+
let text = id;
|
|
273
|
+
if (model?.reasoning === true) {
|
|
274
|
+
const level = ctx.thinkingLevel ?? "off";
|
|
275
|
+
text = level === "off" ? text + " \\u2022 thinking off" : text + " \\u2022 " + level;
|
|
276
|
+
}
|
|
277
|
+
const segments: Segment[] = [{ text, color: "dim" }];
|
|
278
|
+
const engine = engineLabel(model?.provider);
|
|
279
|
+
if (engine !== undefined) {
|
|
280
|
+
segments.unshift({ text: "(" + engine + ")", color: "muted" });
|
|
281
|
+
}
|
|
282
|
+
return [{ key: "context", segments }];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function engineLabel(provider: string | undefined): string | undefined {
|
|
286
|
+
return engines.find((entry) => entry.provider === provider)?.engine;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function totalsOf(entries: readonly EntryLike[]): {
|
|
290
|
+
input: number;
|
|
291
|
+
output: number;
|
|
292
|
+
cacheRead: number;
|
|
293
|
+
cacheWrite: number;
|
|
294
|
+
cost: number;
|
|
295
|
+
cacheHitRate?: number;
|
|
296
|
+
} {
|
|
297
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
298
|
+
let cacheHitRate: number | undefined;
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
const usage = usageOf(entry);
|
|
301
|
+
if (usage === undefined) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
totals.input += numberOrZero(usage.input);
|
|
305
|
+
totals.output += numberOrZero(usage.output);
|
|
306
|
+
totals.cacheRead += numberOrZero(usage.cacheRead);
|
|
307
|
+
totals.cacheWrite += numberOrZero(usage.cacheWrite);
|
|
308
|
+
totals.cost += numberOrZero(usage.cost?.total);
|
|
309
|
+
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
310
|
+
const prompt =
|
|
311
|
+
numberOrZero(usage.input) + numberOrZero(usage.cacheRead) + numberOrZero(usage.cacheWrite);
|
|
312
|
+
cacheHitRate = prompt > 0 ? (numberOrZero(usage.cacheRead) / prompt) * 100 : undefined;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return cacheHitRate === undefined ? totals : { ...totals, cacheHitRate };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function usageOf(entry: EntryLike): UsageLike | undefined {
|
|
319
|
+
if (entry.type === "usage" || entry.type === "branch_summary" || entry.type === "compaction") {
|
|
320
|
+
return entry.usage;
|
|
321
|
+
}
|
|
322
|
+
return entry.message?.usage;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function numberOrZero(value: number | undefined): number {
|
|
326
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function contextColor(percent: number | null | undefined): string {
|
|
330
|
+
if (percent === undefined || percent === null) {
|
|
331
|
+
return "dim";
|
|
332
|
+
}
|
|
333
|
+
if (percent >= 95) {
|
|
334
|
+
return "error";
|
|
335
|
+
}
|
|
336
|
+
return percent >= 80 ? "warning" : "dim";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sanitize(text: string): string {
|
|
340
|
+
return text.replace(/[\\u0000-\\u001f\\u007f]/gu, " ").trim();
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function formatTokens(count: number): string {
|
|
344
|
+
if (count < 1000) {
|
|
345
|
+
return String(count);
|
|
346
|
+
}
|
|
347
|
+
if (count < 10000) {
|
|
348
|
+
return (count / 1000).toFixed(1) + "k";
|
|
349
|
+
}
|
|
350
|
+
if (count < 1000000) {
|
|
351
|
+
return Math.round(count / 1000) + "k";
|
|
352
|
+
}
|
|
353
|
+
return count < 10000000 ? (count / 1000000).toFixed(1) + "M" : Math.round(count / 1000000) + "M";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function groupsWidth(groups: readonly Group[]): number {
|
|
357
|
+
return plainText(groups).length;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function groupText(group: Group): string {
|
|
361
|
+
return group.segments.map((segment) => segment.text).join(segmentJoiner);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function plainText(groups: readonly Group[]): string {
|
|
365
|
+
return groups.map(groupText).join(groupJoiner);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function truncateGroups(groups: readonly Group[], width: number): Group[] {
|
|
369
|
+
const result: Group[] = [];
|
|
370
|
+
let used = 0;
|
|
371
|
+
for (const group of groups) {
|
|
372
|
+
const separator = result.length === 0 ? 0 : groupJoiner.length;
|
|
373
|
+
const available = width - used - separator;
|
|
374
|
+
if (available <= 0) {
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
const segments = truncateSegments(group.segments, available);
|
|
378
|
+
if (segments.length === 0) {
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
result.push({ key: group.key, segments });
|
|
382
|
+
used += separator + groupText({ key: group.key, segments }).length;
|
|
383
|
+
}
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function truncateSegments(segments: readonly Segment[], width: number): Segment[] {
|
|
388
|
+
const result: Segment[] = [];
|
|
389
|
+
let used = 0;
|
|
390
|
+
for (const segment of segments) {
|
|
391
|
+
const separator = result.length === 0 ? 0 : segmentJoiner.length;
|
|
392
|
+
const available = width - used - separator;
|
|
393
|
+
if (available <= 0) {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
const text = truncate(segment.text, available);
|
|
397
|
+
if (text.length === 0) {
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
result.push({ text, color: segment.color });
|
|
401
|
+
used += separator + text.length;
|
|
402
|
+
if (text.length < segment.text.length) {
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function paintGroups(groups: readonly Group[], theme: ThemeLike): string {
|
|
410
|
+
return groups
|
|
411
|
+
.map((group) =>
|
|
412
|
+
group.segments
|
|
413
|
+
.map((segment) => theme.fg(segment.color, segment.text))
|
|
414
|
+
.join(segmentJoiner)
|
|
415
|
+
)
|
|
416
|
+
.join(groupJoiner);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function truncate(text: string, width: number): string {
|
|
420
|
+
const characters = Array.from(text);
|
|
421
|
+
return characters.length <= width ? text : characters.slice(0, Math.max(0, width)).join("");
|
|
422
|
+
}
|
|
423
|
+
`;
|
|
424
|
+
}
|
|
@@ -4,47 +4,20 @@ export function thinkingControlExtensionSource(settingsPath) {
|
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
8
|
-
|
|
9
|
-
const levels: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
10
7
|
const settingsPath = ${settingsPathSource};
|
|
11
8
|
|
|
9
|
+
// Pi owns the /thinking command. localpi only remembers the level Pi selected.
|
|
12
10
|
export default function localpiThinkingControl(pi: ExtensionAPI): void {
|
|
13
|
-
pi.
|
|
14
|
-
description: "Set localpi thinking level",
|
|
15
|
-
getArgumentCompletions: (prefix) => {
|
|
16
|
-
const trimmed = prefix.trim().toLowerCase();
|
|
17
|
-
const matches = levels.filter((level) => level.startsWith(trimmed));
|
|
18
|
-
return matches.length === 0 ? null : matches.map((level) => ({ value: level, label: level }));
|
|
19
|
-
},
|
|
20
|
-
handler: async (args, ctx) => {
|
|
21
|
-
const requested = parseThinkingLevel(args);
|
|
22
|
-
const level = requested ?? (await promptThinkingLevel(pi.getThinkingLevel(), ctx));
|
|
23
|
-
if (level === undefined) {
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
pi.setThinkingLevel(level);
|
|
27
|
-
const actual = pi.getThinkingLevel();
|
|
28
|
-
await persistThinking(actual);
|
|
29
|
-
ctx.ui.notify(
|
|
30
|
-
actual === level ? \`thinking: \${actual}\` : \`thinking: \${actual} (clamped from \${level})\`,
|
|
31
|
-
actual === level ? "info" : "warning"
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
pi.on("thinking_level_select", async (event, ctx) => {
|
|
11
|
+
pi.on("thinking_level_select", async (event) => {
|
|
37
12
|
await persistThinking(event.level);
|
|
38
|
-
ctx.ui.setStatus("localpi-thinking", \`thinking: \${event.level}\`);
|
|
39
13
|
});
|
|
40
14
|
|
|
41
|
-
pi.on("session_shutdown", async (
|
|
15
|
+
pi.on("session_shutdown", async () => {
|
|
42
16
|
await persistThinking(pi.getThinkingLevel());
|
|
43
|
-
ctx.ui.setStatus("localpi-thinking", undefined);
|
|
44
17
|
});
|
|
45
18
|
}
|
|
46
19
|
|
|
47
|
-
async function persistThinking(level:
|
|
20
|
+
async function persistThinking(level: string): Promise<void> {
|
|
48
21
|
const settings = await readSettings();
|
|
49
22
|
settings.thinking = level;
|
|
50
23
|
await mkdir(dirname(settingsPath), { recursive: true });
|
|
@@ -59,21 +32,5 @@ async function readSettings(): Promise<Record<string, unknown>> {
|
|
|
59
32
|
return {};
|
|
60
33
|
}
|
|
61
34
|
}
|
|
62
|
-
|
|
63
|
-
async function promptThinkingLevel(
|
|
64
|
-
current: ThinkingLevel,
|
|
65
|
-
ctx: { readonly ui: { select(title: string, options: string[]): Promise<string | undefined> } }
|
|
66
|
-
): Promise<ThinkingLevel | undefined> {
|
|
67
|
-
const selected = await ctx.ui.select(
|
|
68
|
-
"Thinking level",
|
|
69
|
-
levels.map((level) => (level === current ? \`\${level} (current)\` : level))
|
|
70
|
-
);
|
|
71
|
-
return selected === undefined ? undefined : parseThinkingLevel(selected);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function parseThinkingLevel(value: string): ThinkingLevel | undefined {
|
|
75
|
-
const normalized = value.trim().split(/\\s+/u)[0]?.toLowerCase();
|
|
76
|
-
return levels.find((level) => level === normalized);
|
|
77
|
-
}
|
|
78
35
|
`;
|
|
79
36
|
}
|