@gonrocca/zero-pi 0.1.10 → 0.1.11
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/README.md +82 -5
- package/extensions/conversation-resume.ts +399 -0
- package/extensions/provider-guard-extension.ts +195 -0
- package/extensions/provider-guard.ts +183 -0
- package/extensions/startup-banner.ts +120 -151
- package/extensions/working-phrases.ts +295 -0
- package/package.json +13 -2
- package/themes/zero-sdd.json +76 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// zero-pi — metered-provider guard, pure-logic module.
|
|
2
|
+
//
|
|
3
|
+
// pi can reach the same Claude models through two providers: `pi-claude-cli`
|
|
4
|
+
// (routes via the locally installed Claude CLI, billed against the user's
|
|
5
|
+
// subscription PLAN — no per-token charge) and `anthropic` (the direct
|
|
6
|
+
// Anthropic API provider, which consumes a metered "extra usage" pool and
|
|
7
|
+
// bills per token). `/model` lists the same models under both, so it is easy
|
|
8
|
+
// to slide into metered billing unnoticed.
|
|
9
|
+
//
|
|
10
|
+
// This module holds every *decision* of the guard — classifying a model switch
|
|
11
|
+
// into the action the wiring must run — in plain, dependency-free TypeScript so
|
|
12
|
+
// it is testable with plain objects via `node --test`. The pi wiring (the
|
|
13
|
+
// `model_select` handler that performs `confirm`/`notify`/`setModel`) lives in
|
|
14
|
+
// `provider-guard-extension.ts`.
|
|
15
|
+
//
|
|
16
|
+
// This file has no pi imports, no filesystem access, and no side-effecting
|
|
17
|
+
// top-level code; it is pure data plus an injected lookup function.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Mapping metered-provider → equivalent subscription-provider.
|
|
21
|
+
*
|
|
22
|
+
* A provider that appears as a *key* here is "metered" — switching to it
|
|
23
|
+
* triggers the guard. The mapped *value* is the subscription provider the
|
|
24
|
+
* guard offers to redirect to. v1 has exactly one pair; adding another
|
|
25
|
+
* metered↔subscription pair is a single extra line.
|
|
26
|
+
*
|
|
27
|
+
* Invariant the no-redirect-loop guarantee depends on: a redirection target
|
|
28
|
+
* (a *value*) must never itself be a *key*. `pi-claude-cli` is a value, not a
|
|
29
|
+
* key, so the `setModel`-triggered second `model_select` event classifies as
|
|
30
|
+
* `ignore` and the chain terminates.
|
|
31
|
+
*/
|
|
32
|
+
export const METERED_TO_SUBSCRIPTION: Readonly<Record<string, string>> = {
|
|
33
|
+
anthropic: "pi-claude-cli",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Origin of a model change, as pi reports it on `ModelSelectEvent.source`.
|
|
38
|
+
* `set`/`cycle` are deliberate user actions; `restore` is a non-deliberate
|
|
39
|
+
* session restore that must never raise a modal.
|
|
40
|
+
*/
|
|
41
|
+
export type GuardSource = "set" | "cycle" | "restore";
|
|
42
|
+
|
|
43
|
+
/** The deliberate sources that may raise a redirect modal. Any other source
|
|
44
|
+
* (including `restore` and unknown strings) is treated as non-deliberate. */
|
|
45
|
+
const DELIBERATE_SOURCES: readonly string[] = ["set", "cycle"];
|
|
46
|
+
|
|
47
|
+
/** The minimum a `Model` of pi must expose for the guard to classify it. */
|
|
48
|
+
export interface ModelLike {
|
|
49
|
+
provider: string;
|
|
50
|
+
id: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Injected registry-lookup function. The wiring builds this over
|
|
55
|
+
* `ctx.modelRegistry.find`; the pure module never knows `modelRegistry` or any
|
|
56
|
+
* pi type. Returns `undefined` when no equivalent model exists. The wiring is
|
|
57
|
+
* responsible for wrapping `find` so this lookup is total — `classifyModelSwitch`
|
|
58
|
+
* assumes a `lookup` that never throws.
|
|
59
|
+
*/
|
|
60
|
+
export type RegistryLookup = (provider: string, id: string) => ModelLike | undefined;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The action the wiring must execute, as a discriminated union keyed on `kind`.
|
|
64
|
+
*
|
|
65
|
+
* - `ignore` — no-op: non-metered provider, or a malformed event.
|
|
66
|
+
* - `warn` — emit only `ctx.ui.notify(message, "warning")`; no modal, no
|
|
67
|
+
* `setModel`. Used for `restore`, for a metered provider with no equivalent,
|
|
68
|
+
* and for any non-deliberate source.
|
|
69
|
+
* - `offer-redirect` — show `ctx.ui.confirm(confirmTitle, confirmMessage)`; on
|
|
70
|
+
* a truthy answer `setModel(safeModel)` and, if applied, notify with
|
|
71
|
+
* `redirectMessage`.
|
|
72
|
+
*/
|
|
73
|
+
export type GuardAction =
|
|
74
|
+
| { kind: "ignore" }
|
|
75
|
+
| { kind: "warn"; message: string }
|
|
76
|
+
| {
|
|
77
|
+
kind: "offer-redirect";
|
|
78
|
+
safeModel: ModelLike;
|
|
79
|
+
confirmTitle: string;
|
|
80
|
+
confirmMessage: string;
|
|
81
|
+
redirectMessage: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// User-facing Spanish strings
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
//
|
|
88
|
+
// Exported as constants/functions so the test can assert them verbatim. `id`
|
|
89
|
+
// is the model id (e.g. `claude-opus-4-7`); `metered`/`subscription` are the
|
|
90
|
+
// provider names from `METERED_TO_SUBSCRIPTION`.
|
|
91
|
+
|
|
92
|
+
/** Title of the redirect `confirm` dialog. */
|
|
93
|
+
export const CONFIRM_TITLE = "zero · proveedor con cobro por token";
|
|
94
|
+
|
|
95
|
+
/** Body of the redirect `confirm` dialog — explains the metered billing and
|
|
96
|
+
* asks whether to switch to the subscription equivalent. */
|
|
97
|
+
export function confirmMessage(id: string, metered: string, subscription: string): string {
|
|
98
|
+
return `Estás cambiando a «${id}» vía el proveedor «${metered}», que factura por token y consume tu pool medido de extra usage. ¿Querés cambiar al equivalente «${id}» en «${subscription}», que usa los límites de tu suscripción?`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `info` notification emitted after `setModel` succeeds. */
|
|
102
|
+
export function redirectMessage(id: string, subscription: string): string {
|
|
103
|
+
return `zero: redirigido a «${id}» en ${subscription} — usando los límites de tu suscripción.`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `warning` notification — no equivalent available, or `restore`. */
|
|
107
|
+
export function warnMessage(id: string, metered: string): string {
|
|
108
|
+
return `zero: «${id}» está activo vía el proveedor «${metered}», que factura por token y consume tu pool medido de extra usage.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `warning` notification — `setModel` returned `false` (redirection did not
|
|
112
|
+
* apply). Optional; emitted by the wiring as a defensive courtesy. */
|
|
113
|
+
export function redirectFailedMessage(id: string, subscription: string): string {
|
|
114
|
+
return `zero: no se pudo cambiar a «${id}» en ${subscription} — seguís en el proveedor medido.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Classification
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** Whether a value is a non-empty string. */
|
|
122
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
123
|
+
return typeof value === "string" && value !== "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classify a model switch into the `GuardAction` the wiring must execute.
|
|
128
|
+
*
|
|
129
|
+
* Pure and total — never throws (assuming `lookup` is total, which is the
|
|
130
|
+
* wiring's contract). The branches, in order:
|
|
131
|
+
*
|
|
132
|
+
* 1. `model` falsy, or `model.provider`/`model.id` not non-empty strings →
|
|
133
|
+
* `ignore` (malformed event, AC 6.3).
|
|
134
|
+
* 2. `model.provider` is not a key of `METERED_TO_SUBSCRIPTION` → `ignore`
|
|
135
|
+
* (non-metered provider, AC 4.1/4.2 — this also classifies `pi-claude-cli`,
|
|
136
|
+
* which is the structural no-redirect-loop guarantee, Story 5).
|
|
137
|
+
* 3. The provider is metered. Resolve the subscription provider and call
|
|
138
|
+
* `lookup(subProvider, model.id)`:
|
|
139
|
+
* - `source === "restore"` → always `warn`, never a modal, regardless of
|
|
140
|
+
* whether an equivalent exists (AC 3.x).
|
|
141
|
+
* - `source` deliberate (`set`/`cycle`):
|
|
142
|
+
* - `lookup` found a model → `offer-redirect` (AC 1).
|
|
143
|
+
* - `lookup` returned `undefined` → `warn` (AC 2).
|
|
144
|
+
* - any other (unknown) `source` → treated as non-deliberate → `warn`
|
|
145
|
+
* (defensive: no modal unless the switch is known to be deliberate).
|
|
146
|
+
*/
|
|
147
|
+
export function classifyModelSwitch(
|
|
148
|
+
model: ModelLike | null | undefined,
|
|
149
|
+
source: GuardSource | string | null | undefined,
|
|
150
|
+
lookup: RegistryLookup,
|
|
151
|
+
): GuardAction {
|
|
152
|
+
// 1. Malformed event — no model, or missing/empty provider/id.
|
|
153
|
+
if (!model || !isNonEmptyString(model.provider) || !isNonEmptyString(model.id)) {
|
|
154
|
+
return { kind: "ignore" };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 2. Non-metered provider — silent no-op (covers `pi-claude-cli`).
|
|
158
|
+
const subscriptionProvider = METERED_TO_SUBSCRIPTION[model.provider];
|
|
159
|
+
if (subscriptionProvider === undefined) {
|
|
160
|
+
return { kind: "ignore" };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 3. Metered provider. A deliberate switch with an equivalent gets a modal;
|
|
164
|
+
// everything else (restore, no equivalent, unknown source) gets a warn.
|
|
165
|
+
const meteredProvider = model.provider;
|
|
166
|
+
const id = model.id;
|
|
167
|
+
const isDeliberate = typeof source === "string" && DELIBERATE_SOURCES.includes(source);
|
|
168
|
+
|
|
169
|
+
if (isDeliberate) {
|
|
170
|
+
const safeModel = lookup(subscriptionProvider, id);
|
|
171
|
+
if (safeModel) {
|
|
172
|
+
return {
|
|
173
|
+
kind: "offer-redirect",
|
|
174
|
+
safeModel,
|
|
175
|
+
confirmTitle: CONFIRM_TITLE,
|
|
176
|
+
confirmMessage: confirmMessage(id, meteredProvider, subscriptionProvider),
|
|
177
|
+
redirectMessage: redirectMessage(id, subscriptionProvider),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { kind: "warn", message: warnMessage(id, meteredProvider) };
|
|
183
|
+
}
|
|
@@ -1,107 +1,97 @@
|
|
|
1
|
-
// zero-pi
|
|
1
|
+
// zero-pi - animated startup banner.
|
|
2
2
|
//
|
|
3
|
-
// A pi extension that renders the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// The render runs synchronously inside the extension's register() call, before
|
|
9
|
-
// pi draws its interactive UI, so the animation never fights pi's renderer and
|
|
10
|
-
// no extra dependency is needed. A banner failure is swallowed: it must never
|
|
11
|
-
// break a pi session.
|
|
12
|
-
//
|
|
13
|
-
// Behaviour is controlled by the ZERO_BANNER environment variable:
|
|
14
|
-
// ZERO_BANNER=off render nothing
|
|
15
|
-
// ZERO_BANNER=static render the settled gradient, no animation
|
|
16
|
-
// (unset) / shimmer render the animated shimmer sweep (default)
|
|
17
|
-
//
|
|
18
|
-
// Pure ANSI 24-bit colour, zero runtime dependencies.
|
|
3
|
+
// A pi extension that renders the ZERO wordmark as ASCII-safe Tetris cells.
|
|
4
|
+
// The animated mode progressively assembles the wordmark from the bottom up,
|
|
5
|
+
// then settles into the complete banner. The extension stays dependency-free
|
|
6
|
+
// and keeps the historical ZERO_BANNER controls.
|
|
19
7
|
|
|
20
8
|
type RGB = [number, number, number];
|
|
21
9
|
|
|
22
10
|
const COLORS: Record<string, RGB> = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
11
|
+
cyan: [80, 210, 255],
|
|
12
|
+
blue: [116, 151, 255],
|
|
13
|
+
mint: [79, 221, 171],
|
|
14
|
+
amber: [238, 190, 92],
|
|
15
|
+
rose: [255, 106, 122],
|
|
16
|
+
violet: [175, 138, 255],
|
|
17
|
+
peak: [255, 245, 218],
|
|
27
18
|
};
|
|
28
19
|
|
|
29
|
-
/** Number of rows in every glyph
|
|
20
|
+
/** Number of rows in every glyph. */
|
|
30
21
|
const ROWS = 6;
|
|
31
22
|
|
|
32
|
-
//
|
|
33
|
-
// rows tall; widths vary per letter and the layout adds one space between
|
|
34
|
-
// glyphs for breathing room.
|
|
23
|
+
// Five-cell-wide glyphs. Non-space characters become visible Tetris cells.
|
|
35
24
|
const FONT: Record<string, string[]> = {
|
|
36
|
-
Z: [
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
],
|
|
44
|
-
E: [
|
|
45
|
-
"███████╗",
|
|
46
|
-
"██╔════╝",
|
|
47
|
-
"█████╗ ",
|
|
48
|
-
"██╔══╝ ",
|
|
49
|
-
"███████╗",
|
|
50
|
-
"╚══════╝",
|
|
51
|
-
],
|
|
52
|
-
R: [
|
|
53
|
-
"██████╗ ",
|
|
54
|
-
"██╔══██╗",
|
|
55
|
-
"██████╔╝",
|
|
56
|
-
"██╔══██╗",
|
|
57
|
-
"██║ ██║",
|
|
58
|
-
"╚═╝ ╚═╝",
|
|
59
|
-
],
|
|
60
|
-
O: [
|
|
61
|
-
" ██████╗ ",
|
|
62
|
-
"██╔═══██╗",
|
|
63
|
-
"██║ ██║",
|
|
64
|
-
"██║ ██║",
|
|
65
|
-
"╚██████╔╝",
|
|
66
|
-
" ╚═════╝ ",
|
|
67
|
-
],
|
|
68
|
-
P: [
|
|
69
|
-
"██████╗ ",
|
|
70
|
-
"██╔══██╗",
|
|
71
|
-
"██████╔╝",
|
|
72
|
-
"██╔═══╝ ",
|
|
73
|
-
"██║ ",
|
|
74
|
-
"╚═╝ ",
|
|
75
|
-
],
|
|
76
|
-
I: [
|
|
77
|
-
"██╗",
|
|
78
|
-
"██║",
|
|
79
|
-
"██║",
|
|
80
|
-
"██║",
|
|
81
|
-
"██║",
|
|
82
|
-
"╚═╝",
|
|
83
|
-
],
|
|
84
|
-
"-": [
|
|
85
|
-
" ",
|
|
86
|
-
" ",
|
|
87
|
-
" █████╗ ",
|
|
88
|
-
" ╚════╝ ",
|
|
89
|
-
" ",
|
|
90
|
-
" ",
|
|
91
|
-
],
|
|
92
|
-
" ": [" ", " ", " ", " ", " ", " "],
|
|
25
|
+
Z: ["ZZZZZ", " ZZ", " ZZ ", " ZZ ", "ZZ ", "ZZZZZ"],
|
|
26
|
+
E: ["EEEEE", "EE ", "EEEE ", "EE ", "EE ", "EEEEE"],
|
|
27
|
+
R: ["RRRR ", "RR RR", "RRRR ", "RR RR", "RR R", "RR R"],
|
|
28
|
+
O: [" OOO ", "OO OO", "OO OO", "OO OO", "OO OO", " OOO "],
|
|
29
|
+
P: ["PPPP ", "PP PP", "PP PP", "PPPP ", "PP ", "PP "],
|
|
30
|
+
I: ["IIIII", " II ", " II ", " II ", " II ", "IIIII"],
|
|
31
|
+
"-": [" ", " ", "-----", " ", " ", " "],
|
|
32
|
+
" ": [" ", " ", " ", " ", " ", " "],
|
|
93
33
|
};
|
|
94
34
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
35
|
+
const PIECE_COLORS: Record<string, RGB> = {
|
|
36
|
+
Z: COLORS.cyan,
|
|
37
|
+
E: COLORS.amber,
|
|
38
|
+
R: COLORS.mint,
|
|
39
|
+
O: COLORS.rose,
|
|
40
|
+
P: COLORS.violet,
|
|
41
|
+
I: COLORS.blue,
|
|
42
|
+
"-": COLORS.amber,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface Matrix {
|
|
46
|
+
rows: string[];
|
|
47
|
+
width: number;
|
|
48
|
+
totalCells: number;
|
|
49
|
+
ranks: Map<string, number>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function glyphFor(ch: string): string[] {
|
|
53
|
+
return FONT[ch.toUpperCase()] ?? FONT[" "];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function matrixForText(text: string): Matrix {
|
|
57
|
+
const rows = Array.from({ length: ROWS }, () => "");
|
|
58
|
+
for (const [index, raw] of Array.from(text).entries()) {
|
|
59
|
+
const glyph = glyphFor(raw);
|
|
100
60
|
for (let r = 0; r < ROWS; r++) {
|
|
101
|
-
|
|
61
|
+
rows[r] += (index === 0 ? "" : " ") + glyph[r];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cells: Array<{ row: number; col: number }> = [];
|
|
66
|
+
for (let row = 0; row < ROWS; row++) {
|
|
67
|
+
for (let col = 0; col < rows[row].length; col++) {
|
|
68
|
+
if (rows[row][col] !== " ") cells.push({ row, col });
|
|
102
69
|
}
|
|
103
70
|
}
|
|
104
|
-
|
|
71
|
+
|
|
72
|
+
// Tetris reads as a pile building upward: bottom rows settle first.
|
|
73
|
+
cells.sort((a, b) => b.row - a.row || a.col - b.col);
|
|
74
|
+
|
|
75
|
+
const ranks = new Map<string, number>();
|
|
76
|
+
for (const [rank, cell] of cells.entries()) {
|
|
77
|
+
ranks.set(`${cell.row}:${cell.col}`, rank);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
rows,
|
|
82
|
+
width: rows[0]?.length ?? 0,
|
|
83
|
+
totalCells: cells.length,
|
|
84
|
+
ranks,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Lay a word out as six rows of ASCII-safe Tetris cells. */
|
|
89
|
+
export function bannerLines(text = "ZERO"): string[] {
|
|
90
|
+
return matrixForText(text).rows.map((row) =>
|
|
91
|
+
Array.from(row)
|
|
92
|
+
.map((cell) => (cell === " " ? " " : "[]"))
|
|
93
|
+
.join(""),
|
|
94
|
+
);
|
|
105
95
|
}
|
|
106
96
|
|
|
107
97
|
function lerp(a: number, b: number, t: number): number {
|
|
@@ -117,48 +107,34 @@ function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
|
|
|
117
107
|
];
|
|
118
108
|
}
|
|
119
109
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*
|
|
123
|
-
* When `spotlight` is null the banner is in its settled state: a subtle
|
|
124
|
-
* horizontal purple -> amber gradient with a top-to-bottom warmth bias so the
|
|
125
|
-
* lower rows glow like embers. Otherwise the cell's colour depends on its
|
|
126
|
-
* distance from the moving spotlight column.
|
|
127
|
-
*/
|
|
128
|
-
function colorAt(col: number, row: number, spotlight: number | null, width: number): RGB {
|
|
129
|
-
if (spotlight === null) {
|
|
130
|
-
const horizontal = col / Math.max(1, width - 1);
|
|
131
|
-
const vertical = row / (ROWS - 1);
|
|
132
|
-
const base = lerpColor(COLORS.purpleMid, COLORS.amber, horizontal * 0.55);
|
|
133
|
-
return lerpColor(base, COLORS.amber, vertical * 0.25);
|
|
134
|
-
}
|
|
135
|
-
const dx = Math.abs(col - spotlight);
|
|
136
|
-
if (dx < 1.5) return COLORS.peak;
|
|
137
|
-
if (dx < 4) return lerpColor(COLORS.peak, COLORS.amber, (dx - 1.5) / 2.5);
|
|
138
|
-
if (dx < 9) return lerpColor(COLORS.amber, COLORS.purpleMid, (dx - 4) / 5);
|
|
139
|
-
if (dx < 16) return lerpColor(COLORS.purpleMid, COLORS.purpleDim, (dx - 9) / 7);
|
|
140
|
-
return COLORS.purpleDim;
|
|
110
|
+
function ansiFg([r, g, b]: RGB, text: string): string {
|
|
111
|
+
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
141
112
|
}
|
|
142
113
|
|
|
143
|
-
|
|
144
|
-
|
|
114
|
+
function colorForPiece(piece: string, row: number, rank: number, active: boolean): RGB {
|
|
115
|
+
if (active) return COLORS.peak;
|
|
116
|
+
const base = PIECE_COLORS[piece] ?? COLORS.blue;
|
|
117
|
+
const heightWarmth = (ROWS - 1 - row) / Math.max(1, ROWS - 1);
|
|
118
|
+
const rankPulse = (rank % 7) / 18;
|
|
119
|
+
return lerpColor(base, COLORS.peak, heightWarmth * 0.18 + rankPulse);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function paintMatrixLine(matrix: Matrix, row: number, visibleCells: number): string {
|
|
145
123
|
let out = "";
|
|
146
|
-
|
|
147
|
-
for (let
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
out += "\x1b[0m";
|
|
152
|
-
colored = false;
|
|
153
|
-
}
|
|
154
|
-
out += " ";
|
|
124
|
+
const line = matrix.rows[row] ?? "";
|
|
125
|
+
for (let col = 0; col < matrix.width; col++) {
|
|
126
|
+
const piece = line[col] ?? " ";
|
|
127
|
+
if (piece === " ") {
|
|
128
|
+
out += " ";
|
|
155
129
|
continue;
|
|
156
130
|
}
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
131
|
+
const rank = matrix.ranks.get(`${row}:${col}`) ?? Number.POSITIVE_INFINITY;
|
|
132
|
+
if (rank >= visibleCells) {
|
|
133
|
+
out += " ";
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
out += ansiFg(colorForPiece(piece, row, rank, rank === visibleCells - 1), "[]");
|
|
160
137
|
}
|
|
161
|
-
if (colored) out += "\x1b[0m";
|
|
162
138
|
return out;
|
|
163
139
|
}
|
|
164
140
|
|
|
@@ -185,15 +161,17 @@ export function resolveBannerMode(raw: string | undefined): BannerMode {
|
|
|
185
161
|
return "shimmer";
|
|
186
162
|
}
|
|
187
163
|
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// without making register() async, so the whole banner is drawn before pi's
|
|
191
|
-
// UI takes over.
|
|
164
|
+
// Atomics.wait gives us a synchronous sleep without adding a dependency. The
|
|
165
|
+
// banner renders before Pi's TUI takes over, so blocking briefly is intentional.
|
|
192
166
|
const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4));
|
|
193
167
|
function sleepSync(ms: number): void {
|
|
194
168
|
if (ms > 0) Atomics.wait(SLEEP_SLOT, 0, 0, ms);
|
|
195
169
|
}
|
|
196
170
|
|
|
171
|
+
function easeOutCubic(t: number): number {
|
|
172
|
+
return 1 - Math.pow(1 - t, 3);
|
|
173
|
+
}
|
|
174
|
+
|
|
197
175
|
/** Options for {@link renderBanner}. */
|
|
198
176
|
export interface RenderOptions {
|
|
199
177
|
mode?: BannerMode;
|
|
@@ -206,48 +184,40 @@ export interface RenderOptions {
|
|
|
206
184
|
/**
|
|
207
185
|
* Render the ZERO banner synchronously.
|
|
208
186
|
*
|
|
209
|
-
* On a non-colour stream the banner is printed plain. Otherwise the
|
|
210
|
-
*
|
|
211
|
-
* settling on the static gradient either way.
|
|
187
|
+
* On a non-colour stream the banner is printed plain. Otherwise the animated
|
|
188
|
+
* mode assembles cells frame by frame, then settles on the completed wordmark.
|
|
212
189
|
*/
|
|
213
190
|
export function renderBanner(options: RenderOptions = {}): void {
|
|
214
191
|
const mode = options.mode ?? resolveBannerMode(process.env.ZERO_BANNER);
|
|
215
192
|
if (mode === "off") return;
|
|
216
193
|
|
|
217
194
|
const stream: OutStream = options.stream ?? process.stdout;
|
|
218
|
-
const
|
|
219
|
-
const width = lines[0].length;
|
|
195
|
+
const matrix = matrixForText(options.text ?? "ZERO");
|
|
220
196
|
|
|
221
197
|
if (!shouldColor(stream)) {
|
|
222
|
-
stream.write("\n" +
|
|
198
|
+
stream.write("\n" + bannerLines(options.text ?? "ZERO").join("\n") + "\n\n");
|
|
223
199
|
return;
|
|
224
200
|
}
|
|
225
201
|
|
|
226
|
-
|
|
227
|
-
stream.write("\n" + lines.map(() => "").join("\n") + "\n");
|
|
202
|
+
stream.write("\n" + Array.from({ length: ROWS }, () => "").join("\n") + "\n");
|
|
228
203
|
|
|
229
204
|
if (mode === "shimmer") {
|
|
230
|
-
const frames = Math.max(2, options.frames ??
|
|
231
|
-
const frameMs = Math.max(1, options.frameMs ??
|
|
232
|
-
const startCol = -10;
|
|
233
|
-
const endCol = width + 10;
|
|
205
|
+
const frames = Math.max(2, options.frames ?? 42);
|
|
206
|
+
const frameMs = Math.max(1, options.frameMs ?? 18);
|
|
234
207
|
for (let f = 0; f < frames; f++) {
|
|
235
|
-
const t = f / (frames - 1);
|
|
236
|
-
|
|
237
|
-
const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
238
|
-
const spotlight = startCol + (endCol - startCol) * eased;
|
|
208
|
+
const t = easeOutCubic(f / (frames - 1));
|
|
209
|
+
const visibleCells = Math.max(1, Math.ceil(matrix.totalCells * t));
|
|
239
210
|
stream.write(`\x1b[${ROWS}A\r`);
|
|
240
211
|
for (let r = 0; r < ROWS; r++) {
|
|
241
|
-
stream.write("\x1b[2K" +
|
|
212
|
+
stream.write("\x1b[2K" + paintMatrixLine(matrix, r, visibleCells) + "\n");
|
|
242
213
|
}
|
|
243
214
|
sleepSync(frameMs);
|
|
244
215
|
}
|
|
245
216
|
}
|
|
246
217
|
|
|
247
|
-
// Settle on the static gradient.
|
|
248
218
|
stream.write(`\x1b[${ROWS}A\r`);
|
|
249
219
|
for (let r = 0; r < ROWS; r++) {
|
|
250
|
-
stream.write("\x1b[2K" +
|
|
220
|
+
stream.write("\x1b[2K" + paintMatrixLine(matrix, r, matrix.totalCells) + "\n");
|
|
251
221
|
}
|
|
252
222
|
stream.write("\n");
|
|
253
223
|
}
|
|
@@ -255,9 +225,8 @@ export function renderBanner(options: RenderOptions = {}): void {
|
|
|
255
225
|
/**
|
|
256
226
|
* The pi extension entry point.
|
|
257
227
|
*
|
|
258
|
-
* pi
|
|
259
|
-
*
|
|
260
|
-
* accepted for API compatibility but not needed: the banner is self-contained.
|
|
228
|
+
* The pi argument is accepted for API compatibility. The banner is rendered
|
|
229
|
+
* defensively: visual sugar should never break an interactive session.
|
|
261
230
|
*/
|
|
262
231
|
export default function register(_pi?: unknown): void {
|
|
263
232
|
try {
|