@bismawy/pi-arnative 0.3.4 → 0.3.6
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/CHANGELOG.md +32 -0
- package/README.md +5 -3
- package/extensions/footer.ts +116 -20
- package/extensions/section-headers.ts +92 -12
- package/extensions/tools.ts +8 -1
- package/extensions/ui-render-tweaks.ts +28 -17
- package/extensions/usage.ts +55 -13
- package/lib/ansi.ts +18 -0
- package/lib/box.ts +12 -6
- package/lib/check.ts +10 -1
- package/lib/preset-store.ts +92 -0
- package/lib/usage-store.ts +190 -80
- package/package.json +3 -3
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
getMarkdownTheme,
|
|
22
22
|
initTheme,
|
|
23
23
|
InteractiveMode,
|
|
24
|
-
keyText,
|
|
25
24
|
Theme,
|
|
26
25
|
UserMessageComponent,
|
|
27
26
|
type ExtensionAPI,
|
|
@@ -40,18 +39,26 @@ import {
|
|
|
40
39
|
} from "@earendil-works/pi-tui";
|
|
41
40
|
import { readdirSync, readFileSync } from "node:fs";
|
|
42
41
|
import { renderBoxLines } from "../lib/box.ts";
|
|
43
|
-
import { ansiBgOpen, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
|
|
42
|
+
import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
|
|
44
43
|
import { assert, isMain } from "../lib/check.ts";
|
|
45
44
|
import { contrast, parseOklch, toHex } from "../lib/color.ts";
|
|
46
45
|
import { expandKeyName } from "../lib/format.ts";
|
|
47
46
|
|
|
48
|
-
|
|
47
|
+
type ActiveTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null;
|
|
48
|
+
const activeTheme = () => getActiveTheme<Exclude<ActiveTheme, null>>();
|
|
49
49
|
|
|
50
50
|
// Hide pi's built-in "Reloading keybindings, extensions, skills..." box
|
|
51
51
|
// (hardcoded in handleReloadCommand, no auto-hide). Patches Container.render
|
|
52
52
|
// through the prototype chain (Container is not exported) and drops the whole box on match.
|
|
53
|
+
const RELOAD_TEXT = "Reloading keybindings, extensions, skills, prompts, themes, and context files...";
|
|
54
|
+
/**
|
|
55
|
+
* True only for the box itself: border rows, blank spacers and (possibly wrapped) copy of the
|
|
56
|
+
* message. The patch sits on every plain Container, so a bare substring test would blank a
|
|
57
|
+
* whole chat the moment a transcript line mentioned the phrase.
|
|
58
|
+
*/
|
|
53
59
|
export function shouldHideReloadBox(lines: readonly string[]): boolean {
|
|
54
|
-
|
|
60
|
+
const rows = lines.map((l) => stripAnsi(l).trim());
|
|
61
|
+
return rows.some((r) => r.includes("Reloading keybindings")) && rows.every((r) => r === "" || /^[─-]+$/.test(r) || RELOAD_TEXT.includes(r));
|
|
55
62
|
}
|
|
56
63
|
const RELOAD_BOX_KEY = Symbol.for("pi-arnative.reloadBoxHidden");
|
|
57
64
|
if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[RELOAD_BOX_KEY]) {
|
|
@@ -134,8 +141,8 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[SELECTIO
|
|
|
134
141
|
if (typeof origHighlight === "function") {
|
|
135
142
|
TuiAltScreen.prototype.applySelectionHighlight = function (text: string): string {
|
|
136
143
|
const highlighted = origHighlight.call(this, text);
|
|
137
|
-
const fgOpen = ansiFgOpen(
|
|
138
|
-
const bgOpen = ansiBgOpen(
|
|
144
|
+
const fgOpen = ansiFgOpen(activeTheme());
|
|
145
|
+
const bgOpen = ansiBgOpen(activeTheme(), "selectedBg");
|
|
139
146
|
return fgOpen && bgOpen ? recolorSelection(highlighted, fgOpen, bgOpen) : highlighted;
|
|
140
147
|
};
|
|
141
148
|
}
|
|
@@ -154,7 +161,7 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[PILL_KEY
|
|
|
154
161
|
layout: unknown,
|
|
155
162
|
width: number,
|
|
156
163
|
) {
|
|
157
|
-
return withAccentPill(this, () => origComposite.call(this, screen, layout, width),
|
|
164
|
+
return withAccentPill(this, () => origComposite.call(this, screen, layout, width), activeTheme());
|
|
158
165
|
};
|
|
159
166
|
}
|
|
160
167
|
}
|
|
@@ -183,7 +190,7 @@ if (ExtensionSelectorComponent?.prototype && !(globalThis as Record<symbol, bool
|
|
|
183
190
|
};
|
|
184
191
|
const origUpdateList = proto.updateList;
|
|
185
192
|
proto.updateList = function (this: typeof proto) {
|
|
186
|
-
const th =
|
|
193
|
+
const th = activeTheme();
|
|
187
194
|
if (!th) return origUpdateList.call(this); // no theme yet -> pi's default
|
|
188
195
|
this.listContainer.clear();
|
|
189
196
|
for (let i = 0; i < this.options.length; i++) {
|
|
@@ -199,7 +206,8 @@ if (ExtensionSelectorComponent?.prototype && !(globalThis as Record<symbol, bool
|
|
|
199
206
|
export default function uiRenderTweaks(pi: ExtensionAPI) {
|
|
200
207
|
// The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
|
|
201
208
|
pi.on("session_start", async (_event, ctx) => {
|
|
202
|
-
|
|
209
|
+
const th = themeOf<Exclude<ActiveTheme, null>>(ctx);
|
|
210
|
+
if (th) setActiveTheme(th);
|
|
203
211
|
});
|
|
204
212
|
|
|
205
213
|
// 4. `pi-jev-eye-review` contract message: use our own border box.
|
|
@@ -217,9 +225,9 @@ export default function uiRenderTweaks(pi: ExtensionAPI) {
|
|
|
217
225
|
// body text is unchanged.
|
|
218
226
|
export class PackageUpdateBoxComponent implements Component {
|
|
219
227
|
private packages: string[];
|
|
220
|
-
private th: { fg(c: string, t: string): string };
|
|
228
|
+
private th: { fg(c: string, t: string): string } | null;
|
|
221
229
|
|
|
222
|
-
constructor(packages: string[], th: { fg(c: string, t: string): string }) {
|
|
230
|
+
constructor(packages: string[], th: { fg(c: string, t: string): string } | null) {
|
|
223
231
|
this.packages = packages;
|
|
224
232
|
this.th = th;
|
|
225
233
|
}
|
|
@@ -228,10 +236,11 @@ export class PackageUpdateBoxComponent implements Component {
|
|
|
228
236
|
|
|
229
237
|
render(width: number): string[] {
|
|
230
238
|
const th = this.th;
|
|
239
|
+
const fg = (color: string, text: string) => (th ? th.fg(color, text) : text);
|
|
231
240
|
const rows = [
|
|
232
|
-
|
|
233
|
-
`${
|
|
234
|
-
|
|
241
|
+
fg("warning", "\x1b[1m\uf449 Package Updates Available\x1b[22m"),
|
|
242
|
+
`${fg("muted", "Package updates are available. Run ")}${fg("accent", "pi update --extensions")}`,
|
|
243
|
+
fg("muted", "Packages:"),
|
|
235
244
|
...this.packages.map((pkg) => `- ${pkg}`),
|
|
236
245
|
];
|
|
237
246
|
return renderBoxLines(th, width, rows, undefined, "warning");
|
|
@@ -250,7 +259,7 @@ if (InteractiveMode?.prototype && !(globalThis as Record<symbol, boolean>)[PACKA
|
|
|
250
259
|
ui: { requestRender(): void };
|
|
251
260
|
};
|
|
252
261
|
self.chatContainer.addChild(new Spacer(1));
|
|
253
|
-
self.chatContainer.addChild(new PackageUpdateBoxComponent(packages,
|
|
262
|
+
self.chatContainer.addChild(new PackageUpdateBoxComponent(packages, activeTheme()));
|
|
254
263
|
self.ui.requestRender();
|
|
255
264
|
};
|
|
256
265
|
}
|
|
@@ -326,7 +335,7 @@ if (CompactionSummaryMessageComponent?.prototype && !(globalThis as Record<symbo
|
|
|
326
335
|
const proto = CompactionSummaryMessageComponent.prototype as any;
|
|
327
336
|
const origUpdateDisplay = proto.updateDisplay;
|
|
328
337
|
proto.updateDisplay = function (): void {
|
|
329
|
-
const th =
|
|
338
|
+
const th = activeTheme();
|
|
330
339
|
if (!th) return origUpdateDisplay.call(this);
|
|
331
340
|
const message = this.message as { tokensBefore?: number; summary?: string } | undefined;
|
|
332
341
|
const tokenStr = Number(message?.tokensBefore ?? 0).toLocaleString();
|
|
@@ -360,7 +369,9 @@ if (CompactionSummaryMessageComponent?.prototype && !(globalThis as Record<symbo
|
|
|
360
369
|
// Self-check: `node extensions/ui-render-tweaks.ts`
|
|
361
370
|
if (isMain(import.meta.url)) {
|
|
362
371
|
// reload box fully hidden (not line by line)
|
|
363
|
-
assert(shouldHideReloadBox(["
|
|
372
|
+
assert(shouldHideReloadBox(["────", "", " Reloading keybindings, extensions, skills, prompts, themes, and context files... ", "", "────"]) === true, "reload box detected");
|
|
373
|
+
assert(shouldHideReloadBox(["────", "", " Reloading keybindings, extensions,", " skills, prompts, themes, and context files...", "", "────"]) === true, "wrapped reload box detected");
|
|
374
|
+
assert(shouldHideReloadBox(["hello", "the log says Reloading keybindings", "world"]) === false, "chat lines that mention the phrase stay visible");
|
|
364
375
|
assert(shouldHideReloadBox(["hello", "world"]) === false, "normal lines pass through");
|
|
365
376
|
|
|
366
377
|
// selection: reverse video replaced with explicit colors (bg selectedBg + fg text)
|
package/extensions/usage.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* shared with the Model tab.
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
11
11
|
import { boxEdge, boxRow } from "../lib/box.ts";
|
|
@@ -285,25 +285,67 @@ if (isMain(import.meta.url)) {
|
|
|
285
285
|
assert(formatTokens(9_999) === "10k", "formatTokens strips a .0 k tail");
|
|
286
286
|
assert(formatCost(999.996) === "$1.0k", "formatCost rolls over at $1000");
|
|
287
287
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
288
|
+
// Fixture agent dir: the real history (and cache) must not decide pass/fail.
|
|
289
|
+
const fixture = mkdtempSync(join(tmpdir(), "arnative-usage-"));
|
|
290
|
+
process.env.PI_CODING_AGENT_DIR = fixture;
|
|
291
|
+
mkdirSync(join(fixture, "sessions", "proj"), { recursive: true });
|
|
292
|
+
const asst = (model: string, input: number) => JSON.stringify({ message: { role: "assistant", provider: "google", model, usage: { input, output: 5, cacheRead: 1, cost: { total: 0.02 } } } });
|
|
293
|
+
writeFileSync(
|
|
294
|
+
join(fixture, "sessions", "proj", "a.jsonl"),
|
|
295
|
+
[JSON.stringify({ message: { role: "user", content: "hi" } }), asst("gemini-3.8-flash", 100), asst("gemini-3.8-flash", 50), asst("other-model", 7)].join(String.fromCharCode(10)),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const summary = collectUsageSummary(true);
|
|
299
|
+
assert(summary.providers.length === 1, "collectUsageSummary detects providers");
|
|
300
|
+
assert(summary.totals.sessions === 1, "collectUsageSummary detects total sessions");
|
|
301
|
+
assert(summary.totals.msgs === 3, "collectUsageSummary counts assistant messages only");
|
|
302
|
+
assert(Math.abs(summary.totals.cost - 0.06) < 1e-9, "collectUsageSummary counts cost");
|
|
293
303
|
|
|
294
304
|
const modelUsage = getModelAllTimeUsage("gemini-3.8-flash");
|
|
295
|
-
assert(modelUsage.input
|
|
305
|
+
assert(modelUsage.input === 150 && modelUsage.output === 10 && modelUsage.cacheRead === 2, "getModelAllTimeUsage sums one model across messages");
|
|
296
306
|
assert(
|
|
297
307
|
getModelAllTimeUsage(undefined).input === 0 && getModelAllTimeUsage(undefined).output === 0,
|
|
298
308
|
"getModelAllTimeUsage without a model id matches nothing (no grand total)",
|
|
299
309
|
);
|
|
300
310
|
|
|
311
|
+
// A session file that grows is parsed from the tail only (byte offset in the cache), not re-read.
|
|
312
|
+
const file = join(fixture, "sessions", "proj", "a.jsonl");
|
|
313
|
+
const cachedBefore = JSON.parse(readFileSync(join(fixture, "arnative-usage-cache.json"), "utf8")) as Record<string, { consumed: number; size: number }>;
|
|
314
|
+
assert(cachedBefore[file]!.consumed === cachedBefore[file]!.size, "cache records the full parsed offset");
|
|
315
|
+
writeFileSync(file, asst("gemini-3.8-flash", 1000) + String.fromCharCode(10), { flag: "a" });
|
|
316
|
+
const grown = collectUsageSummary(true);
|
|
317
|
+
assert(grown.totals.msgs === 4, "an appended record is picked up from the tail");
|
|
318
|
+
assert(
|
|
319
|
+
getModelAllTimeUsage("gemini-3.8-flash").input === 1150,
|
|
320
|
+
"the appended record adds to the existing totals (no duplicate, no drop)",
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// The per-file rollup is what makes a refresh cheap: folding a tail must reproduce the same
|
|
324
|
+
// totals as parsing the whole file from scratch. Compare the incremental answer to a cold one.
|
|
325
|
+
const incremental = JSON.stringify(getModelAllTimeUsage("gemini-3.8-flash"));
|
|
326
|
+
const cacheRaw = JSON.parse(readFileSync(join(fixture, "arnative-usage-cache.json"), "utf8")) as Record<string, unknown>;
|
|
327
|
+
// Drop the cache (as a fresh machine, or a version bump, would) and rescan the whole file.
|
|
328
|
+
writeFileSync(join(fixture, "arnative-usage-cache.json"), "{}", "utf8");
|
|
329
|
+
collectUsageSummary(true);
|
|
330
|
+
assert(
|
|
331
|
+
JSON.stringify(getModelAllTimeUsage("gemini-3.8-flash")) === incremental,
|
|
332
|
+
"an incremental tail fold equals a full cold parse of the same file",
|
|
333
|
+
);
|
|
334
|
+
assert(Object.keys(cacheRaw).length === 1, "one session file produces exactly one cache entry");
|
|
335
|
+
|
|
336
|
+
// A half-written trailing record must not be counted, then must appear once completed in place.
|
|
337
|
+
const pending = asst("other-model", 5);
|
|
338
|
+
writeFileSync(file, pending.slice(0, 40), { flag: "a" });
|
|
339
|
+
assert(collectUsageSummary(true).totals.msgs === 4, "a half-written trailing record is not counted");
|
|
340
|
+
writeFileSync(file, pending.slice(40) + String.fromCharCode(10), { flag: "a" });
|
|
341
|
+
assert(collectUsageSummary(true).totals.msgs === 5, "the record is counted once the write completes");
|
|
342
|
+
|
|
301
343
|
// Disk cache pruning: no cache key may point at a session file that is gone.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
344
|
+
rmSync(join(fixture, "sessions", "proj", "a.jsonl"));
|
|
345
|
+
collectUsageSummary(true);
|
|
346
|
+
const raw = JSON.parse(readFileSync(join(fixture, "arnative-usage-cache.json"), "utf8")) as Record<string, unknown>;
|
|
347
|
+
assert(Object.keys(raw).length === 0, "usage disk cache prunes deleted session files");
|
|
348
|
+
rmSync(fixture, { recursive: true, force: true });
|
|
307
349
|
|
|
308
350
|
const fakeTheme = {
|
|
309
351
|
fg: (_c: string, t: string) => `\x1b[38;2;100;100;100m${t}\x1b[39m`,
|
package/lib/ansi.ts
CHANGED
|
@@ -25,6 +25,19 @@ export function themeOf<T>(ctx: unknown): T | null {
|
|
|
25
25
|
return (ctx as { ui?: { theme?: T } } | null | undefined)?.ui?.theme ?? null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Shared active-theme slot. `/reload` re-imports an extension's modules, so two copies
|
|
29
|
+
// can coexist: one carries the session_start handler, the other owns a stacked UI patch.
|
|
30
|
+
// globalThis gives both the same theme, so the patched copy paints instead of drawing
|
|
31
|
+
// uncolored (or, before the null-guard fix, crashing). Set from `session_start`.
|
|
32
|
+
const THEME_PROXY_KEY = Symbol.for("pi-arnative.activeThemeProxy");
|
|
33
|
+
export function getActiveTheme<T>(): T | null {
|
|
34
|
+
return (globalThis as Record<symbol, T>)[THEME_PROXY_KEY] ?? null;
|
|
35
|
+
}
|
|
36
|
+
export function setActiveTheme<T>(th: T | null): void {
|
|
37
|
+
if (th) (globalThis as Record<symbol, T>)[THEME_PROXY_KEY] = th;
|
|
38
|
+
else delete (globalThis as Record<symbol, T>)[THEME_PROXY_KEY];
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
/** Painter for the `accentSoft` token, which only arnative themes define; other themes fall back to accent (probed once). */
|
|
29
42
|
export function accentSoftOf(th: { fg?(c: string, t: string): string } | null): (s: string) => string {
|
|
30
43
|
const fg = th?.fg?.bind(th);
|
|
@@ -46,6 +59,11 @@ if (isMain(import.meta.url)) {
|
|
|
46
59
|
assert(themeOf({ ui: { theme: "T" } }) === "T", "themeOf reads ctx.ui.theme");
|
|
47
60
|
assert(themeOf(null) === null, "themeOf tolerates a missing ctx");
|
|
48
61
|
|
|
62
|
+
setActiveTheme({ fg: (c, t) => `<${c}>${t}</${c}>` });
|
|
63
|
+
assert(getActiveTheme<object>() !== null, "active theme round-trips through globalThis");
|
|
64
|
+
setActiveTheme(null);
|
|
65
|
+
assert(getActiveTheme<object>() === null, "clearing the active theme leaves no stale slot");
|
|
66
|
+
|
|
49
67
|
const tag = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
|
|
50
68
|
assert(accentSoftOf(tag)("x") === "<accentSoft>x</accentSoft>", "accentSoftOf uses accentSoft when the theme defines it");
|
|
51
69
|
const noSoft = {
|
package/lib/box.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
|
7
7
|
import { assert, isMain } from "./check.ts";
|
|
8
8
|
|
|
9
9
|
export type BoxTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string };
|
|
10
|
-
|
|
10
|
+
type Dim = (s: string) => string;
|
|
11
11
|
|
|
12
12
|
export const boxEdge = (l: string, r: string, width: number, dim: Dim): string =>
|
|
13
13
|
dim(`${l}${"─".repeat(Math.max(0, width - 2))}${r}`);
|
|
@@ -23,18 +23,21 @@ const applyBg = (line: string, width: number, bgFn: (text: string) => string): s
|
|
|
23
23
|
return bgFn(line + " ".repeat(pad));
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
/** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color.
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color.
|
|
27
|
+
* `theme` may be null: a box is patched in at module load, before `session_start`
|
|
28
|
+
* delivers the active theme. An uncolored box for that first frame beats a null
|
|
29
|
+
* deref that kills the process. */
|
|
30
|
+
export function renderBoxLines(theme: BoxTheme | null, width: number, rows: string[], bgType?: string, borderColor = "dim"): string[] {
|
|
31
|
+
const dim = (s: string) => (theme ? theme.fg(borderColor, s) : s);
|
|
29
32
|
const inner = Math.max(8, width - 4);
|
|
30
33
|
const lines = [boxEdge("╭", "╮", width, dim)];
|
|
31
34
|
for (const row of rows) {
|
|
32
35
|
for (const line of wrapTextWithAnsi(row, inner)) lines.push(boxRow(line, width, dim));
|
|
33
36
|
}
|
|
34
37
|
lines.push(boxEdge("╰", "╯", width, dim));
|
|
35
|
-
if (bgType && theme
|
|
38
|
+
if (bgType && theme?.bg) {
|
|
36
39
|
try {
|
|
37
|
-
return lines.map((l) => applyBg(l, width, (s) => theme
|
|
40
|
+
return lines.map((l) => applyBg(l, width, (s) => theme!.bg!(bgType, s)));
|
|
38
41
|
} catch {
|
|
39
42
|
return lines;
|
|
40
43
|
}
|
|
@@ -50,5 +53,8 @@ if (isMain(import.meta.url)) {
|
|
|
50
53
|
assert(plain[1] === "│ hello │", "content line padded flush");
|
|
51
54
|
assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "long line wrapped");
|
|
52
55
|
assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg ignored when the theme has no bg");
|
|
56
|
+
const nullTheme = renderBoxLines(null, 24, ["hello"], "customMessageBg");
|
|
57
|
+
assert(nullTheme.every((l) => visibleWidth(l) === 24), "null theme renders an uncolored box instead of throwing");
|
|
58
|
+
assert(nullTheme[0]!.startsWith("╭") && nullTheme[1] === "│ hello │", "null-theme frame and padding intact");
|
|
53
59
|
console.log("lib/box.ts OK");
|
|
54
60
|
}
|
package/lib/check.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Self-check helpers shared by every module's `node <file>` block.
|
|
2
2
|
// Lives outside `extensions/*.ts` so pi never loads it as an extension.
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
3
4
|
export function assert(cond: boolean, msg: string): asserts cond {
|
|
4
5
|
if (!cond) {
|
|
5
6
|
console.error(`FAIL: ${msg}`);
|
|
@@ -8,5 +9,13 @@ export function assert(cond: boolean, msg: string): asserts cond {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export function isMain(url: string): boolean {
|
|
11
|
-
|
|
12
|
+
const argv = process.argv[1];
|
|
13
|
+
if (!argv) return false;
|
|
14
|
+
// Compare canonical file URLs: string `endsWith` broke on paths with spaces or
|
|
15
|
+
// other characters that `import.meta.url` percent-encodes, silently skipping the check.
|
|
16
|
+
try {
|
|
17
|
+
return pathToFileURL(argv).href === url;
|
|
18
|
+
} catch {
|
|
19
|
+
return url.endsWith(argv.split("\\").join("/"));
|
|
20
|
+
}
|
|
12
21
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persist the small UI choices made through `/arnative` (header and footer preset).
|
|
3
|
+
*
|
|
4
|
+
* Why a sidecar and not `settings.json`: pi owns that file through its live `SettingsManager`
|
|
5
|
+
* (queued writes, atomic replace, field-level merge) and exposes no write API to extensions, so
|
|
6
|
+
* editing it directly would race the manager and could drop concurrent changes. The choices here
|
|
7
|
+
* belong to this package, not to pi.
|
|
8
|
+
*
|
|
9
|
+
* The file is read once per process and merged on write, so two extensions writing different
|
|
10
|
+
* keys cannot clobber each other.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { assert, isMain } from "./check.ts";
|
|
17
|
+
|
|
18
|
+
export const HEADER_PRESET_KEY = "headerPreset";
|
|
19
|
+
export const FOOTER_PRESET_KEY = "footerPreset";
|
|
20
|
+
|
|
21
|
+
// Resolved per call, not at import: the self-check points getAgentDir() at a fixture dir.
|
|
22
|
+
const configPath = (): string => join(getAgentDir(), "arnative-config.json");
|
|
23
|
+
|
|
24
|
+
let memo: Record<string, unknown> | null = null;
|
|
25
|
+
|
|
26
|
+
function readConfig(): Record<string, unknown> {
|
|
27
|
+
if (memo) return memo;
|
|
28
|
+
memo = {};
|
|
29
|
+
try {
|
|
30
|
+
if (existsSync(configPath())) {
|
|
31
|
+
const raw = JSON.parse(readFileSync(configPath(), "utf8")) as unknown;
|
|
32
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) memo = raw as Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// unreadable/corrupt: fall back to defaults rather than crash the header
|
|
36
|
+
}
|
|
37
|
+
return memo;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The stored value for `key`, or undefined when absent or not one of `allowed`. */
|
|
41
|
+
export function loadChoice<T extends string>(key: string, allowed: readonly T[]): T | undefined {
|
|
42
|
+
const value = readConfig()[key];
|
|
43
|
+
return typeof value === "string" && (allowed as readonly string[]).includes(value) ? (value as T) : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Store a choice. Best effort: an unwritable agent dir must not break the TUI. */
|
|
47
|
+
export function saveChoice(key: string, value: string): void {
|
|
48
|
+
const current = readConfig();
|
|
49
|
+
if (current[key] === value) return;
|
|
50
|
+
current[key] = value;
|
|
51
|
+
try {
|
|
52
|
+
const dir = dirname(configPath());
|
|
53
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
54
|
+
writeFileSync(configPath(), `${JSON.stringify(current, null, 2)}\n`, "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore: the choice still applies for this session
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Drop the in-memory copy so the next read hits disk (a restart, in a test). */
|
|
61
|
+
export function resetPresetCache(): void {
|
|
62
|
+
memo = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Self-check: `node lib/preset-store.ts`
|
|
66
|
+
if (isMain(import.meta.url)) {
|
|
67
|
+
// The real agent dir must not decide pass/fail, and the test must not touch it.
|
|
68
|
+
const fixture = mkdtempSync(join(tmpdir(), "arnative-preset-"));
|
|
69
|
+
process.env.PI_CODING_AGENT_DIR = fixture;
|
|
70
|
+
|
|
71
|
+
assert(loadChoice(HEADER_PRESET_KEY, ["a", "b"] as const) === undefined, "absent key reads as undefined");
|
|
72
|
+
saveChoice(HEADER_PRESET_KEY, "b");
|
|
73
|
+
saveChoice(FOOTER_PRESET_KEY, "a");
|
|
74
|
+
assert(loadChoice(HEADER_PRESET_KEY, ["a", "b"] as const) === "b", "a saved choice reads back");
|
|
75
|
+
assert(loadChoice(FOOTER_PRESET_KEY, ["a", "b"] as const) === "a", "a second key is stored without clobbering the first");
|
|
76
|
+
|
|
77
|
+
// The point of the file: the choice outlives the process (drop the in-memory copy).
|
|
78
|
+
resetPresetCache();
|
|
79
|
+
assert(loadChoice(HEADER_PRESET_KEY, ["a", "b"] as const) === "b", "the choice survives a restart (read from disk)");
|
|
80
|
+
|
|
81
|
+
// A value no longer in the allowed set (renamed preset) falls back instead of breaking.
|
|
82
|
+
saveChoice(HEADER_PRESET_KEY, "gone");
|
|
83
|
+
assert(loadChoice(HEADER_PRESET_KEY, ["a", "b"] as const) === undefined, "an unknown stored value is rejected");
|
|
84
|
+
|
|
85
|
+
// A corrupt file must not throw on the header's render path.
|
|
86
|
+
writeFileSync(join(fixture, "arnative-config.json"), "{not json", "utf8");
|
|
87
|
+
resetPresetCache();
|
|
88
|
+
assert(loadChoice(HEADER_PRESET_KEY, ["a", "b"] as const) === undefined, "a corrupt config file reads as empty");
|
|
89
|
+
|
|
90
|
+
rmSync(fixture, { recursive: true, force: true });
|
|
91
|
+
console.log("preset-store.ts self-check OK");
|
|
92
|
+
}
|