@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.
@@ -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
- let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
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
- return lines.some((l) => l.includes("Reloading keybindings"));
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(activeThemeProxy);
138
- const bgOpen = ansiBgOpen(activeThemeProxy, "selectedBg");
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), activeThemeProxy);
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 = activeThemeProxy;
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
- activeThemeProxy = themeOf<typeof activeThemeProxy>(ctx) ?? activeThemeProxy;
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
- th.fg("warning", "\x1b[1m\uf449 Package Updates Available\x1b[22m"),
233
- `${th.fg("muted", "Package updates are available. Run ")}${th.fg("accent", "pi update --extensions")}`,
234
- th.fg("muted", "Packages:"),
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, activeThemeProxy ?? Theme));
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 = activeThemeProxy;
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(["╭──╮", "│ Reloading keybindings, extensions, skills... │"]) === true, "reload box detected");
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)
@@ -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 { getAgentDir } from "@earendil-works/pi-coding-agent";
8
- import { existsSync, readFileSync } from "node:fs";
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
- const summary = collectUsageSummary();
289
- assert(summary.providers.length > 0, "collectUsageSummary detects providers");
290
- assert(summary.totals.sessions > 0, "collectUsageSummary detects total sessions");
291
- assert(summary.totals.msgs > 0, "collectUsageSummary detects total msgs");
292
- assert(summary.totals.cost >= 0, "collectUsageSummary counts cost");
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 > 0, "getModelAllTimeUsage reads input tokens for gemini-3.8-flash");
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
- const cacheFile = join(getAgentDir(), "arnative-usage-cache.json");
303
- if (existsSync(cacheFile)) {
304
- const raw = JSON.parse(readFileSync(cacheFile, "utf8")) as Record<string, unknown>;
305
- assert(Object.keys(raw).every((k) => existsSync(k)), "usage disk cache prunes deleted session files");
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
- export type Dim = (s: string) => string;
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
- export function renderBoxLines(theme: BoxTheme, width: number, rows: string[], bgType?: string, borderColor = "dim"): string[] {
28
- const dim = (s: string) => theme.fg(borderColor, s);
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.bg) {
38
+ if (bgType && theme?.bg) {
36
39
  try {
37
- return lines.map((l) => applyBg(l, width, (s) => theme.bg!(bgType, s)));
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
- return Boolean(process.argv[1] && url.endsWith(process.argv[1].split("\\").join("/")));
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
+ }