@bismawy/pi-arnative 0.2.12 → 0.3.1

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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-10-04
4
+
5
+ ### Changed
6
+ - `/arnative headers` and `/arnative footers` picker rows are reordered built-in first (`Pi (system)`, `Arnative (Full)`) and the parenthetical suffix now renders in the theme's `dim` token, so the preset name carries the row and the qualifier reads as a footnote (`extensions/arnative.ts`).
7
+ - Preset ids renamed: `Pi` → `Pi (system)`. `Arnative (Full)` is still the default and the header/footer slot logic is unchanged.
8
+ - Picker subtitles no longer repeat the preset names: "Selection applies live — custom header or built-in."
9
+ - README shows only `assets/pi-arnative.webp` as the hero image; the full-width `assets/banner.webp` stays reserved for the pi.dev package page via `pi.image`.
10
+ - `package.json` `description` now leads with the README tagline ("Refined aesthetics. Cohesive tools. Built for Pi.") followed by the capability summary, per the `/arnative-pi` manifest standard — pi.dev/packages renders this field verbatim as the package card description.
11
+
12
+ ## [0.3.0] - 2026-10-03
13
+
14
+ ### Fixed
15
+ - `package.json` `files` now ships `LICENSE`; README hero banner uses the standard full-width `assets/banner.webp`.
16
+ - Unit-boundary display bugs in the usage numbers (`lib/usage-store.ts` `formatTokens`, `extensions/usage.ts` `formatCost`): a value that rounds up to the next unit printed `"1000k"` / `"$1000.00"` instead of `"1M"` / `"$1.0k"`; every unit now promotes through `fmtUnit()` (also strips a `.0` tail below 10k, so `9,999` is `"10k"` not `"10.0k"`).
17
+ - `getModelAllTimeUsage()` without a model id matched **every** model, so the header's Model tab showed the grand total under "No Model"; it now matches nothing (zeros).
18
+ - The usage disk cache never dropped session files deleted from disk, so `~/.pi/agent/arnative-usage-cache.json` only ever grew; `collectUsageSummary()` now prunes missing files while scanning.
19
+ - A git refresh arriving while one was in flight was dropped (early return in `refreshGit()`), so the footer's git state stayed stale until the next trigger; it is now queued (last requested cwd wins) and re-run after the in-flight one (`extensions/footer.ts`).
20
+ - `USER_TIMESTAMPS_MAP` grew for the whole process lifetime (it survives `/reload`); `session_start` now clears it first — it is refilled from the branch right after, so it is bounded by the current session (`extensions/timestamps.ts`).
21
+ - The footer's token usage could show numbers up to 2s stale while a reply streamed: the cache only refreshed when the branch length changed, but a stream tick mutates the last message in place. The delta of that message is now applied on top of the cached sums — O(1) per frame, always fresh, still no O(N) rescan (`getUsage()` in `extensions/footer.ts`).
22
+
23
+ ### Changed (breaking — theme contents)
24
+ - All 12 `themes/*.json` are now generated from `themes/gen-themes.mjs` and store **OKLCH** values (`oklch(L% C H)`) instead of hand-written hex. `vars` was renamed to a role-based scheme (`text`→`fg`, `gray`→`fgMuted`, `dimGray`→`fgDim`, `darkGray`→`fgFaint`, `cyan`→`accentBorder`, `softCyan`→`accentSoft`, `blue`→`border`, `green/red/yellow`→`success/error/warning`, `searchBg`→`surfaceSearch`, `selectedBg`→`surfaceSelected`, `userMsgBg`→`surfaceMsg`, `toolPendingBg`→`surfaceRaised`, `toolSuccessBg`→`surfaceToolOk`, `toolErrorBg`→`surfaceToolErr`, `customMsgBg`→`surfaceCustom`); the custom `colors.tint` token was renamed to `colors.accentSoft`. Any theme copied from the old 0.2.x files must be updated by hand — there is no compatibility shim.
25
+ - Colour sets are now derived from a shared ramp, so every theme has consistent lightness/saturation per type and no two `vars` resolve to the same value (the old base had `accent` = `cyan`, `syntaxOperator` = `syntaxPunctuation` = `text`, and variants that copied 12+ identical hex values).
26
+ - `lib/color.ts` (new) holds the OKLCH↔sRGB math, WCAG contrast and `toHex`, shared by the generator and the theme self-check.
27
+
28
+ ### Changed
29
+ - The header's `Themes` tab is gone: pi 1.0 no longer emits a startup `[Themes]` section (only Context, Skills, Prompts, Extensions), so the tab could only ever read `Themes [0]`. The theme list stays reachable through `/settings` → Theme and `/arnative themes` (`extensions/section-headers.ts`).
30
+ - Select menus (`ctx.ui.select`, e.g. `/arnative` and `/jev-eye`): the selected row no longer turns fully accent — only the `→` marker and `·` separators keep accent, the selected text uses `accentSoft` (fallback accent on themes without the token). Live-preview theme picker follows the same rule (`extensions/ui-render-tweaks.ts`, `extensions/arnative.ts`).
31
+ - `accentSoftOf` (accentSoft token with accent fallback) moved to `lib/ansi.ts`, shared by `tools.ts`, `usage.ts` and the new select-row patch; the theme picker no longer crashes when a built-in theme without `accentSoft` is previewed.
32
+ - Theme self-check (`extensions/ui-render-tweaks.ts`) now accepts hex **or** validated OKLCH, converts through `lib/color.ts` before building pi's `Theme` (the bundled 0.87.1 parser only understands hex), asserts every `vars` value is unique per theme, and asserts the thinking ramp rises monotonically.
33
+
34
+ ### Added
35
+ - `/arnative footers` picks the footer preset with the same live-preview picker as headers: `Arnative (Full)` (default, the 3-row grid footer) or `Pi` (pi's built-in footer, stock line count). Moving the selection swaps the footer on the spot, Enter keeps it, Esc restores the preset the picker opened with. The boxed editor is registered once per session and stays arnative in both presets — pi's `setEditorComponent()` calls `disposeActiveSelector()`, which would close the picker mid-preview (`extensions/footer.ts`, `extensions/arnative.ts`).
36
+ - `/arnative headers` picks the header preset: `Arnative (Full)` (default, this extension's header) or `Pi` (pi's built-in header). It uses the same live-preview picker as themes — moving the selection swaps the header on the spot via `ctx.ui.setHeader()`, Enter keeps it, Esc restores the preset the picker opened with. The choice lasts for the process (a new pi launch starts at `Arnative (Full)` again) (`extensions/section-headers.ts`, `extensions/arnative.ts`).
37
+ - `/arnative <menu>` settings command, shaped like `/jev-eye`: no argument opens the menu picker with autocomplete; first menu `themes` is a live-preview picker over the `/settings` → Theme list — moving the selection applies the theme instantly via `ctx.ui.setTheme()`, Enter keeps it, Esc reverts to the theme opened with (`extensions/arnative.ts`). The picker component (`ListPicker`) is shared by the themes, headers and footers menus, and the three identical picker-label helpers were consolidated into `markedLabels()` in `lib/format.ts`.
38
+
39
+ ### Changed
40
+ - Header layout reworked (`extensions/section-headers.ts`): the left column is the pi block mark with `pi vX` directly under it (10 columns wide). The info column starts with `Arnative vY · Welcome back, <User>` (username's first letter capitalized via `capitalize()`), then the tab box and the tab data — no blank lines, so no row is left half empty. The box is 7 lines.
41
+ - The mark is the pi logo's grid rendered at terminal aspect (8 columns x 4 lines) instead of the 6-line "Pi" wordmark, and keeps the old wordmark's two-tone: P (the salmon/blue structure, columns 0-2) in the theme accent, i (the yellow bar, column 3) in the theme tint.
42
+ - The column divider joins the frame with `┬`/`┴` (`render()`, `border()`), so the left/right split runs the full height with no gap at the top or bottom border.
43
+ - The tab box lost its ` Menu ` label, and its tab dividers now join its own borders with `┬`/`┴` (`tabRule()`, `tabSeps`), so each divider is full height instead of a lone `│` on the middle row.
44
+
3
45
  ## [0.2.12] - 2026-10-02
4
46
 
5
47
  ### Changed
package/README.md CHANGED
@@ -13,7 +13,7 @@ Refined aesthetics. Cohesive tools. Built for Pi.
13
13
  pi-arnative replaces pi’s default terminal chrome with a unified, cohesive visual aesthetic.
14
14
 
15
15
  - Warm Themes: Cohesive, contrast-checked color palettes generated without drift.
16
- - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, Themes, and Shortcut.
16
+ - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, and Shortcut.
17
17
  - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call.
18
18
  - Enhanced Footer: fixed 3-row grid — directory/git line, then extension status paired with token metrics.
19
19
  - Transcript Clock: Timestamps for messages with clean bubble backgrounds.
@@ -41,6 +41,7 @@ pi --extension ./extensions/footer.ts
41
41
  | `ctrl+alt+r` · `alt+r` | Quick reload (`/reload`) |
42
42
  | `ctrl+alt+n` · `alt+n` | New session (`/new`) — requires keybinding below |
43
43
  | `/usage` | Show session usage & token metrics |
44
+ | `/arnative` | Settings menu — Themes, Headers, Footers |
44
45
  | Click tab | Open selected header tab |
45
46
  | Click tool / `ctrl+e` | Toggle tool output box |
46
47
 
@@ -67,8 +68,9 @@ Add to `~/.pi/agent/keybindings.json` to bind `app.session.new`:
67
68
  | File | Role |
68
69
  | --- | --- |
69
70
  | `extensions/tools.ts` | Rounded tool boxes, spinners, and duration |
70
- | `extensions/section-headers.ts` | Tabbed header and resource box |
71
- | `extensions/footer.ts` | 3-row status grid footer and boxed editor |
71
+ | `extensions/section-headers.ts` | Tabbed header and resource box (preset: `/arnative headers`) |
72
+ | `extensions/footer.ts` | 3-row status grid footer and boxed editor (preset: `/arnative footers`) |
73
+ | `extensions/arnative.ts` | `/arnative` settings menu — live-preview theme/header/footer pickers |
72
74
  | `extensions/timestamps.ts` | Message clock and bubble background fixes |
73
75
  | `extensions/ui-render-tweaks.ts` | Contrast tweaks, selection style, and UI polish |
74
76
  | `extensions/usage.ts` | `/usage` token dashboard |
@@ -78,8 +80,9 @@ Add to `~/.pi/agent/keybindings.json` to bind `app.session.new`:
78
80
  <details>
79
81
  <summary><b>Themes</b></summary>
80
82
 
81
- - `themes/arnative.json` serves as the structure source of truth.
82
- - `themes/gen-themes.mjs` generates variants with automated contrast checks.
83
+ - `themes/gen-themes.mjs` is the single source of truth: it **writes every** `themes/*.json`, including the base `arnative.json`.
84
+ - Each theme is a few parameters (accent hue, chroma, canvas lightness, neutral tint) plus optional hue overrides for the strong palettes; all colors are derived as OKLCH from a shared ramp, so lightness/saturation stay consistent and no two `vars` collapse to the same value.
85
+ - `lib/color.ts` holds the OKLCH↔sRGB math and WCAG contrast used by both the generator and the self-check.
83
86
 
84
87
  **Variants:** `sun` · `zinc` · `violet` · `emerald` · `matrix` · `cyberpunk` · `synthwave` · `gruvbox` · `nord` · `dracula`
85
88
 
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Arnative settings menu (/arnative <menu>) — same shape as /jev-eye:
3
+ * no argument opens the menu picker, each menu is a subcommand with autocomplete.
4
+ * First menu: themes — the theme list from /settings with live preview: moving the
5
+ * selection calls ctx.ui.setTheme() immediately (applies + persists like the
6
+ * /settings picker), Enter keeps it, Esc restores the theme opened with.
7
+ * `headers` uses the same picker over the header presets, swapping
8
+ * the header on every move (ctx.ui.setHeader()) and restoring it on Esc.
9
+ */
10
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
+ import { matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, type Component } from "@earendil-works/pi-tui";
12
+ import { accentSoftOf } from "../lib/ansi.ts";
13
+ import { boxEdge, boxRow } from "../lib/box.ts";
14
+ import { assert, isMain } from "../lib/check.ts";
15
+ import { markedLabels } from "../lib/format.ts";
16
+ import { FOOTER_PRESETS, activeFooterPreset, setFooterPreset, type FooterPreset } from "./footer.ts";
17
+ import { HEADER_PRESETS, activeHeaderPreset, setHeaderPreset, type HeaderPreset } from "./section-headers.ts";
18
+
19
+ export interface ArnativeMenu {
20
+ value: string;
21
+ label: string;
22
+ description: string;
23
+ open: (ctx: ExtensionCommandContext) => Promise<void>;
24
+ }
25
+
26
+ /** Menu picker rows: `Label · description` (same rhythm as /jev-eye's menu). */
27
+ export function menuLabels(menus: Pick<ArnativeMenu, "label" | "description">[]): string[] {
28
+ return menus.map((m) => `${m.label} · ${m.description}`);
29
+ }
30
+
31
+ interface ListPickerHooks {
32
+ /** Apply a value on every selection move (live preview). */
33
+ preview: (name: string) => void;
34
+ /** Restore the value the picker opened with (Esc). */
35
+ revert: () => void;
36
+ done: () => void;
37
+ requestRender: () => void;
38
+ }
39
+
40
+ /** Box copy, so one picker serves every menu ("Arnative · Themes", "Arnative · Headers", …). */
41
+ export interface ListPickerText {
42
+ title: string;
43
+ subtitle: string;
44
+ footer: string;
45
+ }
46
+
47
+ /** Live-preview picker: ↑↓ previews instantly, Enter keeps, Esc reverts. */
48
+ export class ListPicker implements Component {
49
+ private theme: { fg(c: string, t: string): string; bold?(t: string): string };
50
+ private names: string[];
51
+ private hooks: ListPickerHooks;
52
+ private text: ListPickerText;
53
+ private selected: number;
54
+ private applied: string | undefined;
55
+ private readonly opened: string | undefined;
56
+
57
+ constructor(
58
+ theme: { fg(c: string, t: string): string; bold?(t: string): string },
59
+ names: string[],
60
+ current: string | undefined,
61
+ hooks: ListPickerHooks,
62
+ text: ListPickerText,
63
+ ) {
64
+ this.theme = theme;
65
+ this.names = names;
66
+ this.hooks = hooks;
67
+ this.text = text;
68
+ this.opened = current;
69
+ this.applied = current;
70
+ this.selected = Math.max(0, names.indexOf(current ?? ""));
71
+ }
72
+
73
+ invalidate(): void {}
74
+ dispose(): void {}
75
+
76
+ private move(delta: number): void {
77
+ const next = Math.min(this.names.length - 1, Math.max(0, this.selected + delta));
78
+ if (next === this.selected) return;
79
+ this.selected = next;
80
+ this.applied = this.names[next];
81
+ this.hooks.preview(this.applied);
82
+ this.hooks.requestRender();
83
+ }
84
+
85
+ handleInput(keyData: string): void {
86
+ if (matchesKey(keyData, "q") || matchesKey(keyData, "Q") || matchesKey(keyData, "escape")) {
87
+ if (this.applied !== this.opened) this.hooks.revert();
88
+ this.hooks.done();
89
+ return;
90
+ }
91
+ if (matchesKey(keyData, "up") || matchesKey(keyData, "k")) {
92
+ this.move(-1);
93
+ return;
94
+ }
95
+ if (matchesKey(keyData, "down") || matchesKey(keyData, "j")) {
96
+ this.move(1);
97
+ return;
98
+ }
99
+ if (matchesKey(keyData, "enter")) this.hooks.done();
100
+ }
101
+
102
+ render(width: number): string[] {
103
+ const th = this.theme;
104
+ const bold = th.bold ?? ((t: string) => `\x1b[1m${t}\x1b[22m`);
105
+ const soft = accentSoftOf(th);
106
+ const row = (text: string) => boxRow(text, width, soft);
107
+ const out: string[] = [boxEdge("╭", "╮", width, soft)];
108
+ out.push(row(th.fg("accent", bold(this.text.title))));
109
+ out.push(row(th.fg("muted", this.text.subtitle)));
110
+ out.push(row(""));
111
+ for (let i = 0; i < this.names.length; i++) {
112
+ const name = this.names[i]!;
113
+ const isSelected = i === this.selected;
114
+ const mark = name === this.applied ? "● " : " ";
115
+ const plain = truncateToWidth(`${isSelected ? "▸" : " "} ${mark}${name}`, width - 4);
116
+ // Preset rows end in a parenthetical (`Pi (system)`, `Arnative (Full)`): the suffix reads softer than the name.
117
+ const suffix = /(\(.*\))$/.exec(plain)?.[1] ?? "";
118
+ const head = plain.slice(0, plain.length - suffix.length);
119
+ const tail = suffix ? th.fg("dim", suffix) : "";
120
+ // Same rule as the select dialogs: marker accent, name soft.
121
+ out.push(row(isSelected ? th.fg("accent", head.slice(0, 2 + mark.length)) + soft(head.slice(2 + mark.length)) + tail : th.fg("text", head) + tail));
122
+ }
123
+ out.push(row(""));
124
+ out.push(row(th.fg("dim", this.text.footer)));
125
+ out.push(boxEdge("╰", "╯", width, soft));
126
+ return out.map((l) => truncateToWidth(l, width, ""));
127
+ }
128
+ }
129
+
130
+ const THEMES_PICKER_TEXT: ListPickerText = {
131
+ title: "Arnative · Themes",
132
+ subtitle: "Selection previews live — same list as /settings → Theme.",
133
+ footer: "[↑↓] Preview live [Enter] Keep [Esc] Revert",
134
+ };
135
+
136
+ const openThemes = async (ctx: ExtensionCommandContext): Promise<void> => {
137
+ const themes = ctx.ui.getAllThemes();
138
+ if (themes.length === 0) {
139
+ ctx.ui.notify("[arnative] No themes found. Add files to ~/.pi/agent/themes/ and run /reload.", "warning");
140
+ return;
141
+ }
142
+ const names = themes.map((t) => t.name);
143
+ const current = ctx.ui.theme.name;
144
+
145
+ if (typeof ctx.ui.custom !== "function") {
146
+ // No custom components (headless/RPC): fall back to the blocking picker.
147
+ const labels = markedLabels(names, current);
148
+ const pick = await ctx.ui.select("Arnative · Themes", labels);
149
+ if (pick === undefined) return;
150
+ const result = ctx.ui.setTheme(names[labels.indexOf(pick)]!);
151
+ ctx.ui.notify(
152
+ result.success ? `[arnative] Theme: ${names[labels.indexOf(pick)]}` : `[arnative] ${result.error ?? "failed"}`,
153
+ result.success ? "info" : "error",
154
+ );
155
+ return;
156
+ }
157
+
158
+ await ctx.ui.custom<void>((tui, theme, _kb, done) =>
159
+ new ListPicker(theme, names, current, {
160
+ preview: (name) => {
161
+ const result = ctx.ui.setTheme(name);
162
+ if (!result.success) ctx.ui.notify(`[arnative] ${result.error ?? `failed to apply ${name}`}`, "error");
163
+ },
164
+ revert: () => {
165
+ if (current) ctx.ui.setTheme(current);
166
+ },
167
+ done,
168
+ requestRender: () => tui.requestRender(),
169
+ }, THEMES_PICKER_TEXT),
170
+ );
171
+ };
172
+
173
+ const FOOTERS_PICKER_TEXT: ListPickerText = {
174
+ title: "Arnative · Footers",
175
+ subtitle: "Selection applies live — custom footer or built-in.",
176
+ footer: "[↑↓] Preview live [Enter] Keep [Esc] Revert",
177
+ };
178
+
179
+ const openFooters = async (ctx: ExtensionCommandContext): Promise<void> => {
180
+ const opened = activeFooterPreset();
181
+
182
+ if (typeof ctx.ui.custom !== "function") {
183
+ // No custom components (headless/RPC): fall back to the blocking picker.
184
+ const labels = markedLabels(FOOTER_PRESETS, opened);
185
+ const pick = await ctx.ui.select("Arnative · Footers", labels);
186
+ if (pick === undefined) return;
187
+ const name = FOOTER_PRESETS[labels.indexOf(pick)]!;
188
+ setFooterPreset(name, ctx);
189
+ ctx.ui.notify(`[arnative] Footer: ${name}`, "info");
190
+ return;
191
+ }
192
+
193
+ await ctx.ui.custom<void>((tui, theme, _kb, done) =>
194
+ new ListPicker(
195
+ theme,
196
+ [...FOOTER_PRESETS],
197
+ opened,
198
+ {
199
+ preview: (name) => setFooterPreset(name as FooterPreset, ctx),
200
+ revert: () => setFooterPreset(opened, ctx),
201
+ done,
202
+ requestRender: () => tui.requestRender(),
203
+ },
204
+ FOOTERS_PICKER_TEXT,
205
+ ),
206
+ );
207
+ };
208
+
209
+ const HEADERS_PICKER_TEXT: ListPickerText = {
210
+ title: "Arnative · Headers",
211
+ subtitle: "Selection applies live — custom header or built-in.",
212
+ footer: "[↑↓] Preview live [Enter] Keep [Esc] Revert",
213
+ };
214
+
215
+ const openHeaders = async (ctx: ExtensionCommandContext): Promise<void> => {
216
+ const opened = activeHeaderPreset();
217
+
218
+ if (typeof ctx.ui.custom !== "function") {
219
+ // No custom components (headless/RPC): fall back to the blocking picker.
220
+ const labels = markedLabels(HEADER_PRESETS, opened);
221
+ const pick = await ctx.ui.select("Arnative · Headers", labels);
222
+ if (pick === undefined) return;
223
+ const name = HEADER_PRESETS[labels.indexOf(pick)]!;
224
+ setHeaderPreset(name, ctx);
225
+ ctx.ui.notify(`[arnative] Header: ${name}`, "info");
226
+ return;
227
+ }
228
+
229
+ await ctx.ui.custom<void>((tui, theme, _kb, done) =>
230
+ new ListPicker(
231
+ theme,
232
+ [...HEADER_PRESETS],
233
+ opened,
234
+ {
235
+ preview: (name) => setHeaderPreset(name as HeaderPreset, ctx),
236
+ revert: () => setHeaderPreset(opened, ctx),
237
+ done,
238
+ requestRender: () => tui.requestRender(),
239
+ },
240
+ HEADERS_PICKER_TEXT,
241
+ ),
242
+ );
243
+ };
244
+
245
+ export const MENUS: ArnativeMenu[] = [
246
+ {
247
+ value: "themes",
248
+ label: "Themes",
249
+ description: "list & apply themes (the /settings → Theme list)",
250
+ open: openThemes,
251
+ },
252
+ {
253
+ value: "headers",
254
+ label: "Headers",
255
+ description: "choose the header preset",
256
+ open: openHeaders,
257
+ },
258
+ {
259
+ value: "footers",
260
+ label: "Footers",
261
+ description: "choose the footer preset",
262
+ open: openFooters,
263
+ },
264
+ ];
265
+
266
+ export async function arnativeHandler(args: string, ctx: ExtensionCommandContext): Promise<void> {
267
+ if (!ctx.hasUI) return;
268
+ const sub = args.trim().toLowerCase();
269
+
270
+ if (sub === "") {
271
+ const labels = menuLabels(MENUS);
272
+ const pick = await ctx.ui.select("Arnative · Settings", labels);
273
+ if (pick === undefined) return;
274
+ await MENUS[labels.indexOf(pick)]!.open(ctx);
275
+ return;
276
+ }
277
+
278
+ const menu = MENUS.find((m) => m.value === sub);
279
+ if (!menu) {
280
+ ctx.ui.notify(`[arnative] Unknown menu "${sub}". Menus: ${MENUS.map((m) => m.value).join(" | ")}`, "warning");
281
+ return;
282
+ }
283
+ await menu.open(ctx);
284
+ }
285
+
286
+ export function arnativeCompletions(prefix: string): AutocompleteItem[] | null {
287
+ const items: AutocompleteItem[] = MENUS.filter((m) => m.value.startsWith(prefix.trim().toLowerCase())).map((m) => ({
288
+ value: m.value,
289
+ label: m.label,
290
+ description: m.description,
291
+ }));
292
+ return items.length > 0 ? items : null;
293
+ }
294
+
295
+ export default function (pi: ExtensionAPI) {
296
+ pi.registerCommand("arnative", {
297
+ description: "Arnative settings menu: themes, headers, footers",
298
+ getArgumentCompletions: arnativeCompletions,
299
+ handler: arnativeHandler,
300
+ });
301
+ }
302
+
303
+ // Self-check: `node extensions/arnative.ts`
304
+ if (isMain(import.meta.url)) {
305
+ assert(markedLabels(["a", "b"], "b")[1] === "● b", "markedLabels marks the active theme");
306
+ assert(markedLabels(["a", "b"], "b")[0] === " a", "markedLabels leaves others unmarked");
307
+ assert(markedLabels(["a"], undefined)[0] === " a", "markedLabels handles no active theme");
308
+ const labels = menuLabels(MENUS);
309
+ assert(labels.length === MENUS.length, "menuLabels one row per menu");
310
+ assert(labels[0] === "Themes · list & apply themes (the /settings → Theme list)", "menu row format");
311
+ assert(MENUS.every((m) => typeof m.open === "function"), "every menu opens");
312
+ assert(MENUS.some((m) => m.value === "headers"), "headers menu registered");
313
+
314
+ assert(arnativeCompletions("th")!.length === 1, "completions match prefix");
315
+ assert(arnativeCompletions("th")![0]!.value === "themes", "completions value = menu value");
316
+ assert(arnativeCompletions("he")![0]!.value === "headers", "headers menu has completions");
317
+ assert(arnativeCompletions("fo")![0]!.value === "footers", "footers menu has completions");
318
+ assert(MENUS.some((m) => m.value === "footers"), "footers menu registered");
319
+ assert(arnativeCompletions("zz") === null, "completions null when nothing matches");
320
+
321
+ // Live-preview picker (shared by Themes and Headers): move = preview, Enter = keep, Esc = revert.
322
+ const fakeTheme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
323
+ const pickerText: ListPickerText = { title: "Arnative · Themes", subtitle: "sub", footer: "[Esc] Revert" };
324
+ const previews: string[] = [];
325
+ let reverted = 0;
326
+ let done = 0;
327
+ const picker = new ListPicker(fakeTheme, ["arnative", "arnative-nord"], "arnative", {
328
+ preview: (n) => previews.push(n),
329
+ revert: () => reverted++,
330
+ done: () => done++,
331
+ requestRender: () => {},
332
+ }, pickerText);
333
+ const lines = picker.render(50);
334
+ assert(lines[0]!.startsWith("╭") && lines[lines.length - 1]!.startsWith("╰"), "picker framed");
335
+ assert(lines.every((l) => visibleWidth(l) === 50), "picker lines fit width 50");
336
+ assert(lines.some((l) => l.includes("Arnative · Themes")), "picker renders the configured title");
337
+ assert(lines.some((l) => l.includes("● arnative")), "applied value marked");
338
+ picker.handleInput("\x1b[B"); // down
339
+ assert(previews.length === 1 && previews[0] === "arnative-nord", "move previews immediately");
340
+ assert(picker.render(50).some((l) => l.includes("● arnative-nord")), "mark follows the preview");
341
+ picker.handleInput("\r"); // enter
342
+ assert(done === 1 && reverted === 0, "Enter keeps (no revert)");
343
+ picker.handleInput("\x1b[B");
344
+ picker.handleInput("\x1b"); // escape
345
+ assert(reverted === 1 && done === 2, "Esc reverts after preview");
346
+
347
+ // Headers reuses the same picker with its own copy and presets.
348
+ assert(HEADER_PRESETS[0] === "Pi (system)", "built-in header preset is listed first");
349
+ const headerPicker = new ListPicker(fakeTheme, [...HEADER_PRESETS], "Arnative (Full)", { preview: () => {}, revert: () => {}, done: () => {}, requestRender: () => {} }, HEADERS_PICKER_TEXT);
350
+ const headerLines = headerPicker.render(50);
351
+ assert(headerLines.some((l) => l.includes("Arnative · Headers")), "headers picker title");
352
+ assert(headerLines.some((l) => l.includes("● Arnative (Full)")), "headers picker marks the active preset");
353
+ assert(headerLines.some((l) => l.includes(" Pi (system)")), "headers picker lists the built-in preset");
354
+
355
+ // Footers reuses the same picker too.
356
+ assert(FOOTER_PRESETS[0] === "Pi (system)", "built-in footer preset is listed first");
357
+ const footerPicker = new ListPicker(fakeTheme, [...FOOTER_PRESETS], "Pi (system)", { preview: () => {}, revert: () => {}, done: () => {}, requestRender: () => {} }, FOOTERS_PICKER_TEXT);
358
+ const footerLines = footerPicker.render(50);
359
+ assert(footerLines.some((l) => l.includes("Arnative · Footers")), "footers picker title");
360
+ assert(footerLines.some((l) => l.includes("● Pi (system)")), "footers picker marks the active preset");
361
+
362
+ console.log("arnative.ts self-check OK");
363
+ }