@bismawy/pi-arnative 0.3.10 → 0.5.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 +54 -0
- package/README.md +59 -45
- package/extensions/animations.ts +213 -0
- package/extensions/arnative.ts +422 -99
- package/extensions/footer.ts +327 -65
- package/extensions/section-headers.ts +35 -17
- package/extensions/timestamps.ts +1 -1
- package/extensions/tools.ts +31 -12
- package/extensions/ui-render-tweaks.ts +36 -4
- package/extensions/usage.ts +483 -89
- package/lib/ansi.ts +89 -6
- package/lib/box.ts +90 -5
- package/lib/shortcuts.ts +62 -13
- package/lib/spinner.ts +270 -0
- package/lib/usage-store.ts +96 -4
- package/package.json +4 -3
- package/themes/arnative-cyberpunk.json +34 -33
- package/themes/arnative-dracula.json +26 -25
- package/themes/arnative-emerald.json +6 -5
- package/themes/arnative-gruvbox.json +26 -25
- package/themes/arnative-matrix.json +22 -21
- package/themes/arnative-nord.json +23 -22
- package/themes/arnative-sun.json +7 -6
- package/themes/arnative-synthwave.json +34 -33
- package/themes/arnative-violet.json +8 -7
- package/themes/arnative-zinc.json +6 -5
- package/themes/arnative.json +7 -6
- package/themes/gen-themes.mjs +121 -52
package/extensions/arnative.ts
CHANGED
|
@@ -1,33 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Arnative settings menu (/arnative <menu>)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* /
|
|
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 {
|
|
13
|
-
import {
|
|
10
|
+
import { softOf, 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
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
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 ("
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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")) {
|
|
@@ -102,38 +172,34 @@ export class ListPicker implements Component {
|
|
|
102
172
|
render(width: number): string[] {
|
|
103
173
|
const th = this.theme;
|
|
104
174
|
const bold = th.bold ?? ((t: string) => `\x1b[1m${t}\x1b[22m`);
|
|
105
|
-
const soft =
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
for (let i = 0; i < this.names.length; i++) {
|
|
112
|
-
const name = this.names[i]!;
|
|
175
|
+
const soft = softOf(th);
|
|
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
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
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 = /(\(.*\))$/.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
|
-
|
|
121
|
-
|
|
189
|
+
const content = `${isSelected ? th.fg("accent", "→ ") : " "}${paint(head)}${tail}${pad}${note}`;
|
|
190
|
+
body.push(truncateToWidth(content, width - 4, ""));
|
|
122
191
|
}
|
|
123
|
-
|
|
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: "
|
|
132
|
-
|
|
133
|
-
|
|
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 (
|
|
146
|
-
// No custom components (headless
|
|
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(
|
|
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<
|
|
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: "
|
|
175
|
-
|
|
176
|
-
|
|
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 (
|
|
183
|
-
// No custom components (headless
|
|
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(
|
|
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<
|
|
267
|
+
const goBack = await ctx.ui.custom<boolean>((tui, theme, _kb, done) =>
|
|
194
268
|
new ListPicker(
|
|
195
269
|
theme,
|
|
196
|
-
|
|
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: "
|
|
211
|
-
|
|
212
|
-
|
|
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 (
|
|
219
|
-
// No custom components (headless
|
|
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(
|
|
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<
|
|
311
|
+
const goBack = await ctx.ui.custom<boolean>((tui, theme, _kb, done) =>
|
|
230
312
|
new ListPicker(
|
|
231
313
|
theme,
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
279
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
309
|
-
assert(labels.length === MENUS.length, "
|
|
310
|
-
assert(labels[0] === "Themes
|
|
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: "
|
|
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,33 +526,163 @@ 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("
|
|
337
|
-
assert(lines.some((l) => l.includes("
|
|
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("
|
|
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,
|
|
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("
|
|
352
|
-
assert(headerLines.some((l) => l.includes("
|
|
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,
|
|
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("
|
|
360
|
-
assert(footerLines.some((l) => l.includes("
|
|
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
|
|
|
676
|
+
// Animations picker: "Pi (system)" carries a note AND renders "(system)" in dim
|
|
677
|
+
const animPicker = new ListPicker(
|
|
678
|
+
{ fg: (c, t) => `<${c}>${t}</${c}>`, bold: (t) => `<bold>${t}</bold>` },
|
|
679
|
+
animationRows(),
|
|
680
|
+
"Pi (system)",
|
|
681
|
+
{ preview: () => {}, revert: () => {}, done: () => {}, requestRender: () => {} },
|
|
682
|
+
ANIMATIONS_PICKER_TEXT,
|
|
683
|
+
);
|
|
684
|
+
const animLines = animPicker.render(120);
|
|
685
|
+
assert(animLines.some((l) => l.includes("Pi ") && l.includes("<dim>(system)</dim>")), "animations picker dims (system)");
|
|
686
|
+
|
|
364
687
|
console.log("arnative.ts self-check OK");
|
|
365
688
|
}
|