@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/lib/ansi.ts
CHANGED
|
@@ -38,15 +38,70 @@ export function setActiveTheme<T>(th: T | null): void {
|
|
|
38
38
|
else delete (globalThis as Record<symbol, T>)[THEME_PROXY_KEY];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Painter for arnative's soft accent (`accentSoft`).
|
|
43
|
+
*
|
|
44
|
+
* Pi <=1.1.0 let a theme declare extra tokens in `colors`, so `accentSoft` was
|
|
45
|
+
* addressable directly. 1.2.0 validates `colors` with `additionalProperties: false`
|
|
46
|
+
* and rejects the whole theme if it carries one, so the token now lives only in
|
|
47
|
+
* `vars` and `fg("accentSoft")` throws. The soft accent stays available through the
|
|
48
|
+
* resolved `accent` color (1.2.0 exposes `theme.colors`): its hue and chroma are
|
|
49
|
+
* softened to the generator's `*Soft` recipe, which keeps the two-tone look instead
|
|
50
|
+
* of collapsing to grey. Falls back to `muted`, then `accent`, on any older theme.
|
|
51
|
+
*/
|
|
52
|
+
export function softOf(th: {
|
|
53
|
+
fg?(c: string, t: string): string;
|
|
54
|
+
style?(t: string, o: { fg: unknown }): string;
|
|
55
|
+
colors?: Record<string, { kind?: string; c?: number; h?: number }>;
|
|
56
|
+
} | null): (s: string) => string {
|
|
43
57
|
const fg = th?.fg?.bind(th);
|
|
44
58
|
if (!fg) return (s) => s;
|
|
45
59
|
try {
|
|
46
60
|
fg("accentSoft", "");
|
|
47
61
|
return (s) => fg("accentSoft", s);
|
|
48
62
|
} catch {
|
|
49
|
-
|
|
63
|
+
// 1.2.0+ rejects `accentSoft`: derive the soft tint from the resolved accent.
|
|
64
|
+
}
|
|
65
|
+
const accent = th?.colors?.accent;
|
|
66
|
+
const style = th?.style?.bind(th);
|
|
67
|
+
if (accent?.kind === "oklch" && style) {
|
|
68
|
+
// The generator's `*Soft` recipe: lightness 0.82, a fifth of the accent chroma.
|
|
69
|
+
const soft = { kind: "oklch" as const, l: 0.82, c: (accent.c ?? 0) * 0.18, h: accent.h ?? 0 };
|
|
70
|
+
return (s) => style(s, { fg: soft });
|
|
71
|
+
}
|
|
72
|
+
return (s) => fg("muted", s);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** pi defines one theme slot per thinking level (`thinkingOff`…`thinkingMax`), so a box
|
|
76
|
+
* can follow the model instead of a hardcoded palette — switching theme keeps the
|
|
77
|
+
* meaning of the color. Shared by `usage.ts` and `arnative.ts`. */
|
|
78
|
+
const THINKING_SLOTS: Record<string, string> = {
|
|
79
|
+
minimal: "thinkingMinimal",
|
|
80
|
+
low: "thinkingLow",
|
|
81
|
+
medium: "thinkingMedium",
|
|
82
|
+
high: "thinkingHigh",
|
|
83
|
+
xhigh: "thinkingXhigh",
|
|
84
|
+
max: "thinkingMax",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function thinkingColorSlot(level: string | undefined): string {
|
|
88
|
+
return THINKING_SLOTS[(level ?? "off").toLowerCase()] ?? "thinkingOff";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Painter for a thinking level, probed once; falls back to `dim` when the theme lacks
|
|
92
|
+
* the slot, so a non-arnative theme draws a plain border instead of throwing. */
|
|
93
|
+
export function thinkingColorOf(
|
|
94
|
+
th: { fg?(c: string, t: string): string } | null,
|
|
95
|
+
level: string | undefined,
|
|
96
|
+
): (s: string) => string {
|
|
97
|
+
const fg = th?.fg?.bind(th);
|
|
98
|
+
if (!fg) return (s) => s;
|
|
99
|
+
const slot = thinkingColorSlot(level);
|
|
100
|
+
try {
|
|
101
|
+
fg(slot, "");
|
|
102
|
+
return (s) => fg(slot, s);
|
|
103
|
+
} catch {
|
|
104
|
+
return (s) => fg("dim", s);
|
|
50
105
|
}
|
|
51
106
|
}
|
|
52
107
|
|
|
@@ -65,14 +120,42 @@ if (isMain(import.meta.url)) {
|
|
|
65
120
|
assert(getActiveTheme<object>() === null, "clearing the active theme leaves no stale slot");
|
|
66
121
|
|
|
67
122
|
const tag = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
|
|
68
|
-
assert(
|
|
123
|
+
assert(softOf(tag)("x") === "<accentSoft>x</accentSoft>", "softOf uses accentSoft when the theme defines it");
|
|
69
124
|
const noSoft = {
|
|
70
125
|
fg: (c: string, t: string) => {
|
|
71
126
|
if (c === "accentSoft") throw new Error("Unknown theme color: accentSoft");
|
|
72
127
|
return `<${c}>${t}</${c}>`;
|
|
73
128
|
},
|
|
74
129
|
};
|
|
75
|
-
assert(
|
|
76
|
-
assert(
|
|
130
|
+
assert(softOf(noSoft)("x") === "<muted>x</muted>", "softOf falls back to muted without the token or colors");
|
|
131
|
+
assert(softOf(null)("x") === "x", "softOf without a theme is identity");
|
|
132
|
+
// Pi 1.2.0: acentSoft is rejected, so the soft tint is derived from `colors.accent`.
|
|
133
|
+
const modern = {
|
|
134
|
+
fg: (c: string, t: string) => {
|
|
135
|
+
throw new Error(`Unknown theme color: ${c}`);
|
|
136
|
+
},
|
|
137
|
+
style: (t: string, o: { fg: unknown }) => `<soft:${JSON.stringify(o.fg)}>${t}</soft>`,
|
|
138
|
+
colors: { accent: { kind: "oklch", c: 0.2, h: 355 } },
|
|
139
|
+
};
|
|
140
|
+
const modernOut = softOf(modern)("x");
|
|
141
|
+
assert(modernOut.includes('"l":0.82') && modernOut.includes('"c":0.036'), "softOf derives the soft tint from accent (l=0.82, c=accent*0.18)");
|
|
142
|
+
assert(modernOut.includes("x"), "softOf keeps the text");
|
|
143
|
+
|
|
144
|
+
assert(thinkingColorSlot("off") === "thinkingOff", "thinking off maps to thinkingOff");
|
|
145
|
+
assert(thinkingColorSlot(undefined) === "thinkingOff", "missing level falls back to thinkingOff");
|
|
146
|
+
assert(thinkingColorSlot("HIGH") === "thinkingHigh", "matching is case-insensitive");
|
|
147
|
+
assert(thinkingColorSlot("xhigh") === "thinkingXhigh", "xhigh has its own slot");
|
|
148
|
+
assert(thinkingColorSlot("max") === "thinkingMax", "max maps to thinkingMax");
|
|
149
|
+
assert(thinkingColorSlot("nonsense") === "thinkingOff", "an unknown level degrades to thinkingOff");
|
|
150
|
+
assert(thinkingColorOf(tag, "high")("x") === "<thinkingHigh>x</thinkingHigh>", "thinkingColorOf paints the level's slot");
|
|
151
|
+
assert(thinkingColorOf(tag, undefined)("x") === "<thinkingOff>x</thinkingOff>", "thinkingColorOf defaults to thinkingOff");
|
|
152
|
+
assert(thinkingColorOf(null, "high")("x") === "x", "thinkingColorOf without a theme is identity");
|
|
153
|
+
const noThinking = {
|
|
154
|
+
fg: (c: string, t: string) => {
|
|
155
|
+
if (c.startsWith("thinking")) throw new Error(`Unknown theme color: ${c}`);
|
|
156
|
+
return `<${c}>${t}</${c}>`;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
assert(thinkingColorOf(noThinking, "max")("x") === "<dim>x</dim>", "thinkingColorOf falls back to dim without the slot");
|
|
77
160
|
console.log("lib/ansi.ts OK");
|
|
78
161
|
}
|
package/lib/box.ts
CHANGED
|
@@ -3,19 +3,23 @@
|
|
|
3
3
|
* and usage.ts — edit here, all follow. Lives outside `extensions/*.ts` so pi
|
|
4
4
|
* never loads it as an extension.
|
|
5
5
|
*/
|
|
6
|
-
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
6
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
+
import { thinkingColorOf } from "./ansi.ts";
|
|
7
8
|
import { assert, isMain } from "./check.ts";
|
|
8
9
|
|
|
9
|
-
export type BoxTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string };
|
|
10
|
+
export type BoxTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string; bold?(text: string): string };
|
|
10
11
|
type Dim = (s: string) => string;
|
|
11
12
|
|
|
12
13
|
export const boxEdge = (l: string, r: string, width: number, dim: Dim): string =>
|
|
13
14
|
dim(`${l}${"─".repeat(Math.max(0, width - 2))}${r}`);
|
|
14
15
|
|
|
15
|
-
/** Content row: │ <text> │, exactly `width` wide. */
|
|
16
|
+
/** Content row: │ <text> │, exactly `width` wide. Content is truncated to preserve border. */
|
|
16
17
|
export const boxRow = (content: string, width: number, dim: Dim): string => {
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Minimum 4 columns required for borders and padding (skill arnative-pi §6 rule 5).
|
|
19
|
+
if (width < 4) return "";
|
|
20
|
+
const fitted = truncateToWidth(content, width - 4, "");
|
|
21
|
+
const pad = Math.max(0, width - visibleWidth(fitted) - 4);
|
|
22
|
+
return `${dim("│")} ${fitted}${" ".repeat(pad)} ${dim("│")}`;
|
|
19
23
|
};
|
|
20
24
|
|
|
21
25
|
const applyBg = (line: string, width: number, bgFn: (text: string) => string): string => {
|
|
@@ -23,6 +27,50 @@ const applyBg = (line: string, width: number, bgFn: (text: string) => string): s
|
|
|
23
27
|
return bgFn(line + " ".repeat(pad));
|
|
24
28
|
};
|
|
25
29
|
|
|
30
|
+
/** What every arnative screen says about itself. `title` follows `<setting> - <extension>`
|
|
31
|
+
* (see {@link screenTitle}); `description` is the one-liner under it. */
|
|
32
|
+
export interface ScreenText {
|
|
33
|
+
title: string;
|
|
34
|
+
description: string;
|
|
35
|
+
/** Keybind legend, drawn as the last row above the bottom edge. */
|
|
36
|
+
legend: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `Arnative⠙` — the braille mark that brands every arnative screen. */
|
|
40
|
+
export const BRAND = "\u2819";
|
|
41
|
+
|
|
42
|
+
/** The one title shape for an arnative screen: `<setting> - <extension>⠙`. */
|
|
43
|
+
export function screenTitle(setting: string, extension = `Arnative${BRAND}`): string {
|
|
44
|
+
return `${setting} - ${extension}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The chrome every arnative screen shares: frame, title in `accent`+bold, description in
|
|
48
|
+
* `muted`, blank, the screen's `body`, blank, legend in `dim`. One function so the screens
|
|
49
|
+
* cannot drift apart; `body` rows arrive already painted and truncated. */
|
|
50
|
+
export function renderScreen(
|
|
51
|
+
theme: BoxTheme | null,
|
|
52
|
+
width: number,
|
|
53
|
+
text: ScreenText,
|
|
54
|
+
body: string[],
|
|
55
|
+
/** Border painter. Defaults to the thinking slot (skill arnative-pi §6 rule 1: the frame
|
|
56
|
+
* follows the session's thinking level); pass the real level so the box tracks the model. */
|
|
57
|
+
border: Dim = thinkingColorOf(theme, undefined),
|
|
58
|
+
): string[] {
|
|
59
|
+
const fg = (color: string, s: string) => (theme ? theme.fg(color, s) : s);
|
|
60
|
+
const bold = theme?.bold?.bind(theme) ?? ((s: string) => s);
|
|
61
|
+
const row = (content: string) => boxRow(content, width, border);
|
|
62
|
+
return [
|
|
63
|
+
boxEdge("╭", "╮", width, border),
|
|
64
|
+
row(fg("accent", bold(text.title))),
|
|
65
|
+
row(fg("muted", text.description)),
|
|
66
|
+
row(""),
|
|
67
|
+
...body.map(row),
|
|
68
|
+
row(""),
|
|
69
|
+
row(fg("dim", text.legend)),
|
|
70
|
+
boxEdge("╰", "╯", width, border),
|
|
71
|
+
].map((l) => truncateToWidth(l, width, ""));
|
|
72
|
+
}
|
|
73
|
+
|
|
26
74
|
/** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color.
|
|
27
75
|
* `theme` may be null: a box is patched in at module load, before `session_start`
|
|
28
76
|
* delivers the active theme. An uncolored box for that first frame beats a null
|
|
@@ -53,8 +101,45 @@ if (isMain(import.meta.url)) {
|
|
|
53
101
|
assert(plain[1] === "│ hello │", "content line padded flush");
|
|
54
102
|
assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "long line wrapped");
|
|
55
103
|
assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg ignored when the theme has no bg");
|
|
104
|
+
// A row wider than the box must lose its own tail, never its right wall: `boxRow` used to
|
|
105
|
+
// pad only, so the wall overflowed `width` and the caller's `truncateToWidth` cut the wall.
|
|
106
|
+
const overlong = boxRow("x".repeat(60), 20, (s) => s);
|
|
107
|
+
assert(visibleWidth(overlong) === 20, `an overlong row is still exactly the width, got ${visibleWidth(overlong)}`);
|
|
108
|
+
assert(overlong.endsWith("│"), `an overlong row keeps its right wall, got ${JSON.stringify(overlong)}`);
|
|
109
|
+
assert(!overlong.includes("x".repeat(17)), "an overlong row truncates its own content");
|
|
56
110
|
const nullTheme = renderBoxLines(null, 24, ["hello"], "customMessageBg");
|
|
57
111
|
assert(nullTheme.every((l) => visibleWidth(l) === 24), "null theme renders an uncolored box instead of throwing");
|
|
58
112
|
assert(nullTheme[0]!.startsWith("╭") && nullTheme[1] === "│ hello │", "null-theme frame and padding intact");
|
|
113
|
+
// Rule 5: too narrow for `│ │`, a row is empty rather than 4 columns wide — pi may hand a
|
|
114
|
+
// component the width of a sidebar, and a 4-wide row would overflow it.
|
|
115
|
+
for (const w of [1, 2, 3]) assert(boxRow("hello", w, (s) => s) === "", `a row narrower than four columns is empty at width ${w}`);
|
|
116
|
+
|
|
117
|
+
// The shared screen chrome: one sequence for every arnative screen.
|
|
118
|
+
assert(screenTitle("Settings") === `Settings - Arnative${BRAND}`, "screenTitle pins the <setting> - <extension> shape");
|
|
119
|
+
assert(screenTitle("Usage") === "Usage - Arnative\u2819", "the brand is the braille mark, not a Nerd Font glyph");
|
|
120
|
+
const screen = renderScreen(th, 60, { title: "Usage - Arnative\u2819", description: "Token & cost", legend: "[Esc] Close" }, ["a row", ""]);
|
|
121
|
+
assert(screen[0]!.startsWith("╭") && screen[screen.length - 1]!.startsWith("╰"), "screen frame intact");
|
|
122
|
+
assert(screen.every((l) => visibleWidth(l) === 60), "every screen line is exactly the width");
|
|
123
|
+
assert(screen[1]!.includes("Usage - Arnative\u2819"), "the title sits on the first row inside the frame");
|
|
124
|
+
assert(screen[2]!.includes("Token & cost"), "the description sits under the title");
|
|
125
|
+
assert(screen[screen.length - 2]!.includes("[Esc] Close"), "the legend is the last row above the bottom edge");
|
|
126
|
+
// No full-width rule inside the frame (skill rule 7): the chrome is the whole chrome.
|
|
127
|
+
assert(!screen.some((l) => /[├┤]/.test(l)), "the shared chrome draws no inner rule");
|
|
128
|
+
const taggedBox = { fg: (c: string, t: string) => `<${c}>${t}</>`, bold: (t: string) => `<bold>${t}</bold>` };
|
|
129
|
+
const taggedScreen = renderScreen(taggedBox, 60, { title: "T", description: "d", legend: "l" }, []);
|
|
130
|
+
assert(taggedScreen[1]!.includes("<accent><bold>T"), "the title is accent + bold");
|
|
131
|
+
assert(taggedScreen[2]!.includes("<muted>d"), "the description is muted");
|
|
132
|
+
assert(taggedScreen[taggedScreen.length - 2]!.includes("<dim>l"), "the legend is dim");
|
|
133
|
+
assert(renderScreen(null, 40, { title: "t", description: "d", legend: "l" }, ["x"]).every((l) => visibleWidth(l) === 40), "a null theme still frames the screen");
|
|
134
|
+
// The header copy is longer than a narrow terminal: the wall must win over the text.
|
|
135
|
+
const longCopy = "Selection previews live — same list as /settings → Theme.";
|
|
136
|
+
for (const w of [40, 50, 56, 60]) {
|
|
137
|
+
const narrow = renderScreen(th, w, { title: screenTitle("Themes"), description: longCopy, legend: "[Esc] Revert" }, []);
|
|
138
|
+
assert(narrow.every((l) => visibleWidth(l) === w), `a long description keeps every line at width ${w}`);
|
|
139
|
+
assert(
|
|
140
|
+
narrow.filter((l) => l.startsWith("│")).every((l) => l.endsWith("│")),
|
|
141
|
+
`a long description keeps the right wall at width ${w}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
59
144
|
console.log("lib/box.ts OK");
|
|
60
145
|
}
|
package/lib/shortcuts.ts
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
|
-
// Platform-
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
// PR #20052), so the legacy "ESC + control byte" pair (ctrl+alt+r = \x1b\x12) that
|
|
5
|
-
// matchesKey() looks for is never emitted — the keystroke dies inside the terminal.
|
|
6
|
-
// A WSL pane inherits that same input translation, so it counts as Windows-like too,
|
|
7
|
-
// mirroring pi's own useWindowsKeybindings() (not exported from the package root).
|
|
8
|
-
// pi falls back to alt+<letter> for this class of problem (app.clipboard.pasteImage ->
|
|
9
|
-
// alt+v, app.model.cycleBackward -> alt+p) and every Windows terminal prefixes
|
|
10
|
-
// alt+<letter> with ESC (libuv win/tty.c), so do the same here. macOS keeps ctrl+alt
|
|
11
|
-
// only: plain Option there composes characters (Option+r = ®) unless "Use Option as
|
|
12
|
-
// Meta key" is enabled, while ctrl+alt transmits in every macOS terminal.
|
|
13
|
-
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
1
|
+
// Platform-aware single-letter shortcuts. Windows/WSL uses Alt+<char> to avoid AltGr
|
|
2
|
+
// aliasing; macOS displays Option; other platforms use Ctrl+Alt.
|
|
3
|
+
import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
|
|
14
4
|
import { assert, isMain } from "./check.ts";
|
|
15
5
|
|
|
16
6
|
/** Windows Terminal's AltGr aliasing hits native Windows and WSL panes alike. */
|
|
@@ -46,6 +36,44 @@ export const SHORTCUT_RELOAD = build("r");
|
|
|
46
36
|
* (same platform split as the shortcuts above). */
|
|
47
37
|
export const SHORTCUT_TOGGLE_FOOTER = build("m");
|
|
48
38
|
|
|
39
|
+
/** The one "go back" key: `←` leaves a screen for its parent, while Esc closes or reverts
|
|
40
|
+
* it. Defined here so no screen can advertise a key another screen does not handle. */
|
|
41
|
+
export const BACK_KEY_LABEL = "←";
|
|
42
|
+
export function matchesBack(data: string): boolean {
|
|
43
|
+
return matchesKey(data, "left");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A key a screen advertises: its label plus the matcher the handler acts on. A legend
|
|
47
|
+
* built from these cannot drift from the handler — the same `matches` decides both. */
|
|
48
|
+
export interface KeySet {
|
|
49
|
+
label: string;
|
|
50
|
+
matches: (data: string) => boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const keySet = (label: string, ...keyIds: string[]): KeySet => ({
|
|
54
|
+
label,
|
|
55
|
+
matches: (data) => keyIds.some((keyId) => matchesKey(data, keyId)),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** One label for the pair — the handler moves on either arrow. */
|
|
59
|
+
const MOVE = keySet("↑↓", "up", "down");
|
|
60
|
+
const CHOOSE = keySet("Enter", "enter");
|
|
61
|
+
const BACK = keySet(BACK_KEY_LABEL, "left");
|
|
62
|
+
const CANCEL = keySet("Esc", "escape");
|
|
63
|
+
const QUIT = keySet("q/Esc", "q", "escape");
|
|
64
|
+
|
|
65
|
+
const legend = (...pairs: [string, string][]): string => pairs.map(([key, action]) => `[${key}] ${action}`).join(" ");
|
|
66
|
+
|
|
67
|
+
/** Root menu: move and open. No `[←]` — the menu has no screen behind it. */
|
|
68
|
+
export const LEGEND_MOVE = legend([MOVE.label, "Move"], [CHOOSE.label, "Open"], [CANCEL.label, "Close"]);
|
|
69
|
+
/** Live-preview list: moving applies, Enter keeps, `←` steps out, Esc reverts. */
|
|
70
|
+
export const LEGEND_LIVE = legend([MOVE.label, "Preview"], [CHOOSE.label, "Select"], [BACK.label, "Back"], [CANCEL.label, "Revert"]);
|
|
71
|
+
/** The read-only usage dashboard. The full legend needs the room, so a narrow box degrades. */
|
|
72
|
+
export function legendUsage(width: number): string {
|
|
73
|
+
const full = legend([MOVE.label, "Move"], ["Click", "Open/close"], [BACK.label, "Back"], [QUIT.label, "Exit"]);
|
|
74
|
+
return width >= visibleWidth(full) ? full : legend([QUIT.label, "Exit"]);
|
|
75
|
+
}
|
|
76
|
+
|
|
49
77
|
if (isMain(import.meta.url)) {
|
|
50
78
|
assert(SHORTCUT_RELOAD.keys[0] === Key.ctrlAlt("r"), "ctrl+alt+r is registered on every platform");
|
|
51
79
|
assert(
|
|
@@ -72,5 +100,26 @@ if (isMain(import.meta.url)) {
|
|
|
72
100
|
"alt+m is registered only where Ctrl+Alt is aliased away",
|
|
73
101
|
);
|
|
74
102
|
assert(!SHORTCUT_TOGGLE_FOOTER.matches("\x1b\x12"), "ctrl+alt+r does not match the footer toggle");
|
|
103
|
+
assert(matchesBack("\x1b[D"), "← (CSI) matches the back key");
|
|
104
|
+
assert(matchesBack("\x1bOD"), "← (SS3, application cursor mode) matches the back key");
|
|
105
|
+
assert(!matchesBack("\x1b[C"), "→ is not the back key");
|
|
106
|
+
assert(!matchesBack("\x1b"), "Esc is not the back key: Esc closes, ← steps back");
|
|
107
|
+
assert(!matchesBack("h"), "a bare letter is not the back key");
|
|
108
|
+
// The legends are built from the same keys the handlers match, so a key cannot be
|
|
109
|
+
// advertised on one screen and handled on none.
|
|
110
|
+
assert(LEGEND_MOVE === "[↑↓] Move [Enter] Open [Esc] Close", `root menu legend pinned, got ${LEGEND_MOVE}`);
|
|
111
|
+
assert(!LEGEND_MOVE.includes(BACK_KEY_LABEL), "the root menu does not advertise ←");
|
|
112
|
+
assert(
|
|
113
|
+
LEGEND_LIVE === `[↑↓] Preview [Enter] Select [${BACK_KEY_LABEL}] Back [Esc] Revert`,
|
|
114
|
+
`live legend pinned, got ${LEGEND_LIVE}`,
|
|
115
|
+
);
|
|
116
|
+
assert(MOVE.matches("\x1b[A") && MOVE.matches("\x1b[B"), "the ↑↓ key matches both arrows");
|
|
117
|
+
assert(CHOOSE.matches("\r"), "[Enter] matches carriage return");
|
|
118
|
+
assert(BACK.matches("\x1b[D"), "[←] in the legend is the same key matchesBack accepts");
|
|
119
|
+
assert(CANCEL.matches("\x1b"), "[Esc] matches escape");
|
|
120
|
+
assert(!CANCEL.matches("\x1b[D"), "[Esc] is not the back key");
|
|
121
|
+
assert(legendUsage(120).includes(`[${BACK_KEY_LABEL}] Back`), "a wide usage legend advertises back");
|
|
122
|
+
assert(legendUsage(120).includes("[Click] Open/close"), "a wide usage legend advertises the mouse");
|
|
123
|
+
assert(legendUsage(20) === "[q/Esc] Exit", "a narrow usage legend degrades to the one key that still fits");
|
|
75
124
|
console.log("shortcuts.ts self-check OK");
|
|
76
125
|
}
|
package/lib/spinner.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working-indicator presets for `/arnative` → **Animations** and their painters.
|
|
3
|
+
* Single source of truth for frames, cadence, and colours.
|
|
4
|
+
*/
|
|
5
|
+
import { assert, isMain } from "./check.ts";
|
|
6
|
+
|
|
7
|
+
export const SPINNER_PRESETS = [
|
|
8
|
+
"Pi (system)",
|
|
9
|
+
"Diamond Glow",
|
|
10
|
+
"Diamond Pulse",
|
|
11
|
+
"Circle Pulse",
|
|
12
|
+
"Circle Wave",
|
|
13
|
+
"Shade Wave",
|
|
14
|
+
"Braille Column",
|
|
15
|
+
"Braille Level",
|
|
16
|
+
"Kanji Count",
|
|
17
|
+
"Suzhou Tally",
|
|
18
|
+
"Gemini",
|
|
19
|
+
"Claude",
|
|
20
|
+
] as const;
|
|
21
|
+
export type SpinnerPreset = (typeof SPINNER_PRESETS)[number];
|
|
22
|
+
export const DEFAULT_SPINNER_PRESET: SpinnerPreset = "Diamond Glow";
|
|
23
|
+
|
|
24
|
+
/** Persistence key in `arnative-config.json` (see `lib/preset-store.ts`). */
|
|
25
|
+
export const SPINNER_PRESET_KEY = "spinnerPreset";
|
|
26
|
+
|
|
27
|
+
interface Preset {
|
|
28
|
+
/** Animation frames. Empty leaves pi's indicator alone. */
|
|
29
|
+
frames: readonly string[];
|
|
30
|
+
/** Frame delay in ms. `0` keeps pi's interval. */
|
|
31
|
+
intervalMs: number;
|
|
32
|
+
/** How the frame is painted. */
|
|
33
|
+
paint: "accent" | "gradient" | "claude";
|
|
34
|
+
/** Shown in the picker row after `·`. */
|
|
35
|
+
note: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Claude Code mark animation frames, adapting to Ghostty and non-macOS font fallbacks. */
|
|
39
|
+
function defaultClaudeCharacters(): readonly string[] {
|
|
40
|
+
if (process.env.TERM === "xterm-ghostty") {
|
|
41
|
+
return ["\u00b7", "\u2722", "\u2733", "\u2736", "\u273b", "*"];
|
|
42
|
+
}
|
|
43
|
+
return process.platform === "darwin"
|
|
44
|
+
? ["\u00b7", "\u2722", "\u2733", "\u2736", "\u273b", "\u273d"]
|
|
45
|
+
: ["\u00b7", "\u2722", "*", "\u2736", "\u273b", "\u273d"];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CLAUDE_BASE_FRAMES = defaultClaudeCharacters();
|
|
49
|
+
const CLAUDE_PING_PONG_FRAMES = [...CLAUDE_BASE_FRAMES, ...[...CLAUDE_BASE_FRAMES].reverse()] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `Pi (system)` exists to hand the slot back: empty frames and a zero interval mean the
|
|
53
|
+
* extension installs nothing, so pi's own Braille spinner and colour are untouched. It is
|
|
54
|
+
* also the only way to get the default Braille dots once a preset is stored.
|
|
55
|
+
*/
|
|
56
|
+
export const SPINNER_PRESET_DEFS: Record<SpinnerPreset, Preset> = {
|
|
57
|
+
"Pi (system)": {
|
|
58
|
+
frames: [],
|
|
59
|
+
intervalMs: 0,
|
|
60
|
+
paint: "accent",
|
|
61
|
+
note: "pi's own Braille spinner",
|
|
62
|
+
},
|
|
63
|
+
// The diamond closing from outlined to filled and back; 200ms × 4 is one turn per 0.8s,
|
|
64
|
+
// the rate pi's Braille default turns at (80ms × 10).
|
|
65
|
+
"Diamond Glow": {
|
|
66
|
+
frames: ["\u2b25", "\u25c8", "\u25c6", "\u25c7"],
|
|
67
|
+
intervalMs: 200,
|
|
68
|
+
paint: "accent",
|
|
69
|
+
note: "diamond closing, 0.8s",
|
|
70
|
+
},
|
|
71
|
+
// Three-diamond travelling pulse: a filled diamond moving across hollow ones.
|
|
72
|
+
"Diamond Pulse": {
|
|
73
|
+
frames: ["\u25c6\u25c7\u25c7", "\u25c7\u25c6\u25c7", "\u25c7\u25c7\u25c6"],
|
|
74
|
+
intervalMs: 200,
|
|
75
|
+
paint: "accent",
|
|
76
|
+
note: "travelling diamond pulse",
|
|
77
|
+
},
|
|
78
|
+
// Three-circle fill ladder: filled dots filling from left to right (● ◌ ◌ → ● ● ◌ → ● ● ●).
|
|
79
|
+
"Circle Pulse": {
|
|
80
|
+
frames: ["\u25cf \u25cc \u25cc", "\u25cf \u25cf \u25cc", "\u25cf \u25cf \u25cf"],
|
|
81
|
+
intervalMs: 200,
|
|
82
|
+
paint: "accent",
|
|
83
|
+
note: "filling circles, one to three",
|
|
84
|
+
},
|
|
85
|
+
// Three-circle travelling wave: a filled dot moving across hollow dots (● ◌ ◌ → ◌ ● ◌ → ◌ ◌ ●).
|
|
86
|
+
"Circle Wave": {
|
|
87
|
+
frames: ["\u25cf \u25cc \u25cc", "\u25cc \u25cf \u25cc", "\u25cc \u25cc \u25cf"],
|
|
88
|
+
intervalMs: 200,
|
|
89
|
+
paint: "accent",
|
|
90
|
+
note: "travelling circle wave",
|
|
91
|
+
},
|
|
92
|
+
// Three-block travelling shade wave from light to dark shading.
|
|
93
|
+
"Shade Wave": {
|
|
94
|
+
frames: ["\u2591\u2592\u2593", "\u2593\u2591\u2592", "\u2592\u2593\u2591"],
|
|
95
|
+
intervalMs: 200,
|
|
96
|
+
paint: "accent",
|
|
97
|
+
note: "travelling block shade wave",
|
|
98
|
+
},
|
|
99
|
+
// Braille stepping dots: rotating/tumbling dot pairs (⠖ ⠲ ⠴ ⠦).
|
|
100
|
+
"Braille Column": {
|
|
101
|
+
frames: ["\u2816", "\u2832", "\u2834", "\u2826"],
|
|
102
|
+
intervalMs: 160,
|
|
103
|
+
paint: "accent",
|
|
104
|
+
note: "stepping braille dot pattern",
|
|
105
|
+
},
|
|
106
|
+
// Braille fill levels: rising horizontal dot lines from bottom to top (⣀ ⣤ ⣶ ⣿).
|
|
107
|
+
"Braille Level": {
|
|
108
|
+
frames: ["\u28c0", "\u28e4", "\u28f6", "\u28ff", "\u28f6", "\u28e4"],
|
|
109
|
+
intervalMs: 140,
|
|
110
|
+
paint: "accent",
|
|
111
|
+
note: "rising braille dot level fill",
|
|
112
|
+
},
|
|
113
|
+
// Kanji stroke count: one, two, three lines (一 二 三).
|
|
114
|
+
"Kanji Count": {
|
|
115
|
+
frames: ["\u4e00", "\u4e8c", "\u4e09"],
|
|
116
|
+
intervalMs: 200,
|
|
117
|
+
paint: "accent",
|
|
118
|
+
note: "kanji numerals, one to three",
|
|
119
|
+
},
|
|
120
|
+
// Suzhou numerals (tally marks): vertical bars one to three (〡 〢 〣).
|
|
121
|
+
"Suzhou Tally": {
|
|
122
|
+
frames: ["\u3021", "\u3022", "\u3023"],
|
|
123
|
+
intervalMs: 200,
|
|
124
|
+
paint: "accent",
|
|
125
|
+
note: "vertical tally bars, one to three",
|
|
126
|
+
},
|
|
127
|
+
// Gemini CLI's spinner is the `dots` entry from the `cli-spinners` package, the same
|
|
128
|
+
// Braille set pi ships; what makes it Gemini is the Google brand gradient sweeping the
|
|
129
|
+
// glyph on a 4s cycle (GeminiSpinner.tsx paints `CliSpinner` through `tinygradient`).
|
|
130
|
+
Gemini: {
|
|
131
|
+
frames: ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"],
|
|
132
|
+
intervalMs: 80,
|
|
133
|
+
paint: "gradient",
|
|
134
|
+
note: "pi's Braille + Google gradient",
|
|
135
|
+
},
|
|
136
|
+
// Claude Code's mark animation: ping-pong loop of dingbats swelling and contracting,
|
|
137
|
+
// matching upstream SpinnerAnimationRow (120ms per frame).
|
|
138
|
+
Claude: {
|
|
139
|
+
frames: CLAUDE_PING_PONG_FRAMES,
|
|
140
|
+
intervalMs: 120,
|
|
141
|
+
paint: "claude",
|
|
142
|
+
note: "Claude Code's pulsing marks, 120ms",
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Google brand gradient midpoints (sRGB), seamless 4s loop. */
|
|
147
|
+
const GOOGLE_BRAND: ReadonlyArray<readonly [number, number, number]> = [
|
|
148
|
+
[168, 130, 234], // purple
|
|
149
|
+
[66, 133, 244], // blue
|
|
150
|
+
[52, 168, 172], // cyan
|
|
151
|
+
[52, 168, 83], // green
|
|
152
|
+
[251, 188, 4], // yellow
|
|
153
|
+
[234, 67, 53], // red
|
|
154
|
+
[168, 130, 234], // back to purple
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/** Claude Code's own spinner colour: the 256-colour grey-orange index it brands around. */
|
|
158
|
+
const CLAUDE_RGB: readonly [number, number, number] = [215, 119, 87];
|
|
159
|
+
|
|
160
|
+
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
|
161
|
+
|
|
162
|
+
/** The brand gradient at `t` in [0, 1), as sRGB. Wraps, so any t is valid. */
|
|
163
|
+
export function googleGradientRgb(t: number): [number, number, number] {
|
|
164
|
+
const wrapped = t - Math.floor(t);
|
|
165
|
+
const spans = GOOGLE_BRAND.length - 1;
|
|
166
|
+
const scaled = wrapped * spans;
|
|
167
|
+
const i = Math.min(Math.floor(scaled), spans - 1);
|
|
168
|
+
const f = scaled - i;
|
|
169
|
+
const a = GOOGLE_BRAND[i]!;
|
|
170
|
+
const b = GOOGLE_BRAND[i + 1]!;
|
|
171
|
+
return [Math.round(lerp(a[0], b[0], f)), Math.round(lerp(a[1], b[1], f)), Math.round(lerp(a[2], b[2], f))];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Truecolor SGR for one glyph. pi's own `fg` cannot take a raw RGB, so the escape is ours. */
|
|
175
|
+
const fgRgb = ([r, g, b]: readonly [number, number, number]): string => `\x1b[38;2;${r};${g};${b}m`;
|
|
176
|
+
|
|
177
|
+
/** Painter for a preset's spinner; gradient and claude use fixed branding, accent uses theme. */
|
|
178
|
+
export function spinnerPainter(
|
|
179
|
+
preset: SpinnerPreset,
|
|
180
|
+
theme: { fg?(color: string, text: string): string } | null,
|
|
181
|
+
): (text: string) => string {
|
|
182
|
+
const def = SPINNER_PRESET_DEFS[preset];
|
|
183
|
+
if (def.paint === "gradient") {
|
|
184
|
+
return (text) => `${fgRgb(googleGradientRgb(Date.now() / 4000))}${text}\x1b[0m`;
|
|
185
|
+
}
|
|
186
|
+
if (def.paint === "claude") {
|
|
187
|
+
return (text) => `${fgRgb(CLAUDE_RGB)}${text}\x1b[0m`;
|
|
188
|
+
}
|
|
189
|
+
const fg = theme?.fg?.bind(theme);
|
|
190
|
+
if (!fg) return (text) => text;
|
|
191
|
+
return (text) => fg("accent", text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Frames for the picker preview row, e.g. `⬥ ◈ ◆ ◇`. `Pi (system)` reports its own set. */
|
|
195
|
+
export function previewFrames(preset: SpinnerPreset): string {
|
|
196
|
+
const def = SPINNER_PRESET_DEFS[preset];
|
|
197
|
+
const frames = def.frames.length > 0 ? def.frames : SPINNER_PRESET_DEFS.Gemini.frames;
|
|
198
|
+
return frames.join(" ");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
// Self-check: `node lib/spinner.ts`
|
|
203
|
+
if (isMain(import.meta.url)) {
|
|
204
|
+
for (const p of SPINNER_PRESETS) {
|
|
205
|
+
const def = SPINNER_PRESET_DEFS[p];
|
|
206
|
+
assert(def.note.length > 0, `${p} carries a picker note`);
|
|
207
|
+
for (const frame of def.frames) {
|
|
208
|
+
assert(!/\ufffd|\ue000-\uf8ff/.test(frame), `${p}: frame ${frame} is not a blank private-use cell`);
|
|
209
|
+
}
|
|
210
|
+
if (def.frames.length > 0) {
|
|
211
|
+
assert(def.intervalMs > 0, `${p} with frames must set an interval`);
|
|
212
|
+
assert(new Set(def.frames).size > 1, `${p} must animate, not repeat one frame`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// `Pi (system)` is the escape hatch: it must install nothing at all.
|
|
217
|
+
assert(SPINNER_PRESET_DEFS["Pi (system)"].frames.length === 0, "Pi (system) leaves pi's frames alone");
|
|
218
|
+
assert(SPINNER_PRESET_DEFS["Pi (system)"].intervalMs === 0, "Pi (system) leaves pi's interval alone");
|
|
219
|
+
|
|
220
|
+
// Gemini's Braille is the one sanctioned exception: Braille Patterns is a block every
|
|
221
|
+
// terminal font covers (it is pi's own default set, so a terminal that shows pi at all has
|
|
222
|
+
// it), and the ten cells share one face because U+2800-U+28FF is a single contiguous run.
|
|
223
|
+
const braille = /^[\u2800-\u28ff]+$/;
|
|
224
|
+
assert(SPINNER_PRESET_DEFS.Gemini.frames.every((f) => braille.test(f)), "Gemini frames are Braille cells");
|
|
225
|
+
assert(SPINNER_PRESET_DEFS.Gemini.frames.length === 10, "Gemini uses the ten-frame dots spinner");
|
|
226
|
+
assert(
|
|
227
|
+
SPINNER_PRESET_DEFS.Gemini.intervalMs === 80,
|
|
228
|
+
"Gemini keeps the cli-spinners cadence (80ms)",
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Diamond Glow uses the Geometric Shapes block; `⬥ ◈ ◆ ◇` all live in one contiguous run
|
|
232
|
+
// and are hosted by the terminal face on the platforms this package targets.
|
|
233
|
+
for (const f of SPINNER_PRESET_DEFS["Diamond Glow"].frames) {
|
|
234
|
+
assert(/^[\u25a0-\u25ff\u2b00-\u2bff]$/.test(f), `Diamond Glow frame ${f} is a geometric shape`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Claude preset: upstream dingbats in ping-pong order, matching Claude Code.
|
|
238
|
+
const claude = SPINNER_PRESET_DEFS.Claude.frames;
|
|
239
|
+
assert(claude[0] === "\u00b7", "Claude starts on the interpunct");
|
|
240
|
+
assert(claude.length === 12, "Claude uses a 12-frame ping-pong loop");
|
|
241
|
+
assert(SPINNER_PRESET_DEFS.Claude.intervalMs === 120, "Claude matches upstream 120ms cadence");
|
|
242
|
+
assert(claude.join("") === [...claude].reverse().join(""), "Claude's ping-pong frames are a palindrome");
|
|
243
|
+
for (const f of claude) assert(f !== " " && f.length === 1, `Claude frame ${JSON.stringify(f)} is one glyph`);
|
|
244
|
+
|
|
245
|
+
// The gradient closes: t=1 must equal t=0, or the 4s sweep would jump at the seam.
|
|
246
|
+
assert(
|
|
247
|
+
googleGradientRgb(0).join() === googleGradientRgb(1).join(),
|
|
248
|
+
"the Google gradient wraps without a seam",
|
|
249
|
+
);
|
|
250
|
+
assert(googleGradientRgb(0.9999).join() !== googleGradientRgb(0.5).join(), "the gradient actually moves");
|
|
251
|
+
assert(googleGradientRgb(-0.25).join() === googleGradientRgb(0.75).join(), "a negative t wraps like a positive one");
|
|
252
|
+
for (const rgb of [googleGradientRgb(0), googleGradientRgb(0.37), googleGradientRgb(0.91)]) {
|
|
253
|
+
assert(rgb.every((c) => Number.isInteger(c) && c >= 0 && c <= 255), `gradient stays in sRGB: ${rgb.join()}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The accent painter must go through the theme, not a hardcoded colour.
|
|
257
|
+
assert(
|
|
258
|
+
spinnerPainter("Diamond Glow", { fg: (c, t) => `<${c}>${t}` })("x") === "<accent>x",
|
|
259
|
+
"accent presets paint through the theme token",
|
|
260
|
+
);
|
|
261
|
+
assert(spinnerPainter("Diamond Glow", null)("x") === "x", "no theme -> identity painter, never a crash");
|
|
262
|
+
const grad = spinnerPainter("Gemini", null)("x");
|
|
263
|
+
assert(grad.startsWith("\x1b[38;2;") && grad.endsWith("x\x1b[0m"), "the gradient painter emits truecolor + reset");
|
|
264
|
+
assert(spinnerPainter("Claude", null)("x").startsWith("\x1b[38;2;215;119;87m"), "Claude uses its own mark colour");
|
|
265
|
+
|
|
266
|
+
assert(previewFrames("Diamond Glow").split(" ").length === 4, "the picker preview shows every Diamond frame");
|
|
267
|
+
assert(previewFrames("Pi (system)").length > 0, "Pi (system) still previews pi's Braille set");
|
|
268
|
+
|
|
269
|
+
console.log("spinner.ts self-check OK");
|
|
270
|
+
}
|