@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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.6] - 2026-10-05
4
+
5
+ ### Fixed
6
+ - Fixed a startup crash: `Cannot read properties of null (reading 'fg')`. pi's `[Extension issues]` diagnostic box passed a null theme into the box renderer, which dereferenced it and killed the process with `uncaughtException`. It only surfaced on machines where an extension reported a problem (a conflicting shortcut, a failed `session_start`, …), because that is when the diagnostic is drawn. The renderer now draws an uncolored box for the first frame instead of throwing (`lib/box.ts`).
7
+ - The active theme now lives in a single `globalThis` slot. `/reload` re-imports extension modules, so two copies can coexist, and only one of them receives `session_start`. The copy that owns the `loadedResourcesContainer` patch used to keep a permanently-null theme; it now paints with the live theme (`lib/ansi.ts`, `extensions/section-headers.ts`, `extensions/ui-render-tweaks.ts`).
8
+ - Removed the `activeThemeProxy ?? Theme` fallback in the package-update box: `Theme` is a class, not an instance, so the fallback would itself have thrown `th.fg is not a function`. The box component now tolerates a null theme (`extensions/ui-render-tweaks.ts`).
9
+
10
+ ## [0.3.5] - 2026-10-04
11
+
12
+ ### Fixed
13
+ - Git info in the footer now works on a fresh machine: the branch is read straight from `.git/HEAD` (the trick pi's own footer uses), so it shows without a `git` install and on a repo git refuses to touch (its dubious-ownership guard). Tag, dirty count and ahead/behind still need the `git` binary; when it cannot answer, those parts are left out instead of a fabricated `-`/`clean` (`extensions/footer.ts`).
14
+ - `run()` no longer swallows a failed `git` call into `""`. "git said nothing" and "git could not run" used to be indistinguishable, which is what made four different states — not a repo, no `git` on PATH, a detached HEAD, and a foreign-owned repo — all show as no git info at all.
15
+ - The header's `Context`/`Skills`/`Extensions` counts show `—` instead of a misleading `0` under `"quietStartup": true` (or `"header"`), which hides the resource listing the counts are read from. The empty-tab placeholder now says the counts are not tracked, and the state resets on `session_start` so `/new` cannot show stale counts (`extensions/section-headers.ts`).
16
+ - The header and footer preset chosen in `/arnative` now survives a restart. The choice was only kept on `globalThis`, so every new pi process fell back to the default Arnative (Full) header and footer. It is now written to `arnative-config.json` in the agent dir and read back at startup (`lib/preset-store.ts`, `extensions/section-headers.ts`, `extensions/footer.ts`).
17
+ - The reload-box patch no longer blanks a whole chat: it only drops a container that holds nothing but the box itself (border rows, blank spacers and the message copy). Any other container whose content merely mentioned "Reloading keybindings" — a `read`/`grep` result, this repo's own source — was rendered as zero lines (`extensions/ui-render-tweaks.ts`).
18
+ - Row-1 git info works from a subfolder of a repo: `.git` is looked up by walking up the directory tree like git itself does, so the branch no longer vanishes when the session runs in `extensions/` or `src/` (`extensions/footer.ts`).
19
+ - A tool result whose text merely starts with `Error` is no longer painted as a failed call (red `x`); only `ctx.isError`/`result.isError` decide. Reading a file that opens with "Error handling…" used to show an error box (`extensions/tools.ts`).
20
+ - The stale git state is cleared when the working directory leaves a repo, so a `/reload` cannot re-show the previous directory's branch (`extensions/footer.ts`).
21
+ - The `usage.ts` self-check builds a fixture session directory (and its own cache) instead of reading the real `~/.pi/agent/sessions`. A machine without `gemini-3.8-flash` in its history failed `npm test` (`extensions/usage.ts`, `lib/usage-store.ts`).
22
+ - `isMain()` compares canonical file URLs, so a checkout path containing a space or another character that `import.meta.url` percent-encodes no longer makes a self-check exit 0 without running (`lib/check.ts`).
23
+
24
+ ### Changed
25
+ - An active session that grows is no longer walked in full on every usage refresh. The cache keeps a per-file rollup (providers and provider+model totals) plus the byte offset of the last complete line, so a refresh reads only the appended tail and folds it in. On a 2.3MB / 11,000-message session, a refresh after one new record went from ~76ms (full re-parse **and** re-aggregation of every cached entry) to ~12ms, flat as the chat grows; the cache file went from megabytes to ~0.4KB. A half-written trailing record is retried instead of counted or dropped (`lib/usage-store.ts`).
26
+ - The usage cache format is version 4 (per-file rollup, was raw entries in v3); an older cache is rebuilt once.
27
+ - `npm test` also runs `lib/color.ts`; `prepublishOnly` runs the full test suite before the pre-publish preflight, so a publish cannot ship a red build (`package.json`).
28
+ - `release-check.mjs` verifies the tag points at `HEAD`, and `post-publish` also checks the `latest` dist-tag and the tarball URL are live, not just that the version exists (`scripts/release-check.mjs`).
29
+ - The open-PR check falls back to the public GitHub API when `gh` is unavailable, instead of failing the whole preflight (`scripts/release-check.mjs`).
30
+ - `.gitattributes` pins every text file to LF in the repository, so a Windows checkout stops reintroducing CRLF into diffs.
31
+
32
+ ### Removed
33
+ - `ASCII_LOGO_LINES` (computed at module load, never read) and the unused `Dim` export (`extensions/section-headers.ts`, `lib/box.ts`).
34
+
3
35
  ## [0.3.4] - 2026-10-04
4
36
 
5
37
  ### Added
package/README.md CHANGED
@@ -14,8 +14,10 @@ pi-arnative replaces pi’s default terminal chrome with a unified, cohesive vis
14
14
 
15
15
  - Warm Themes: Cohesive, contrast-checked color palettes generated without drift.
16
16
  - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, and Shortcut.
17
+ > The Context/Skills/Extensions counts read pi's startup listing, which `"quietStartup": true` (and `"header"`) hides. Under those settings the counts show `—` rather than a misleading `0`; set `"quietStartup": false` to see the real numbers.
17
18
  - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call. Built-in `codemode` is boxed and collapsed to two rows (title + first script line) like `bash`/`grep`/`read`; nested calls and output sit behind `[ctrl+o to expand]` (shown whenever the collapsed summary hides more, including `edit` diffs); a failed run always shows its full output. The `chrome_devtools_*` tools box too, replacing their bare "Chrome DevTools: …" lines.
18
19
  - Enhanced Footer: fixed 3-row grid — directory/git line, then extension status paired with token metrics.
20
+ > The branch is read straight from `.git/HEAD`, so it shows up without a git install. Tag, dirty count, and ahead/behind need the `git` binary — when it is missing (or the repo is owned by another user, which git refuses to touch) that half is left out rather than shown wrong. Run `git config --global --add safe.directory <repo>` (a local, non-synced setting) to make `git` cooperate with such a repo.
19
21
  - Transcript Clock: Timestamps for messages with clean bubble backgrounds.
20
22
  - Usage Dashboard: /usage command to track tokens and session costs.
21
23
 
@@ -101,10 +103,10 @@ npm run themes # Rebuild all theme variants
101
103
  | Stage | Run before | Checks |
102
104
  | :--- | :--- | :--- |
103
105
  | `pre-tag` | `git tag` | clean tree on `main`, in sync, no open PRs, `CHANGELOG.md` entry, tag free, version unpublished |
104
- | `pre-publish` | `npm publish` | the above, plus the tag exists **and is pushed** |
105
- | `post-publish` | done | the above, plus the version is live on the registry |
106
+ | `pre-publish` | `npm publish` | the above, plus the tag exists **and is pushed**, and points at `HEAD` |
107
+ | `post-publish` | done | the above, plus the version is live on the registry, `latest` points at it and its tarball URL answers |
106
108
 
107
- `pre-publish` runs automatically through npm's `prepublishOnly`, so a missing or unpushed tag aborts the publish (`npm error code 1`) instead of shipping a version whose tag was forgotten. `npm run release:check` is the `pre-tag` shorthand. Exit codes: `0` pass, `1` a check failed, `2` bad usage.
109
+ `pre-publish` runs automatically through npm's `prepublishOnly` (after `npm test`), so a red build or a missing/unpushed tag aborts the publish (`npm error code 1`) instead of shipping a version whose tag was forgotten. The open-PR check uses `gh` when present and falls back to the public GitHub API otherwise. `npm run release:check` is the `pre-tag` shorthand. Exit codes: `0` pass, `1` a check failed, `2` bad usage.
108
110
  </details>
109
111
 
110
112
  ## License
@@ -13,14 +13,15 @@ import type { AssistantMessage } from "@earendil-works/pi-ai";
13
13
  import { CustomEditor, FooterComponent, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
15
  import { execFile } from "node:child_process";
16
- import { mkdtempSync, rmSync } from "node:fs";
16
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
17
  import { tmpdir } from "node:os";
18
- import { join } from "node:path";
18
+ import { dirname, join, resolve } from "node:path";
19
19
  import { stripAnsi } from "../lib/ansi.ts";
20
20
  import { assert, isMain } from "../lib/check.ts";
21
21
  import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
22
22
  import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
23
23
  import { formatTokens } from "../lib/usage-store.ts";
24
+ import { FOOTER_PRESET_KEY as FOOTER_PRESET_CONFIG_KEY, loadChoice, resetPresetCache, saveChoice } from "../lib/preset-store.ts";
24
25
 
25
26
  // ctrl+alt+r (alt+r on Windows/WSL — see lib/shortcuts.ts).
26
27
  // The TUI drops key-release events before handleInput, so press+release cannot
@@ -38,7 +39,7 @@ const FOOTER_PRESET_KEY = Symbol.for("pi-arnative.footerPreset");
38
39
  const FOOTER_REGISTRAR_KEY = Symbol.for("pi-arnative.footerRegistrar");
39
40
 
40
41
  export function activeFooterPreset(): FooterPreset {
41
- const stored = (globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY];
42
+ const stored = (globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY] ?? loadChoice(FOOTER_PRESET_CONFIG_KEY, FOOTER_PRESETS);
42
43
  return FOOTER_PRESETS.includes(stored as FooterPreset) ? (stored as FooterPreset) : DEFAULT_FOOTER_PRESET;
43
44
  }
44
45
 
@@ -59,6 +60,7 @@ export function applyFooterPreset(ctx: ExtensionContext): void {
59
60
  /** Select a preset and apply it immediately (the footer swaps on the spot). */
60
61
  export function setFooterPreset(name: FooterPreset, ctx: ExtensionContext): void {
61
62
  (globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY] = name;
63
+ saveChoice(FOOTER_PRESET_CONFIG_KEY, name);
62
64
  applyFooterPreset(ctx);
63
65
  }
64
66
 
@@ -77,7 +79,14 @@ if (FooterComponent?.prototype?.render && !(globalThis as Record<symbol, boolean
77
79
 
78
80
  let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
79
81
 
80
- type GitInfo = { branch: string; tag: string; uncommitted: number; ahead: number; behind: number } | null;
82
+ type GitInfo = {
83
+ branch: string;
84
+ /** Absent (null) when git could not answer — never a false "-"/"clean". */
85
+ tag: string | null;
86
+ uncommitted: number | null;
87
+ ahead: number;
88
+ behind: number;
89
+ } | null;
81
90
  let gitRefreshInFlight = false;
82
91
 
83
92
  let git: GitInfo = null;
@@ -118,18 +127,64 @@ export function currentToolVerb(active: ReadonlyMap<string, string>): string {
118
127
  return verb;
119
128
  }
120
129
 
121
- function run(cmd: string, args: string[], cwd: string): Promise<string> {
122
- return new Promise((resolve) => {
130
+ type RunResult = { ok: boolean; out: string };
131
+ /** Distinguishes "git said nothing" from "git could not run" — the caller must not
132
+ * invent a clean tree when git is absent or the repo is not ours. */
133
+ function run(cmd: string, args: string[], cwd: string): Promise<RunResult> {
134
+ return new Promise((resolvePromise) => {
123
135
  execFile(cmd, args, { cwd, timeout: 2000 }, (err, stdout) => {
124
- resolve(err ? "" : String(stdout).trim());
136
+ resolvePromise({ ok: !err, out: String(stdout).trim() });
125
137
  });
126
138
  });
127
139
  }
128
140
 
141
+ /**
142
+ * The branch, straight from `.git/HEAD` — the same trick pi's own footer uses.
143
+ * `git branch --show-current` fails identically for a repo the user does not own
144
+ * (git's dubious-ownership guard), a PATH without git, and a detached HEAD; all
145
+ * three collapsed into a silent null before. Reading the ref needs no binary and
146
+ * no ownership check, so a fresh user sees the branch without installing git.
147
+ */
148
+ function branchFromDisk(cwd: string): string | null {
149
+ // Walk up like git does: a subfolder of a repo is still inside it.
150
+ for (let dir = resolve(cwd); ; ) {
151
+ try {
152
+ const dotGit = join(dir, ".git");
153
+ // A worktree/submodule has a `.git` file pointing at the real git dir.
154
+ const gitDir = statSync(dotGit).isDirectory()
155
+ ? dotGit
156
+ : resolve(dir, readFileSync(dotGit, "utf8").trim().replace(/^gitdir:\s*/, ""));
157
+ const head = readFileSync(join(gitDir, "HEAD"), "utf8").trim();
158
+ const ref = /^ref:\s+refs\/heads\/(.+)$/.exec(head);
159
+ return ref ? ref[1] : "detached";
160
+ } catch (e) {
161
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") return null; // unreadable metadata
162
+ }
163
+ const parent = dirname(dir);
164
+ if (parent === dir) return null; // reached the filesystem root: not a repo
165
+ dir = parent;
166
+ }
167
+ }
168
+
129
169
  // A refresh arriving while one is in flight is queued (last requested cwd wins) and
130
170
  // re-run afterwards — it used to be dropped, leaving the footer's git state stale
131
171
  // until the next trigger.
132
172
  let gitRefreshQueuedCwd: string | null = null;
173
+
174
+ /**
175
+ * Row-1 git segment: branch(+sync), tag, worktree state. Halves git cannot answer
176
+ * (no binary, or a repo the user does not own) are omitted, so a fresh user still sees
177
+ * the branch but never a fabricated "-" or "clean".
178
+ */
179
+ export function gitParts(info: GitInfo, acc: (t: string) => string, soft: (t: string) => string): string[] {
180
+ if (!info) return [];
181
+ const branch = `${acc("\uf126")} ${soft(info.branch)}${info.ahead > 0 ? ` ${soft(`↑${info.ahead}`)}` : ""}${info.behind > 0 ? ` ${soft(`↓${info.behind}`)}` : ""}`;
182
+ const parts = [branch];
183
+ if (info.tag !== null) parts.push(`${acc("\uf02b")} ${soft(info.tag)}`);
184
+ if (info.uncommitted !== null) parts.push(`${acc("\uf172")} ${info.uncommitted > 0 ? soft(`~${info.uncommitted}`) : soft("clean")}`);
185
+ return parts;
186
+ }
187
+
133
188
  async function refreshGit(cwd: string): Promise<void> {
134
189
  if (gitRefreshInFlight) {
135
190
  gitRefreshQueuedCwd = cwd;
@@ -137,9 +192,11 @@ async function refreshGit(cwd: string): Promise<void> {
137
192
  }
138
193
  gitRefreshInFlight = true;
139
194
  try {
140
- const branch = await run("git", ["branch", "--show-current"], cwd);
195
+ const branch = branchFromDisk(cwd);
141
196
  if (!branch) {
197
+ // Clear the shared slot too, or the previous cwd's branch survives a reload.
142
198
  git = null;
199
+ (globalThis as Record<symbol, any>)[GIT_KEY] = null;
143
200
  return;
144
201
  }
145
202
  const [tag, status, sync] = await Promise.all([
@@ -147,13 +204,13 @@ async function refreshGit(cwd: string): Promise<void> {
147
204
  run("git", ["status", "--porcelain"], cwd),
148
205
  run("git", ["rev-list", "--left-right", "--count", "@{u}...HEAD"], cwd),
149
206
  ]);
150
- const uncommitted = status ? status.split("\n").filter((l) => l.trim().length > 0).length : 0;
151
- const parts = sync.trim() === "" ? [] : sync.trim().split(/\s+/).map(Number);
207
+ const uncommitted = status.ok ? status.out.split("\n").filter((l) => l.trim().length > 0).length : null;
208
+ const parts = sync.ok && sync.out !== "" ? sync.out.split(/\s+/).map(Number) : [];
152
209
  const behind = parts.length > 0 && parts[0] > 0 ? parts[0] : 0;
153
210
  const ahead = parts.length > 1 && parts[1] > 0 ? parts[1] : 0;
154
211
  git = {
155
212
  branch,
156
- tag: tag || "-",
213
+ tag: tag.ok && tag.out ? tag.out : null,
157
214
  uncommitted,
158
215
  ahead,
159
216
  behind,
@@ -528,12 +585,7 @@ export default function (pi: ExtensionAPI) {
528
585
  const cwdShort = shortenCwd(cwd);
529
586
  let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
530
587
  if (git) {
531
- const gitIcon = acc("\uf172");
532
- const gitText = git.uncommitted > 0 ? soft(`~${git.uncommitted}`) : soft("clean");
533
- const pBranch = `${acc("\uf126")} ${soft(git.branch)}${git.ahead > 0 ? ` ${soft(`↑${git.ahead}`)}` : ""}${git.behind > 0 ? ` ${soft(`↓${git.behind}`)}` : ""}`;
534
- const pTag = `${acc("\uf02b")} ${soft(git.tag)}`;
535
- const pState = `${gitIcon} ${gitText}`;
536
- left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${sep}${pBranch}${sep}${pTag}${sep}${pState}`;
588
+ left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, soft).map((p) => `${sep}${p}`).join("")}`;
537
589
  }
538
590
  const right1 = formatModelName(currentModel, currentThinkingLevel, acc, soft, dim);
539
591
 
@@ -885,14 +937,43 @@ if (isMain(import.meta.url)) {
885
937
  assert(s3.includes("↑1.1k") && s3.includes("↓2.6k"), "branch length changed: full rescan");
886
938
 
887
939
  // regression: a refresh arriving while one is in flight is re-run with the newest
888
- // cwd, not dropped (the footer's git state no longer stays stale until the next trigger)
940
+ // cwd, not dropped (the footer's git state no longer stays stale until the next trigger).
941
+ // "newest cwd" needs a real repo: the branch now comes from .git/HEAD, not from git.
942
+ const repo = mkdtempSync(join(tmpdir(), "pi-arnative-repo-"));
943
+ mkdirSync(join(repo, ".git"), { recursive: true });
944
+ writeFileSync(join(repo, ".git", "HEAD"), "ref: refs/heads/arnative-tests\n");
889
945
  git = null;
890
946
  const noGit = mkdtempSync(join(tmpdir(), "pi-arnative-nogit-"));
891
947
  void refreshGit(noGit); // in flight (deterministic: sync up to the first await), resolves git = null
892
- await refreshGit(process.cwd()); // queued behind it with the newest cwd
948
+ await refreshGit(repo); // queued behind it with the newest cwd
893
949
  for (let i = 0; i < 100 && git === null; i++) await new Promise((r) => setTimeout(r, 20));
894
- assert(git !== null && git.branch.length > 0, "queued git refresh re-ran on the newest cwd (not dropped)");
950
+ assert(git !== null && git.branch === "arnative-tests", "queued git refresh re-ran on the newest cwd (not dropped)");
895
951
  rmSync(noGit, { recursive: true, force: true });
952
+ rmSync(repo, { recursive: true, force: true });
953
+
954
+ // A repo whose branch is readable without git on PATH: the branch comes from .git/HEAD
955
+ // (pi's own footer does this too), so a fresh user needs no git install for row 1.
956
+ const bare = mkdtempSync(join(tmpdir(), "pi-arnative-disk-"));
957
+ mkdirSync(join(bare, ".git"), { recursive: true });
958
+ writeFileSync(join(bare, ".git", "HEAD"), "ref: refs/heads/arnative-smoke\n");
959
+ assert(branchFromDisk(bare) === "arnative-smoke", "branch is read from .git/HEAD without running git");
960
+ writeFileSync(join(bare, ".git", "HEAD"), "1234567890abcdef1234567890abcdef12345678\n");
961
+ assert(branchFromDisk(bare) === "detached", "a raw HEAD hash reports detached, not null");
962
+ // A subfolder of the repo resolves to the repo's branch (walks up like git).
963
+ writeFileSync(join(bare, ".git", "HEAD"), "ref: refs/heads/arnative-smoke\n");
964
+ mkdirSync(join(bare, "src", "deep"), { recursive: true });
965
+ assert(branchFromDisk(join(bare, "src", "deep")) === "arnative-smoke", "branch resolves from a subfolder of the repo");
966
+ rmSync(bare, { recursive: true, force: true });
967
+
968
+ // Honesty: with the branch known but git unable to answer, tag/status must be absent
969
+ // rather than the old false "-"/"clean".
970
+ const A = (t: string) => t;
971
+ const S = (t: string) => t;
972
+ const unknownGit = gitParts({ branch: "main", tag: null, uncommitted: null, ahead: 0, behind: 0 }, A, S);
973
+ assert(unknownGit.length === 1 && unknownGit[0]!.endsWith("main"), "unreadable git: branch only, no fake '-'/'clean'");
974
+ const fullGit = gitParts({ branch: "main", tag: "v1.2.3", uncommitted: 2, ahead: 1, behind: 0 }, A, S);
975
+ assert(fullGit.length === 3 && fullGit[1]!.endsWith("v1.2.3") && fullGit[2]!.includes("~2"), "readable git: tag + dirty count present");
976
+ assert(gitParts(null, A, S).length === 0, "not a repo: no git segment at all");
896
977
 
897
978
  // Footer presets: Arnative (Full) is the default; Pi (system) hands the footer slot back to pi (the editor stays arnative).
898
979
  assert(DEFAULT_FOOTER_PRESET === "Arnative (Full)" && FOOTER_PRESETS.length === 2, "Arnative (Full) is the default footer preset");
@@ -906,6 +987,11 @@ if (isMain(import.meta.url)) {
906
987
  setEditorComponent: () => { editorTouched++; },
907
988
  },
908
989
  };
990
+ // Persisting a preset writes a config file: point it at a fixture so the real agent dir stays clean.
991
+ const presetFixture = mkdtempSync(join(tmpdir(), "arnative-ftr-"));
992
+ const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
993
+ process.env.PI_CODING_AGENT_DIR = presetFixture;
994
+ resetPresetCache();
909
995
  setFooterPreset("Pi (system)", presetCtx);
910
996
  assert(footerCleared === 1 && editorTouched === 0 && activeFooterPreset() === "Pi (system)", "Pi (system) clears the footer only (an editor swap would close the picker)");
911
997
  let registered = 0;
@@ -913,6 +999,16 @@ if (isMain(import.meta.url)) {
913
999
  setFooterPreset("Arnative (Full)", presetCtx);
914
1000
  assert(registered === 1 && activeFooterPreset() === "Arnative (Full)", "Arnative (Full) re-runs the arnative registrar");
915
1001
  delete (globalThis as Record<symbol, unknown>)[FOOTER_REGISTRAR_KEY];
1002
+ // Persist a NON-default value: asserting the default survives proves nothing.
1003
+ setFooterPreset("Pi (system)", presetCtx);
1004
+ assert(loadChoice(FOOTER_PRESET_CONFIG_KEY, FOOTER_PRESETS) === "Pi (system)", "the footer preset is persisted to disk");
1005
+ delete (globalThis as Record<symbol, unknown>)[FOOTER_PRESET_KEY];
1006
+ resetPresetCache();
1007
+ assert(activeFooterPreset() === "Pi (system)", "a restart reads the non-default footer preset back from disk");
1008
+ rmSync(presetFixture, { recursive: true, force: true });
1009
+ if (prevAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
1010
+ else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
1011
+ resetPresetCache();
916
1012
 
917
1013
  console.log("footer.ts self-check OK");
918
1014
  }
@@ -31,10 +31,11 @@ import {
31
31
  type TuiMouseEventResult,
32
32
  } from "@earendil-works/pi-tui";
33
33
  import { renderBoxLines } from "../lib/box.ts";
34
- import { stripAnsi, themeOf } from "../lib/ansi.ts";
34
+ import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.ts";
35
35
  import { assert, isMain } from "../lib/check.ts";
36
36
  import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
37
37
  import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
38
+ import { HEADER_PRESET_KEY as HEADER_PRESET_CONFIG_KEY, loadChoice, resetPresetCache, saveChoice } from "../lib/preset-store.ts";
38
39
  import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
39
40
 
40
41
  export type TabKey = "Model" | "Directory" | "Context" | "Skills" | "Extensions" | "Shortcut";
@@ -53,8 +54,6 @@ export const SECTION_ICONS: Record<TabKey, string> = {
53
54
 
54
55
  export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
55
56
 
56
- let activeThemeProxy: Themeish = null;
57
-
58
57
  const MODEL_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentModel");
59
58
  const THINKING_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentThinking");
60
59
 
@@ -282,7 +281,6 @@ export function buildLogoLines(th: Themeish): string[] {
282
281
  ];
283
282
  }
284
283
 
285
- export const ASCII_LOGO_LINES = buildLogoLines(null);
286
284
 
287
285
  // Singleton store for loaded resources
288
286
  const STORE_KEY = Symbol.for("pi-arnative.resourceStore");
@@ -307,7 +305,7 @@ export const DEFAULT_HEADER_PRESET: HeaderPreset = "Arnative (Full)";
307
305
  const HEADER_PRESET_KEY = Symbol.for("pi-arnative.headerPreset");
308
306
 
309
307
  export function activeHeaderPreset(): HeaderPreset {
310
- const stored = (globalThis as Record<symbol, unknown>)[HEADER_PRESET_KEY];
308
+ const stored = (globalThis as Record<symbol, unknown>)[HEADER_PRESET_KEY] ?? loadChoice(HEADER_PRESET_CONFIG_KEY, HEADER_PRESETS);
311
309
  return HEADER_PRESETS.includes(stored as HeaderPreset) ? (stored as HeaderPreset) : DEFAULT_HEADER_PRESET;
312
310
  }
313
311
 
@@ -323,9 +321,20 @@ export function applyHeaderPreset(ctx: ExtensionContext): void {
323
321
  /** Select a preset and apply it immediately (real-time: the header swaps on the spot). */
324
322
  export function setHeaderPreset(name: HeaderPreset, ctx: ExtensionContext): void {
325
323
  (globalThis as Record<symbol, unknown>)[HEADER_PRESET_KEY] = name;
324
+ saveChoice(HEADER_PRESET_CONFIG_KEY, name);
326
325
  applyHeaderPreset(ctx);
327
326
  }
328
327
 
328
+ // Counts are captured from pi's loaded-resource listing, which pi skips entirely under
329
+ // `quietStartup: true | "header"` (docs/settings.md). Without the listing, [Context]/
330
+ // [Skills]/[Extensions] are never built, so the store can only answer "unknown" — not 0.
331
+ // That state is tracked globally because the header renders long after `session_start`.
332
+ let countsKnown = true;
333
+ export function resourcesListingShown(current?: boolean): boolean {
334
+ if (current !== undefined) countsKnown = current;
335
+ return countsKnown;
336
+ }
337
+
329
338
  // The installed header instance. globalThis-backed because `/reload` re-imports the
330
339
  // extension modules and arnative.ts imports this file, so two copies can coexist.
331
340
  const HEADER_INSTANCE_KEY = Symbol.for("pi-arnative.headerInstance");
@@ -402,21 +411,21 @@ export class ArnativeHeader implements Component {
402
411
  name: "Context",
403
412
  icon: SECTION_ICONS.Context,
404
413
  count: tabStore.get("Context")?.length ?? 0,
405
- label: `Context [${tabStore.get("Context")?.length ?? 0}]`,
414
+ label: countsKnown ? `Context [${tabStore.get("Context")?.length ?? 0}]` : "Context [—]",
406
415
  },
407
416
  {
408
417
  key: "Skills",
409
418
  name: "Skills",
410
419
  icon: SECTION_ICONS.Skills,
411
420
  count: tabStore.get("Skills")?.length ?? 0,
412
- label: `Skills [${tabStore.get("Skills")?.length ?? 0}]`,
421
+ label: countsKnown ? `Skills [${tabStore.get("Skills")?.length ?? 0}]` : "Skills [—]",
413
422
  },
414
423
  {
415
424
  key: "Extensions",
416
425
  name: "Extensions",
417
426
  icon: SECTION_ICONS.Extensions,
418
427
  count: tabStore.get("Extensions")?.length ?? 0,
419
- label: `Extensions [${tabStore.get("Extensions")?.length ?? 0}]`,
428
+ label: countsKnown ? `Extensions [${tabStore.get("Extensions")?.length ?? 0}]` : "Extensions [—]",
420
429
  },
421
430
  {
422
431
  key: "Shortcut",
@@ -463,7 +472,7 @@ export class ArnativeHeader implements Component {
463
472
  if (width < 36) {
464
473
  return [centerLine(`pi v${VERSION}`, width)];
465
474
  }
466
- const th = this.themeProxy || (activeThemeProxy as Themeish);
475
+ const th = this.themeProxy || getActiveTheme<Themeish>();
467
476
  const side = fgFirst(th, ["dim"], "│");
468
477
 
469
478
  const infoLines: string[] = [];
@@ -608,7 +617,9 @@ export class ArnativeHeader implements Component {
608
617
  infoLines.push(fgFirst(th, ["accentSoft", "text"], line));
609
618
  }
610
619
  } else if (activeItems.length === 0) {
611
- infoLines.push(fgFirst(th, ["accentSoft"], "(empty)"));
620
+ infoLines.push(
621
+ fgFirst(th, ["dim"], countsKnown ? "(empty)" : "(not tracked — quietStartup hides the listing)"),
622
+ );
612
623
  } else if (this.activeTab === "Model") {
613
624
  // The Model tab already carries soft styling + a dim separator
614
625
  infoLines.push(...activeItems);
@@ -658,6 +669,7 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
658
669
  // Mark this container as the loadedResourcesContainer
659
670
  (this as Record<string, unknown>)._isLoadedResourcesContainer = true;
660
671
 
672
+ countsKnown = true;
661
673
  const rawBody = body.split("\n").slice(1).join("\n");
662
674
  const items = extractItemsFromBody(rawBody, sectionName === "Extensions");
663
675
  tabStore.set(sectionName, items);
@@ -675,7 +687,7 @@ if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[
675
687
  const text = themedTextOf(child);
676
688
  if (text.includes("[Extension issues]") && typeof child.render === "function") {
677
689
  const origRender = child.render.bind(child);
678
- child.render = (w: number) => renderBoxLines(activeThemeProxy, w, origRender(w), undefined, "warning");
690
+ child.render = (w: number) => renderBoxLines(getActiveTheme<Themeish>(), w, origRender(w), undefined, "warning");
679
691
  return origAddChild.call(this, child);
680
692
  }
681
693
  // Any other element inside loadedResourcesContainer (e.g. Spacer) is hidden too
@@ -704,8 +716,20 @@ export default function (pi: ExtensionAPI) {
704
716
  pi.registerShortcut(key, { description: "Next header tab", handler: nextTab });
705
717
  }
706
718
 
719
+ // pi renders the resource listing after this event, so a session that starts
720
+ // with it disabled leaves the store empty. That is "not tracked", not "none".
707
721
  pi.on("session_start", async (_event, ctx) => {
708
- activeThemeProxy = themeOf<Themeish>(ctx) ?? activeThemeProxy;
722
+ let quiet: boolean | "header" = false;
723
+ try {
724
+ quiet = pi.getSettings().quietStartup ?? false;
725
+ } catch {
726
+ /* older pi without getSettings: keep pi's default (listing shown) */
727
+ }
728
+ resourcesListingShown(quiet === false);
729
+ for (const key of ["Context", "Skills", "Extensions"] as const) tabStore.set(key, []);
730
+ // A context without a theme (project-trust prompt) must not drop the one we have.
731
+ const th = themeOf<Themeish>(ctx);
732
+ if (th) setActiveTheme(th);
709
733
  // Register the header for the active preset
710
734
  applyHeaderPreset(ctx);
711
735
  });
@@ -735,12 +759,27 @@ if (isMain(import.meta.url)) {
735
759
  assert(DEFAULT_HEADER_PRESET === "Arnative (Full)" && HEADER_PRESETS.length === 2, "Arnative (Full) is the default header preset");
736
760
  assert(HEADER_PRESETS.join(" | ") === "Pi (system) | Arnative (Full)", "built-in preset is listed first");
737
761
  assert(markedLabels(HEADER_PRESETS, "Arnative (Full)").join(" | ") === " Pi (system) | ● Arnative (Full)", "preset labels mark the active one");
762
+ // Persisting a preset writes a config file: point it at a fixture so the real agent dir stays clean.
763
+ const presetFixture = mkdtempSync(join(tmpdir(), "arnative-hdr-"));
764
+ const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
765
+ process.env.PI_CODING_AGENT_DIR = presetFixture;
766
+ resetPresetCache();
738
767
  let installed: unknown = "untouched";
739
768
  const presetCtx: any = { ui: { setHeader: (factory: unknown) => { installed = factory; } } };
740
769
  setHeaderPreset("Pi (system)", presetCtx);
741
770
  assert(installed === undefined && activeHeaderPreset() === "Pi (system)", "Pi (system) clears the custom header");
742
771
  setHeaderPreset("Arnative (Full)", presetCtx);
743
772
  assert(typeof installed === "function" && activeHeaderPreset() === "Arnative (Full)", "Arnative (Full) installs the header factory");
773
+ // Persist a NON-default value: asserting the default survives proves nothing.
774
+ setHeaderPreset("Pi (system)", presetCtx);
775
+ assert(loadChoice(HEADER_PRESET_CONFIG_KEY, HEADER_PRESETS) === "Pi (system)", "the header preset is persisted to disk");
776
+ delete (globalThis as Record<symbol, unknown>)[HEADER_PRESET_KEY];
777
+ resetPresetCache();
778
+ assert(activeHeaderPreset() === "Pi (system)", "a restart reads the non-default preset back from disk");
779
+ rmSync(presetFixture, { recursive: true, force: true });
780
+ if (prevAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
781
+ else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
782
+ resetPresetCache();
744
783
 
745
784
  // Parsing & wrapping
746
785
  assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody splits 3 comma items");
@@ -802,6 +841,25 @@ if (isMain(import.meta.url)) {
802
841
 
803
842
  // Only "dim" is colored, so the box line colors can be asserted.
804
843
  const ansiTheme = { fg: (c: string, t: string) => (c === "dim" ? `\x1b[2m${t}\x1b[22m` : t) };
844
+
845
+ // Counts come from pi's loaded-resource listing. With `quietStartup: true | "header"`
846
+ // pi skips that listing, so the counts are unknown — shown as "—", never a misleading 0.
847
+ resourcesListingShown(false);
848
+ tabStore.set("Context", []);
849
+ tabStore.set("Skills", []);
850
+ tabStore.set("Extensions", []);
851
+ const hdrQuiet = new ArnativeHeader(fakeTui, ansiTheme, fakeCtx);
852
+ assert(hdrQuiet.getTabsData(120).map((t) => t.label).join("|").includes("Skills [—]"), "hidden listing: counts render as —, not 0");
853
+ hdrQuiet.activeTab = "Skills";
854
+ assert(
855
+ hdrQuiet.render(120).some((l) => stripAnsi(l).includes("(not tracked")),
856
+ "hidden listing: empty tab explains itself instead of a bare (empty)",
857
+ );
858
+ resourcesListingShown(true);
859
+ assert(new ArnativeHeader(fakeTui, ansiTheme, fakeCtx).getTabsData(120).some((t) => t.label === "Skills [0]"), "shown listing: 0 is a real count again");
860
+ tabStore.set("Context", ["AGENTS.md"]);
861
+ tabStore.set("Skills", ["agents-sdk", "cloudflare"]);
862
+ tabStore.set("Extensions", ["@bismawy/pi-agentrouter@1.6.1", "footer.ts"]);
805
863
  const hdr = new ArnativeHeader(fakeTui, ansiTheme, fakeCtx);
806
864
  assert(hdr.activeTab === "Directory", "default tab is Directory");
807
865
 
@@ -1009,5 +1067,27 @@ if (isMain(import.meta.url)) {
1009
1067
  assert(sectionBodyOf(helpBlock) === "\u2580\u2580\u2588 v0.99.1\n\u2588\u2580 \u2588 escape interrupt", "sectionBodyOf reads the build shape");
1010
1068
  assert(sectionNameOf(sectionBodyOf(helpBlock)!) === null, "startup help block is not a resource section");
1011
1069
 
1070
+ // pi's "[Extension issues]" diagnostic renders while the installing module's theme is
1071
+ // still null (a `/reload` copy never saw session_start). The box must draw uncolored,
1072
+ // not deref null — that crash was reported in the wild.
1073
+ setActiveTheme(null);
1074
+ const issuesChild = {
1075
+ build: () => "[Extension issues]\n extension \"x\" failed",
1076
+ render: (_w: number) => ["[Extension issues]", ' extension "x" failed'],
1077
+ };
1078
+ container.addChild(issuesChild);
1079
+ const issuesLines = issuesChild.render(80);
1080
+ assert(issuesLines[0]!.startsWith("\u256d") && issuesLines.at(-1)!.startsWith("\u2570"), "extension-issues box renders without a theme");
1081
+
1082
+ // The theme proxy is globalThis-backed, so the copy that owns the addChild patch still
1083
+ // paints once the other copy's session_start has stored the theme.
1084
+ setActiveTheme({ fg: (c: string, t: string) => `<${c}>${t}</${c}>` });
1085
+ const coloredChild = {
1086
+ build: () => "[Extension issues]\n extension \"x\" failed",
1087
+ render: (_w: number) => ["[Extension issues]", ' extension "x" failed'],
1088
+ };
1089
+ container.addChild(coloredChild);
1090
+ assert(coloredChild.render(80)[0]!.includes("<warning>"), "shared theme proxy colors the box even in the patched copy");
1091
+
1012
1092
  console.log("section-headers.ts self-check OK");
1013
1093
  }
@@ -601,7 +601,9 @@ function minimal(
601
601
  if (isPartial) return EMPTY;
602
602
  ((context.state ??= {}) as Record<string, unknown>).hasResult = true;
603
603
  const text = textOf(result);
604
- const isErr = Boolean(context.isError || result.isError || text.startsWith("Error"));
604
+ // `ctx.isError`/`result.isError` are the only honest sources: output text starting with
605
+ // "Error" is normal content (a file whose first line says "Error handling...").
606
+ const isErr = Boolean(context.isError || result.isError);
605
607
  const dur = fmtMs(context.toolCallId ? TIMINGS.get(context.toolCallId) : undefined);
606
608
  const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
607
609
  return new Lines((width) => {
@@ -1207,6 +1209,11 @@ if (isMain(import.meta.url)) {
1207
1209
  minRender("bash", { command: "true" }, { content: [{ type: "text", text: "done" }], details: {} }).every((l) => !l.includes("to expand")),
1208
1210
  "minimal(): a single-line output has nothing hidden, so no hint",
1209
1211
  );
1212
+ // Output that merely STARTS with "Error" is content, not a failure: the box keeps its ✓.
1213
+ assert(
1214
+ minRender("bash", { command: "true" }, { content: [{ type: "text", text: "Error handling notes" }], details: {} }).some((l) => l.includes("✓")),
1215
+ "minimal(): text starting with 'Error' is not treated as a failed call",
1216
+ );
1210
1217
  assert(
1211
1218
  minRender("edit", { path: "README.md" }, diffRes).every((l) => visibleWidth(l) === 74),
1212
1219
  "minimal(): hint row still fills the box width",