@bismawy/pi-arnative 0.3.9 → 0.4.0

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.
@@ -1,33 +1,69 @@
1
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.
2
+ * Arnative settings menu (/arnative <menu>): no argument opens the picker, each menu is a
3
+ * subcommand with autocomplete. Themes/Headers/Footers preview live — a move applies the
4
+ * value, Enter keeps it, Esc restores the one it opened with; Usage opens the dashboard.
5
+ * Screens share one chrome and one title shape ({@link renderScreen}); a box border takes
6
+ * the session's thinking slot (`lib/ansi.ts`), so it belongs to the active model.
9
7
  */
10
8
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
9
  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";
10
+ import { accentSoftOf, stripAnsi, thinkingColorOf } from "../lib/ansi.ts";
11
+ import { renderScreen, screenTitle, type ScreenText } from "../lib/box.ts";
14
12
  import { assert, isMain } from "../lib/check.ts";
15
13
  import { markedLabels } from "../lib/format.ts";
14
+ import { LEGEND_LIVE, LEGEND_MOVE, matchesBack } from "../lib/shortcuts.ts";
15
+ import { SPINNER_PRESETS, type SpinnerPreset } from "../lib/spinner.ts";
16
+ import {
17
+ ANIMATIONS_PICKER_TEXT,
18
+ activeSpinnerPreset,
19
+ animationRows,
20
+ setSpinnerPreset,
21
+ } from "./animations.ts";
16
22
  import { FOOTER_PRESETS, activeFooterPreset, setFooterPreset, type FooterPreset } from "./footer.ts";
17
23
  import { HEADER_PRESETS, activeHeaderPreset, setHeaderPreset, type HeaderPreset } from "./section-headers.ts";
24
+ import { openUsage } from "./usage.ts";
18
25
 
19
26
  export interface ArnativeMenu {
20
27
  value: string;
21
28
  label: string;
22
29
  description: string;
23
- open: (ctx: ExtensionCommandContext) => Promise<void>;
30
+ /** Returns `true` when the screen asked to step back to the menu (`←`). */
31
+ open: (ctx: ExtensionCommandContext) => Promise<boolean | void>;
24
32
  }
