@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
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
|
|
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
|
package/extensions/footer.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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 =
|
|
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 :
|
|
151
|
-
const parts = sync.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 || (
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/extensions/tools.ts
CHANGED
|
@@ -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
|
-
|
|
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",
|