25
33
 
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}`);
34
+ /** One picker row: `Label · Note`. `note` is absent on the preset lists (themes, headers,
35
+ * footers), which are bare names. */
36
+ export interface ListRow {
37
+ label: string;
38
+ note?: string;
39
+ }
40
+
41
+ /** Divider between row label and note, padded to align across rows. */
42
+ export const ROW_DIVIDER = " · ";
43
+
44
+ /** Menu rows: `Label · description` (same rhythm as /jev-eye's menu). */
45
+ export function menuRows(menus: Pick<ArnativeMenu, "label" | "description">[]): ListRow[] {
46
+ return menus.map((m) => ({ label: m.label, note: m.description }));
29
47
  }
30
48
 
49
+ // Legends come from the `KeySet`s the handlers match, so a key cannot be advertised on a
50
+ // screen that does not handle it; the root menu has nothing behind it and omits `←`.
51
+ export const LIST_LEGEND_MOVE = LEGEND_MOVE;
52
+ export const LIST_LEGEND_LIVE = LEGEND_LIVE;
53
+
54
+ /** One row as a flat string — the headless/RPC `ctx.ui.select` fallback, which has no columns. */
55
+ export function rowText(row: ListRow): string {
56
+ return row.note ? `${row.label} · ${row.note}` : row.label;
57
+ }
58
+
59
+ /** Bare names (preset lists) as rows; the cursor's `→` marks the live value. */
60
+ const nameRows = (names: readonly string[]): ListRow[] => names.map((label) => ({ label }));
61
+
62
+ /** Title of the settings menu, derived from `screenTitle` so the format lives in one place. */
63
+ export const MENU_TITLE = screenTitle("Settings");
64
+ /** One-line description under the menu title. */
65
+ export const MENU_DESC = "Themes, headers, footers, animations, usage";
66
+
31
67
  interface ListPickerHooks {
32
68
  /** Apply a value on every selection move (live preview). */
33
69
  preview: (name: string) => void;
@@ -35,57 +71,91 @@ interface ListPickerHooks {
35
71
  revert: () => void;
36
72
  done: () => void;
37
73
  requestRender: () => void;
74
+ /** Esc, when it must be told apart from Enter (a menu selects, it does not revert). */
75
+ cancel?: () => void;
76
+ /** `←`: leave this screen for its parent (the menu, or the chat when there is none).
77
+ * Distinct from `cancel`/Esc, which closes or reverts the screen it is on. */
78
+ back?: () => void;
38
79
  }
39
80
 
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
- }
81
+ /** Box copy, so one picker serves every menu ("Themes - Arnative⠙", "Headers - Arnative⠙", …).
82
+ * The same shape every arnative screen uses — see {@link renderScreen}. */
83
+ export type ListPickerText = ScreenText;
46
84
 
47
- /** Live-preview picker: ↑↓ previews instantly, Enter keeps, Esc reverts. */
85
+ /** Live-preview picker: ↑↓ previews instantly, Enter keeps, Esc reverts. A caller whose
86
+ * preview does nothing (`livePreview: false`) gets a plain menu: no revert on Esc, and
87
+ * Esc is reported through `cancel` so "chose nothing" stays distinguishable from Enter. */
48
88
  export class ListPicker implements Component {
49
89
  private theme: { fg(c: string, t: string): string; bold?(t: string): string };
50
- private names: string[];
90
+ private rows: ListRow[];
51
91
  private hooks: ListPickerHooks;
52
92
  private text: ListPickerText;
53
93
  private selected: number;
54
94
  private applied: string | undefined;
95
+ private readonly livePreview: boolean;
55
96
  private readonly opened: string | undefined;
97
+ /** Border painter, from the session's thinking level — the box follows the model. */
98
+ private readonly edge: (s: string) => string;
56
99
 
57
100
  constructor(
58
101
  theme: { fg(c: string, t: string): string; bold?(t: string): string },
59
- names: string[],
102
+ rows: ListRow[],
60
103
  current: string | undefined,
61
104
  hooks: ListPickerHooks,
62
105
  text: ListPickerText,
106
+ /** False for a menu: the selection is applied only on Enter, never while moving. */
107
+ livePreview = true,
108
+ /** The session's thinking level; colors the box border (theme `thinking*` slot). */
109
+ thinkingLevel?: string,
63
110
  ) {
64
111
  this.theme = theme;
65
- this.names = names;
112
+ this.rows = rows;
66
113
  this.hooks = hooks;
67
114
  this.text = text;
115
+ this.livePreview = livePreview;
68
116
  this.opened = current;
69
117
  this.applied = current;
70
- this.selected = Math.max(0, names.indexOf(current ?? ""));
118
+ this.selected = Math.max(0, rows.findIndex((r) => r.label === current));
119
+ this.edge = thinkingColorOf(theme, thinkingLevel);
71
120
  }
72
121
 
73
122
  invalidate(): void {}
74
123
  dispose(): void {}
75
124
 
125
+ /** Selected row, for callers that need the choice after `done()` resolves. */
126
+ get index(): number {
127
+ return this.selected;
128
+ }
129
+
76
130
  private move(delta: number): void {
77
- const next = Math.min(this.names.length - 1, Math.max(0, this.selected + delta));
131
+ const next = Math.min(this.rows.length - 1, Math.max(0, this.selected + delta));
78
132
  if (next === this.selected) return;
79
133
  this.selected = next;
80
- this.applied = this.names[next];
134
+ if (!this.livePreview) {
135
+ this.hooks.requestRender();
136
+ return;
137
+ }
138
+ this.applied = this.rows[next]!.label;
81
139
  this.hooks.preview(this.applied);
82
140
  this.hooks.requestRender();
83
141
  }
84
142
 
85
143
  handleInput(keyData: string): void {
144
+ if (matchesBack(keyData)) {
145
+ // Leave for the parent without touching the preview: `back` owns the revert of an
146
+ // uncommitted preview, because "cancel" here means "close", not "undo".
147
+ if (this.hooks.back) {
148
+ this.hooks.back();
149
+ return;
150
+ }
151
+ // No parent (the root menu): ← behaves like Esc — close, reverting a live preview.
152
+ if (this.applied !== this.opened) this.hooks.revert();
153
+ (this.hooks.cancel ?? this.hooks.done)();
154
+ return;
155
+ }
86
156
  if (matchesKey(keyData, "q") || matchesKey(keyData, "Q") || matchesKey(keyData, "escape")) {
87
157
  if (this.applied !== this.opened) this.hooks.revert();
88
- this.hooks.done();
158
+ (this.hooks.cancel ?? this.hooks.done)();
89
159
  return;
90
160
  }
91
161
  if (matchesKey(keyData, "up") || matchesKey(keyData, "k")) {
@@ -103,37 +173,33 @@ export class ListPicker implements Component {
103
173
  const th = this.theme;
104
174
  const bold = th.bold ?? ((t: string) => `\x1b[1m${t}\x1b[22m`);
105
175
  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]!;
176
+ const edge = (text: string) => this.edge(text);
177
+ const column = Math.max(0, ...this.rows.map((r) => visibleWidth(r.label)));
178
+ const body: string[] = [];
179
+ for (let i = 0; i < this.rows.length; i++) {
180
+ const item = this.rows[i]!;
113
181
  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);
182
+ const paint = isSelected ? (s: string) => soft(bold(s)) : (s: string) => th.fg(item.note ? "text" : "muted", s);
183
+ const pad = " ".repeat(Math.max(0, column - visibleWidth(item.label)));
184
+ const note = item.note ? `${th.fg("dim", ROW_DIVIDER)}${th.fg("dim", item.note)}` : "";
185
+ // Extract parenthetical suffix to render softly
186
+ const suffix = item.note ? "" : /(\(.*\))$/.exec(item.label)?.[1] ?? "";
187
+ const head = suffix ? item.label.slice(0, item.label.length - suffix.length) : item.label;
119
188
  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));
189
+ const content = `${isSelected ? th.fg("accent", "→ ") : " "}${paint(head)}${tail}${pad}${note}`;
190
+ body.push(truncateToWidth(content, width - 4, ""));
122
191
  }
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, ""));
192
+ return renderScreen(th, width, this.text, body, edge);
127
193
  }
128
194
  }
129
195
 
130
196
  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",
197
+ title: screenTitle("Themes"),
198
+ description: "Selection previews live — same list as /settings → Theme.",
199
+ legend: LIST_LEGEND_LIVE,
134
200
  };
135
201
 
136
- const openThemes = async (ctx: ExtensionCommandContext): Promise<void> => {
202
+ const openThemes = async (ctx: ExtensionCommandContext): Promise<boolean | void> => {
137
203
  const themes = ctx.ui.getAllThemes();
138
204
  if (themes.length === 0) {
139
205
  ctx.ui.notify("[arnative] No themes found. Add files to ~/.pi/agent/themes/ and run /reload.", "warning");
@@ -142,10 +208,10 @@ const openThemes = async (ctx: ExtensionCommandContext): Promise<void> => {
142
208
  const names = themes.map((t) => t.name);
143
209
  const current = ctx.ui.theme.name;
144
210
 
145
- if (typeof ctx.ui.custom !== "function") {
146
- // No custom components (headless/RPC): fall back to the blocking picker.
211
+ if (ctx.mode !== "tui") {
212
+ // No custom components (RPC/headless): fall back to the blocking picker.
147
213
  const labels = markedLabels(names, current);
148
- const pick = await ctx.ui.select("Arnative · Themes", labels);
214
+ const pick = await ctx.ui.select(THEMES_PICKER_TEXT.title, labels);
149
215
  if (pick === undefined) return;
150
216
  const result = ctx.ui.setTheme(names[labels.indexOf(pick)]!);
151
217
  ctx.ui.notify(
@@ -155,8 +221,8 @@ const openThemes = async (ctx: ExtensionCommandContext): Promise<void> => {
155
221
  return;
156
222
  }
157
223
 
158
- await ctx.ui.custom<void>((tui, theme, _kb, done) =>
159
- new ListPicker(theme, names, current, {
224
+ const goBack = await ctx.ui.custom<boolean>((tui, theme, _kb, done) =>
225
+ new ListPicker(theme, nameRows(names), current, {
160
226
  preview: (name) => {
161
227
  const result = ctx.ui.setTheme(name);
162
228
  if (!result.success) ctx.ui.notify(`[arnative] ${result.error ?? `failed to apply ${name}`}`, "error");
@@ -164,25 +230,33 @@ const openThemes = async (ctx: ExtensionCommandContext): Promise<void> => {
164
230
  revert: () => {
165
231
  if (current) ctx.ui.setTheme(current);
166
232
  },
167
- done,
233
+ done: () => done(false),
234
+ back: () => {
235
+ // Back drops the live preview, then reports upward so the menu reopens.
236
+ // Guarded like `revert`: `theme.name` is optional, and `setTheme(undefined)`
237
+ // would reach pi's unguarded loader path.
238
+ if (current && ctx.ui.theme.name !== current) ctx.ui.setTheme(current);
239
+ done(true);
240
+ },
168
241
  requestRender: () => tui.requestRender(),
169
- }, THEMES_PICKER_TEXT),
242
+ }, THEMES_PICKER_TEXT, true, ctx.thinkingLevel),
170
243
  );
244
+ return goBack === true;
171
245
  };
172
246
 
173
247
  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",
248
+ title: screenTitle("Footers"),
249
+ description: "Selection applies live — custom footer or built-in.",
250
+ legend: LIST_LEGEND_LIVE,
177
251
  };
178
252
 
179
- const openFooters = async (ctx: ExtensionCommandContext): Promise<void> => {
253
+ const openFooters = async (ctx: ExtensionCommandContext): Promise<boolean | void> => {
180
254
  const opened = activeFooterPreset();
181
255
 
182
- if (typeof ctx.ui.custom !== "function") {
183
- // No custom components (headless/RPC): fall back to the blocking picker.
256
+ if (ctx.mode !== "tui") {
257
+ // No custom components (RPC/headless): fall back to the blocking picker.
184
258
  const labels = markedLabels(FOOTER_PRESETS, opened);
185
- const pick = await ctx.ui.select("Arnative · Footers", labels);
259
+ const pick = await ctx.ui.select(FOOTERS_PICKER_TEXT.title, labels);
186
260
  if (pick === undefined) return;
187
261
  const name = FOOTER_PRESETS[labels.indexOf(pick)]!;
188
262
  setFooterPreset(name, ctx);
@@ -190,35 +264,43 @@ const openFooters = async (ctx: ExtensionCommandContext): Promise<void> => {
190
264
  return;
191
265
  }
192
266
 
193
- await ctx.ui.custom<void>((tui, theme, _kb, done) =>
267
+ const goBack = await ctx.ui.custom<boolean>((tui, theme, _kb, done) =>
194
268
  new ListPicker(
195
269
  theme,
196
- [...FOOTER_PRESETS],
270
+ nameRows(FOOTER_PRESETS),
197
271
  opened,
198
272
  {
199
273
  preview: (name) => setFooterPreset(name as FooterPreset, ctx),
200
274
  revert: () => setFooterPreset(opened, ctx),
201
- done,
275
+ done: () => done(false),
276
+ back: () => {
277
+ // Back drops the live preview, then reports upward so the menu reopens.
278
+ if (activeFooterPreset() !== opened) setFooterPreset(opened, ctx);
279
+ done(true);
280
+ },
202
281
  requestRender: () => tui.requestRender(),
203
282
  },
204
283
  FOOTERS_PICKER_TEXT,
284
+ true,
285
+ ctx.thinkingLevel,
205
286
  ),
206
287
  );
288
+ return goBack === true;
207
289
  };
208
290
 
209
291
  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",
292
+ title: screenTitle("Headers"),
293
+ description: "Selection applies live — custom header or built-in.",
294
+ legend: LIST_LEGEND_LIVE,
213
295
  };
214
296
 
215
- const openHeaders = async (ctx: ExtensionCommandContext): Promise<void> => {
297
+ const openHeaders = async (ctx: ExtensionCommandContext): Promise<boolean | void> => {
216
298
  const opened = activeHeaderPreset();
217
299
 
218
- if (typeof ctx.ui.custom !== "function") {
219
- // No custom components (headless/RPC): fall back to the blocking picker.
300
+ if (ctx.mode !== "tui") {
301
+ // No custom components (RPC/headless): fall back to the blocking picker.
220
302
  const labels = markedLabels(HEADER_PRESETS, opened);
221
- const pick = await ctx.ui.select("Arnative · Headers", labels);
303
+ const pick = await ctx.ui.select(HEADERS_PICKER_TEXT.title, labels);
222
304
  if (pick === undefined) return;
223
305
  const name = HEADER_PRESETS[labels.indexOf(pick)]!;
224
306
  setHeaderPreset(name, ctx);
@@ -226,20 +308,71 @@ const openHeaders = async (ctx: ExtensionCommandContext): Promise<void> => {
226
308
  return;
227
309
  }
228
310
 
229
- await ctx.ui.custom<void>((tui, theme, _kb, done) =>
311
+ const goBack = await ctx.ui.custom<boolean>((tui, theme, _kb, done) =>
230
312
  new ListPicker(
231
313
  theme,
232
- [...HEADER_PRESETS],
314
+ nameRows(HEADER_PRESETS),
233
315
  opened,
234
316
  {
235
317
  preview: (name) => setHeaderPreset(name as HeaderPreset, ctx),
236
318
  revert: () => setHeaderPreset(opened, ctx),
237
- done,
319
+ done: () => done(false),
320
+ back: () => {
321
+ // Back drops the live preview, then reports upward so the menu reopens.
322
+ if (activeHeaderPreset() !== opened) setHeaderPreset(opened, ctx);
323
+ done(true);
324
+ },
238
325
  requestRender: () => tui.requestRender(),
239
326
  },
240
327
  HEADERS_PICKER_TEXT,
328
+ true,
329
+ ctx.thinkingLevel,
330
+ ),
331
+ );
332
+ return goBack === true;
333
+ };
334
+
335
+ /** Animations picker. Unlike Themes/Headers/Footers the choice has a visible effect only
336
+ * while a tool runs, so there is nothing to preview live on the screen itself — the row
337
+ * previews the frames instead, and Enter installs the preset for the next run. */
338
+ const openAnimations = async (ctx: ExtensionCommandContext): Promise<boolean | void> => {
339
+ const opened = activeSpinnerPreset();
340
+ // Each row spells its frames out (`Diamond Glow · diamond closing, 0.8s ⬥ ◈ ◆ ◇`), so the
341
+ // preview is what the user picks by; the rows come from animations.ts, one source.
342
+ const rows: ListRow[] = animationRows();
343
+
344
+ if (ctx.mode !== "tui") {
345
+ // No custom components (RPC/headless): fall back to the blocking picker.
346
+ const labels = markedLabels(SPINNER_PRESETS, opened);
347
+ const pick = await ctx.ui.select(ANIMATIONS_PICKER_TEXT.title, labels);
348
+ if (pick === undefined) return;
349
+ setSpinnerPreset(SPINNER_PRESETS[labels.indexOf(pick)]!);
350
+ ctx.ui.notify(`[arnative] Animation: ${SPINNER_PRESETS[labels.indexOf(pick)]}`, "info");
351
+ return;
352
+ }
353
+
354
+ const goBack = await ctx.ui.custom<boolean>((tui, theme, _kb, done) =>
355
+ new ListPicker(
356
+ theme,
357
+ rows,
358
+ opened,
359
+ {
360
+ preview: (name) => setSpinnerPreset(name as SpinnerPreset),
361
+ revert: () => setSpinnerPreset(opened),
362
+ done: () => done(false),
363
+ back: () => {
364
+ // Back restores the preset it opened with, then reports upward.
365
+ if (activeSpinnerPreset() !== opened) setSpinnerPreset(opened);
366
+ done(true);
367
+ },
368
+ requestRender: () => tui.requestRender(),
369
+ },
370
+ ANIMATIONS_PICKER_TEXT,
371
+ true,
372
+ ctx.thinkingLevel,
241
373
  ),
242
374
  );
375
+ return goBack === true;
243
376
  };
244
377
 
245
378
  export const MENUS: ArnativeMenu[] = [
@@ -261,26 +394,71 @@ export const MENUS: ArnativeMenu[] = [
261
394
  description: "choose the footer preset",
262
395
  open: openFooters,
263
396
  },
397
+ {
398
+ value: "animations",
399
+ label: "Animations",
400
+ description: "choose the working spinner",
401
+ open: openAnimations,
402
+ },
403
+ {
404
+ value: "usage",
405
+ label: "Usage",
406
+ description: "open the token & cost dashboard (same as /usage)",
407
+ open: openUsage,
408
+ },
264
409
  ];
265
410
 
411
+ /** The settings menu and the loop that gives `←` its meaning: a screen returning `true`
412
+ * reopens this menu, anything else ends navigation. `startLabel` is the row to open on,
413
+ * so a screen left with `←` (or a direct subcommand) resumes where the user was. */
414
+ async function openMainMenu(ctx: ExtensionCommandContext, startLabel?: string): Promise<void> {
415
+ const rows = menuRows(MENUS);
416
+ if (ctx.mode !== "tui") {
417
+ // No custom components (RPC/headless): a title-only picker, no description row.
418
+ const labels = rows.map(rowText);
419
+ const pick = await ctx.ui.select(MENU_TITLE, labels);
420
+ if (pick === undefined) return;
421
+ await MENUS[labels.indexOf(pick)]!.open(ctx);
422
+ return;
423
+ }
424
+ // Same picker as the submenus, so the description row under the title is rendered by
425
+ // one component instead of two dialogs that could drift apart.
426
+ let cursor = startLabel;
427
+ for (;;) {
428
+ const index = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
429
+ const picker = new ListPicker(theme, rows, cursor, {
430
+ preview: () => {},
431
+ revert: () => {},
432
+ done: () => done(picker.index),
433
+ cancel: () => done(null),
434
+ requestRender: () => tui.requestRender(),
435
+ }, { title: MENU_TITLE, description: MENU_DESC, legend: LIST_LEGEND_MOVE }, false, ctx.thinkingLevel);
436
+ return picker;
437
+ });
438
+ if (index === null || index === undefined) return;
439
+ cursor = rows[index]!.label;
440
+ const wantsBack = await MENUS[index]!.open(ctx);
441
+ if (wantsBack !== true) return;
442
+ }
443
+ }
444
+
266
445
  export async function arnativeHandler(args: string, ctx: ExtensionCommandContext): Promise<void> {
267
446
  if (!ctx.hasUI) return;
268
447
  const sub = args.trim().toLowerCase();
269
448
 
270
449
  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);
450
+ await openMainMenu(ctx);
275
451
  return;
276
452
  }
277
453
 
278
- const menu = MENUS.find((m) => m.value === sub);
279
- if (!menu) {
454
+ const index = MENUS.findIndex((m) => m.value === sub);
455
+ if (index === -1) {
280
456
  ctx.ui.notify(`[arnative] Unknown menu "${sub}". Menus: ${MENUS.map((m) => m.value).join(" | ")}`, "warning");
281
457
  return;
282
458
  }
283
- await menu.open(ctx);
459
+ // A direct subcommand behaves like a menu row: `←` drops into the menu on that row,
460
+ // so `/arnative usage` + `←` is not a dead key.
461
+ if ((await MENUS[index]!.open(ctx)) === true) await openMainMenu(ctx, MENUS[index]!.label);
284
462
  }
285
463
 
286
464
  export function arnativeCompletions(prefix: string): AutocompleteItem[] | null {
@@ -294,7 +472,7 @@ export function arnativeCompletions(prefix: string): AutocompleteItem[] | null {
294
472
 
295
473
  export default function (pi: ExtensionAPI) {
296
474
  pi.registerCommand("arnative", {
297
- description: "Arnative settings menu: themes, headers, footers",
475
+ description: "Arnative settings menu: themes, headers, footers, usage",
298
476
  getArgumentCompletions: arnativeCompletions,
299
477
  handler: arnativeHandler,
300
478
  });
@@ -305,9 +483,20 @@ if (isMain(import.meta.url)) {
305
483
  assert(markedLabels(["a", "b"], "b")[1] === "● b", "markedLabels marks the active theme");
306
484
  assert(markedLabels(["a", "b"], "b")[0] === " a", "markedLabels leaves others unmarked");
307
485
  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");
486
+ const labels = menuRows(MENUS);
487
+ assert(labels.length === MENUS.length, "menuRows one row per menu");
488
+ assert(rowText(labels[0]!) === "Themes · list & apply themes (the /settings → Theme list)", "menu row text format");
489
+ assert(labels[0]!.label === "Themes" && labels[0]!.note === "list & apply themes (the /settings → Theme list)", "menu row keeps label and note apart");
490
+ assert(MENU_TITLE === "Settings - Arnative\u2819", `the menu title is the pinned string, got ${MENU_TITLE}`);
491
+ assert(MENU_DESC === "Themes, headers, footers, animations, usage", `the menu description is the pinned string, got ${MENU_DESC}`);
492
+ // Every menu row is reachable and the description names them all, so a new screen cannot
493
+ // be added to MENUS and forgotten in the copy a user reads first.
494
+ assert(MENUS.length === 5, "five settings screens");
495
+ for (const label of ["Themes", "Headers", "Footers", "Animations", "Usage"]) {
496
+ assert(MENUS.some((m) => m.label === label), `MENUS carries the ${label} screen`);
497
+ }
498
+ assert(/^Settings - Arnative/.test(MENU_TITLE), "title follows `<setting> - <extension>`");
499
+ assert(MENU_TITLE.indexOf("\u2819") > 0 && !/\ufffd/.test(MENU_TITLE), "title carries the Braille glyph, not a blank/replacement cell");
311
500
  assert(MENUS.every((m) => typeof m.open === "function"), "every menu opens");
312
501
  assert(MENUS.some((m) => m.value === "headers"), "headers menu registered");
313
502
 
@@ -316,15 +505,19 @@ if (isMain(import.meta.url)) {
316
505
  assert(arnativeCompletions("he")![0]!.value === "headers", "headers menu has completions");
317
506
  assert(arnativeCompletions("fo")![0]!.value === "footers", "footers menu has completions");
318
507
  assert(MENUS.some((m) => m.value === "footers"), "footers menu registered");
508
+ // Usage is reachable from /arnative even though it is a dashboard, not a setting.
509
+ assert(MENUS.some((m) => m.value === "usage"), "usage menu registered");
510
+ assert(arnativeCompletions("us")![0]!.value === "usage", "usage menu has completions");
511
+ assert(typeof MENUS.find((m) => m.value === "usage")!.open === "function", "the usage row opens the dashboard");
319
512
  assert(arnativeCompletions("zz") === null, "completions null when nothing matches");
320
513
 
321
514
  // Live-preview picker (shared by Themes and Headers): move = preview, Enter = keep, Esc = revert.
322
515
  const fakeTheme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
323
- const pickerText: ListPickerText = { title: "Arnative · Themes", subtitle: "sub", footer: "[Esc] Revert" };
516
+ const pickerText: ListPickerText = { title: screenTitle("Themes"), description: "sub", legend: "[Esc] Revert" };
324
517
  const previews: string[] = [];
325
518
  let reverted = 0;
326
519
  let done = 0;
327
- const picker = new ListPicker(fakeTheme, ["arnative", "arnative-nord"], "arnative", {
520
+ const picker = new ListPicker(fakeTheme, nameRows(["arnative", "arnative-nord"]), "arnative", {
328
521
  preview: (n) => previews.push(n),
329
522
  revert: () => reverted++,
330
523
  done: () => done++,
@@ -333,31 +526,150 @@ if (isMain(import.meta.url)) {
333
526
  const lines = picker.render(50);
334
527
  assert(lines[0]!.startsWith("╭") && lines[lines.length - 1]!.startsWith("╰"), "picker framed");
335
528
  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");
529
+ assert(lines.some((l) => l.includes(screenTitle("Themes"))), "picker renders the configured title");
530
+ assert(lines.some((l) => l.includes("→ arnative")), "the cursor marks the live value");
531
+ assert(!lines.some((l) => l.includes("●")), "no redundant ● column next to the → cursor");
338
532
  picker.handleInput("\x1b[B"); // down
339
533
  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");
534
+ assert(picker.render(50).some((l) => l.includes("→ arnative-nord")), "the cursor follows the preview");
341
535
  picker.handleInput("\r"); // enter
342
536
  assert(done === 1 && reverted === 0, "Enter keeps (no revert)");
343
537
  picker.handleInput("\x1b[B");
344
538
  picker.handleInput("\x1b"); // escape
345
539
  assert(reverted === 1 && done === 2, "Esc reverts after preview");
346
540
 
541
+ // `←` reports upward instead of closing on a screen with a parent, reverting the live
542
+ // preview first; the root menu has nothing behind it and omits the key.
543
+ assert(LIST_LEGEND_MOVE === "[↑↓] Move [Enter] Open [Esc] Close", "menu legend pinned, and free of a dead back key");
544
+ assert(!LIST_LEGEND_MOVE.includes("←"), "the root menu does not advertise ←");
545
+ assert(LIST_LEGEND_LIVE === "[↑↓] Preview [Enter] Select [←] Back [Esc] Revert", "live legend pinned");
546
+ let backs = 0;
547
+ let backReverts = 0;
548
+ const childPicker = new ListPicker(fakeTheme, nameRows(["a", "b"]), "a", {
549
+ preview: () => {},
550
+ revert: () => backReverts++,
551
+ done: () => {},
552
+ back: () => backs++,
553
+ requestRender: () => {},
554
+ }, pickerText);
555
+ childPicker.handleInput("\x1b[D"); // ←
556
+ assert(backs === 1 && backReverts === 0, "← reports back to the parent and leaves the revert to it");
557
+ // The root menu has no parent, so ← must still leave the screen — never a dead key.
558
+ let rootCancels = 0;
559
+ const rootPicker = new ListPicker(fakeTheme, nameRows(["a"]), undefined, {
560
+ preview: () => {},
561
+ revert: () => {},
562
+ done: () => {},
563
+ cancel: () => rootCancels++,
564
+ requestRender: () => {},
565
+ }, pickerText, false);
566
+ rootPicker.handleInput("\x1b[D");
567
+ assert(rootCancels === 1, "← closes the root menu, which has no parent");
568
+
569
+ // The main menu reuses the picker with preview/revert as no-ops: moving changes no
570
+ // setting, only Enter opens a row. `reverted` proves it.
571
+ let menuReverts = 0;
572
+ let menuCancelled = false;
573
+ const mainPicker = new ListPicker(fakeTheme, labels, undefined, {
574
+ preview: () => {},
575
+ revert: () => { menuReverts++; },
576
+ done: () => {},
577
+ cancel: () => { menuCancelled = true; },
578
+ requestRender: () => {},
579
+ }, { title: MENU_TITLE, description: MENU_DESC, legend: LIST_LEGEND_MOVE }, false);
580
+ const menuLines = mainPicker.render(60);
581
+ assert(menuLines.some((l) => l.includes(MENU_TITLE)), "the main menu renders its title");
582
+ assert(menuLines.some((l) => l.includes(MENU_DESC)), "the main menu renders the description under the title");
583
+ assert(menuLines.findIndex((l) => l.includes(MENU_TITLE)) < menuLines.findIndex((l) => l.includes(MENU_DESC)), "description sits below the title");
584
+ assert(menuLines.every((l) => visibleWidth(l) === 60), "main menu lines fit the width");
585
+ // The pinned /arnative layout: `→ Themes · note`, every `·` in one column (skill rule 10).
586
+ const plainMenu = menuLines.map(stripAnsi);
587
+ const row0 = menuLines.map(stripAnsi).find((l) => l.includes("→ Themes"))!;
588
+ // The row is `│ → <label padded to the widest label><divider><note>`: assert the shape,
589
+ // not a literal run of spaces, so adding a wider screen cannot break the layout test.
590
+ const widest = Math.max(...MENUS.map((m) => m.label.length));
591
+ assert(
592
+ row0.startsWith(`│ → ${"Themes".padEnd(widest)}${ROW_DIVIDER}list & apply themes`),
593
+ `menu row layout, got ${JSON.stringify(row0)}`,
594
+ );
595
+ assert(plainMenu.filter((l) => l.includes(ROW_DIVIDER)).every((l) => l.indexOf(ROW_DIVIDER) === row0.indexOf(ROW_DIVIDER)), "every divider sits in one column");
596
+ assert(
597
+ plainMenu.some((l) => l.startsWith(`│ ${"Headers".padEnd(widest)}${ROW_DIVIDER}`)),
598
+ "unselected rows indent to the marker column",
599
+ );
600
+
601
+ // A menu opened with a `current` row starts there. The handler passes the row it last
602
+ // used, so a submenu reopened by `←` lands back on that row instead of jumping to the
603
+ // top of the list — the cursor is the user's place, not the component's business.
604
+ const resumed = new ListPicker(fakeTheme, labels, "Usage", {
605
+ preview: () => {},
606
+ revert: () => {},
607
+ done: () => {},
608
+ requestRender: () => {},
609
+ }, { title: MENU_TITLE, description: MENU_DESC, legend: LIST_LEGEND_MOVE }, false);
610
+ const resumedRows = resumed.render(60).map(stripAnsi);
611
+ assert(resumedRows.some((l) => l.startsWith("│ → Usage")), "the menu reopens on the row it was left on");
612
+ assert(!resumedRows.some((l) => l.startsWith("│ → Themes")), "and not on the first row");
613
+
614
+ // The box border follows the session's thinking level (theme `thinking*` slot), so the
615
+ // menu reads as belonging to the active model.
616
+ const tagged = { fg: (c: string, t: string) => `<${c}>${t}</>`, bold: (t: string) => `<bold>${t}</bold>` };
617
+ const thinkingPicker = new ListPicker(tagged, labels, undefined, {
618
+ preview: () => {},
619
+ revert: () => {},
620
+ done: () => {},
621
+ requestRender: () => {},
622
+ }, { title: MENU_TITLE, description: MENU_DESC, legend: "f" }, false, "high");
623
+ const taggedLines = thinkingPicker.render(200);
624
+ assert(taggedLines[0]!.includes("<thinkingHigh>"), "the menu border uses the session's thinking slot");
625
+ assert(taggedLines[taggedLines.length - 1]!.includes("<thinkingHigh>"), "the bottom border uses the thinking slot too");
626
+ assert(!taggedLines[0]!.includes("<accentSoft>"), "the border is not accentSoft any more");
627
+ // Every frame part must use the same painter: mixing soft walls with thinking rails made
628
+ // the frame two-toned (accentSoft `│` walls, thinking `╭─╮` rails).
629
+ const walls = taggedLines.filter((l) => l.startsWith("<thinkingHigh>│"));
630
+ assert(walls.length >= 5, `every wall is painted by the thinking painter, got ${walls.length}`);
631
+ assert(
632
+ !taggedLines.some((l) => l.startsWith("<accentSoft>│") || l.startsWith("<accentSoft>╭") || l.startsWith("<accentSoft>╰")),
633
+ "no frame glyph is left on accentSoft",
634
+ );
635
+ // The chosen row's label takes the `accentSoft` token (bold), never a hand-written colour; the
636
+ // `→` marker stays on `accent` so the cursor is still findable when the theme has no accentSoft.
637
+ assert(
638
+ taggedLines.some((l) => l.includes("<accent>→ </><accentSoft><bold>")),
639
+ "the chosen label is the accentSoft token in bold",
640
+ );
641
+ assert(taggedLines.some((l) => l.includes("<accent>→ ")), "the → marker keeps the accent voice");
642
+ const offPicker = new ListPicker(tagged, labels, undefined, {
643
+ preview: () => {},
644
+ revert: () => {},
645
+ done: () => {},
646
+ requestRender: () => {},
647
+ }, { title: MENU_TITLE, description: MENU_DESC, legend: "f" }, false, undefined);
648
+ assert(offPicker.render(200)[0]!.includes("<thinkingOff>"), "a missing level paints thinkingOff");
649
+ mainPicker.handleInput("\x1b[B");
650
+ assert(mainPicker.index === 1, "the selection is readable for the caller to open");
651
+ mainPicker.handleInput("\x1b[B");
652
+ assert(mainPicker.index === 2, "index tracks further moves");
653
+ assert(menuReverts === 0, "moving never reverts: the menu changes no setting");
654
+ // Esc closes the menu (cancel), it does not go through the keep path.
655
+ mainPicker.handleInput("\x1b");
656
+ assert(menuCancelled, "Esc cancels the main menu");
657
+ assert(menuReverts === 0, "cancel does not revert either");
658
+
347
659
  // Headers reuses the same picker with its own copy and presets.
348
660
  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);
661
+ const headerPicker = new ListPicker(fakeTheme, nameRows(HEADER_PRESETS), "Arnative (Full)", { preview: () => {}, revert: () => {}, done: () => {}, requestRender: () => {} }, HEADERS_PICKER_TEXT);
350
662
  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");
663
+ assert(headerLines.some((l) => l.includes(screenTitle("Headers"))), "headers picker title");
664
+ assert(headerLines.some((l) => l.includes("→ Arnative (Full)")), "headers picker cursors the active preset");
353
665
  assert(headerLines.some((l) => l.includes(" Pi (system)")), "headers picker lists the built-in preset");
354
666
 
355
667
  // Footers reuses the same picker too.
356
668
  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);
669
+ const footerPicker = new ListPicker(fakeTheme, nameRows(FOOTER_PRESETS), "Pi (system)", { preview: () => {}, revert: () => {}, done: () => {}, requestRender: () => {} }, FOOTERS_PICKER_TEXT);
358
670
  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");
671
+ assert(footerLines.some((l) => l.includes(screenTitle("Footers"))), "footers picker title");
672
+ assert(footerLines.some((l) => l.includes("→ Pi (system)")), "footers picker cursors the active preset");
361
673
  assert(footerLines.some((l) => l.includes("Arnative (Minimal)")), "footers picker lists the minimal preset");
362
674
  assert(footerLines.filter((l) => l.includes("Arnative (")).length === 2, "both arnative footer presets are selectable");
363
675