@gonrocca/zero-pi 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,268 +1,237 @@
1
- // zero-pi animated startup banner.
2
- //
3
- // A pi extension that renders the "ZERO" wordmark in ANSI Shadow figlet style
4
- // heavy block glyphs with box-drawing trim for a chunky pixel-art feel — the
5
- // instant a pi session starts. A purple -> amber shimmer sweeps left -> right
6
- // once, then the glyphs settle into a static gradient glow.
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.
19
-
20
- type RGB = [number, number, number];
21
-
22
- const COLORS: Record<string, RGB> = {
23
- purpleDim: [82, 70, 124],
24
- purpleMid: [157, 124, 216],
25
- amber: [255, 180, 84],
26
- peak: [255, 240, 210],
27
- };
28
-
29
- /** Number of rows in every glyph of the ANSI Shadow font. */
30
- const ROWS = 6;
31
-
32
- // The ANSI Shadow font, one entry per supported character. Each glyph is six
33
- // rows tall; widths vary per letter and the layout adds one space between
34
- // glyphs for breathing room.
35
- 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
- " ": [" ", " ", " ", " ", " ", " "],
93
- };
94
-
95
- /** Lay a word out as six text rows of ANSI Shadow glyphs. */
96
- export function bannerLines(text = "ZERO"): string[] {
97
- const lines: string[] = Array.from({ length: ROWS }, () => "");
98
- for (const ch of text) {
99
- const glyph = FONT[ch.toUpperCase()] ?? FONT[" "];
100
- for (let r = 0; r < ROWS; r++) {
101
- lines[r] += glyph[r] + " ";
102
- }
103
- }
104
- return lines;
105
- }
106
-
107
- function lerp(a: number, b: number, t: number): number {
108
- return a + (b - a) * t;
109
- }
110
-
111
- function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
112
- const k = Math.max(0, Math.min(1, t));
113
- return [
114
- Math.round(lerp(c1[0], c2[0], k)),
115
- Math.round(lerp(c1[1], c2[1], k)),
116
- Math.round(lerp(c1[2], c2[2], k)),
117
- ];
118
- }
119
-
120
- /**
121
- * The colour of one glyph cell.
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;
141
- }
142
-
143
- /** Paint one banner row with 24-bit ANSI colour, leaving spaces uncoloured. */
144
- function paintLine(line: string, row: number, spotlight: number | null, width: number): string {
145
- let out = "";
146
- let colored = false;
147
- for (let i = 0; i < line.length; i++) {
148
- const ch = line[i];
149
- if (ch === " ") {
150
- if (colored) {
151
- out += "\x1b[0m";
152
- colored = false;
153
- }
154
- out += " ";
155
- continue;
156
- }
157
- const [r, g, b] = colorAt(i, row, spotlight, width);
158
- out += `\x1b[38;2;${r};${g};${b}m${ch}`;
159
- colored = true;
160
- }
161
- if (colored) out += "\x1b[0m";
162
- return out;
163
- }
164
-
165
- interface OutStream {
166
- write(chunk: string): unknown;
167
- isTTY?: boolean;
168
- }
169
-
170
- /** Whether colour output is appropriate for the given stream. */
171
- function shouldColor(stream: OutStream): boolean {
172
- if (process.env.NO_COLOR) return false;
173
- if (process.env.FORCE_COLOR) return true;
174
- return Boolean(stream && stream.isTTY);
175
- }
176
-
177
- /** The three banner modes, resolved from the ZERO_BANNER environment variable. */
178
- export type BannerMode = "off" | "static" | "shimmer";
179
-
180
- /** Resolve the banner mode from an environment-variable value. */
181
- export function resolveBannerMode(raw: string | undefined): BannerMode {
182
- const value = (raw ?? "").trim().toLowerCase();
183
- if (value === "off" || value === "0" || value === "false" || value === "none") return "off";
184
- if (value === "static" || value === "plain" || value === "no-animate") return "static";
185
- return "shimmer";
186
- }
187
-
188
- // A synchronous millisecond sleep Atomics.wait blocks the thread until the
189
- // timeout elapses (the value never changes). Used to pace animation frames
190
- // without making register() async, so the whole banner is drawn before pi's
191
- // UI takes over.
192
- const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4));
193
- function sleepSync(ms: number): void {
194
- if (ms > 0) Atomics.wait(SLEEP_SLOT, 0, 0, ms);
195
- }
196
-
197
- /** Options for {@link renderBanner}. */
198
- export interface RenderOptions {
199
- mode?: BannerMode;
200
- text?: string;
201
- frames?: number;
202
- frameMs?: number;
203
- stream?: OutStream;
204
- }
205
-
206
- /**
207
- * Render the ZERO banner synchronously.
208
- *
209
- * On a non-colour stream the banner is printed plain. Otherwise the shimmer
210
- * sweep is animated frame by frame (mode `shimmer`) or skipped (mode `static`),
211
- * settling on the static gradient either way.
212
- */
213
- export function renderBanner(options: RenderOptions = {}): void {
214
- const mode = options.mode ?? resolveBannerMode(process.env.ZERO_BANNER);
215
- if (mode === "off") return;
216
-
217
- const stream: OutStream = options.stream ?? process.stdout;
218
- const lines = bannerLines(options.text ?? "ZERO");
219
- const width = lines[0].length;
220
-
221
- if (!shouldColor(stream)) {
222
- stream.write("\n" + lines.join("\n") + "\n\n");
223
- return;
224
- }
225
-
226
- // Reserve the vertical space the banner will occupy, then redraw in place.
227
- stream.write("\n" + lines.map(() => "").join("\n") + "\n");
228
-
229
- if (mode === "shimmer") {
230
- const frames = Math.max(2, options.frames ?? 28);
231
- const frameMs = Math.max(1, options.frameMs ?? 24);
232
- const startCol = -10;
233
- const endCol = width + 10;
234
- for (let f = 0; f < frames; f++) {
235
- const t = f / (frames - 1);
236
- // ease-in-out so the sweep slows at the edges
237
- const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
238
- const spotlight = startCol + (endCol - startCol) * eased;
239
- stream.write(`\x1b[${ROWS}A\r`);
240
- for (let r = 0; r < ROWS; r++) {
241
- stream.write("\x1b[2K" + paintLine(lines[r], r, spotlight, width) + "\n");
242
- }
243
- sleepSync(frameMs);
244
- }
245
- }
246
-
247
- // Settle on the static gradient.
248
- stream.write(`\x1b[${ROWS}A\r`);
249
- for (let r = 0; r < ROWS; r++) {
250
- stream.write("\x1b[2K" + paintLine(lines[r], r, null, width) + "\n");
251
- }
252
- stream.write("\n");
253
- }
254
-
255
- /**
256
- * The pi extension entry point.
257
- *
258
- * pi calls this once when the extension loads, before the interactive UI
259
- * starts — exactly when the banner should be drawn. The `pi` argument is
260
- * accepted for API compatibility but not needed: the banner is self-contained.
261
- */
262
- export default function register(_pi?: unknown): void {
263
- try {
264
- renderBanner();
265
- } catch {
266
- // A banner failure must never break a pi session.
267
- }
268
- }
1
+ // zero-pi - animated startup banner.
2
+ //
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.
7
+
8
+ type RGB = [number, number, number];
9
+
10
+ const COLORS: Record<string, RGB> = {
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],
18
+ };
19
+
20
+ /** Number of rows in every glyph. */
21
+ const ROWS = 6;
22
+
23
+ // Five-cell-wide glyphs. Non-space characters become visible Tetris cells.
24
+ const FONT: Record<string, string[]> = {
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
+ " ": [" ", " ", " ", " ", " ", " "],
33
+ };
34
+
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);
60
+ for (let r = 0; r < ROWS; r++) {
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 });
69
+ }
70
+ }
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
+ );
95
+ }
96
+
97
+ function lerp(a: number, b: number, t: number): number {
98
+ return a + (b - a) * t;
99
+ }
100
+
101
+ function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
102
+ const k = Math.max(0, Math.min(1, t));
103
+ return [
104
+ Math.round(lerp(c1[0], c2[0], k)),
105
+ Math.round(lerp(c1[1], c2[1], k)),
106
+ Math.round(lerp(c1[2], c2[2], k)),
107
+ ];
108
+ }
109
+
110
+ function ansiFg([r, g, b]: RGB, text: string): string {
111
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
112
+ }
113
+
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 {
123
+ let 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 += " ";
129
+ continue;
130
+ }
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), "[]");
137
+ }
138
+ return out;
139
+ }
140
+
141
+ interface OutStream {
142
+ write(chunk: string): unknown;
143
+ isTTY?: boolean;
144
+ }
145
+
146
+ /** Whether colour output is appropriate for the given stream. */
147
+ function shouldColor(stream: OutStream): boolean {
148
+ if (process.env.NO_COLOR) return false;
149
+ if (process.env.FORCE_COLOR) return true;
150
+ return Boolean(stream && stream.isTTY);
151
+ }
152
+
153
+ /** The three banner modes, resolved from the ZERO_BANNER environment variable. */
154
+ export type BannerMode = "off" | "static" | "shimmer";
155
+
156
+ /** Resolve the banner mode from an environment-variable value. */
157
+ export function resolveBannerMode(raw: string | undefined): BannerMode {
158
+ const value = (raw ?? "").trim().toLowerCase();
159
+ if (value === "off" || value === "0" || value === "false" || value === "none") return "off";
160
+ if (value === "static" || value === "plain" || value === "no-animate") return "static";
161
+ return "shimmer";
162
+ }
163
+
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.
166
+ const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4));
167
+ function sleepSync(ms: number): void {
168
+ if (ms > 0) Atomics.wait(SLEEP_SLOT, 0, 0, ms);
169
+ }
170
+
171
+ function easeOutCubic(t: number): number {
172
+ return 1 - Math.pow(1 - t, 3);
173
+ }
174
+
175
+ /** Options for {@link renderBanner}. */
176
+ export interface RenderOptions {
177
+ mode?: BannerMode;
178
+ text?: string;
179
+ frames?: number;
180
+ frameMs?: number;
181
+ stream?: OutStream;
182
+ }
183
+
184
+ /**
185
+ * Render the ZERO banner synchronously.
186
+ *
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.
189
+ */
190
+ export function renderBanner(options: RenderOptions = {}): void {
191
+ const mode = options.mode ?? resolveBannerMode(process.env.ZERO_BANNER);
192
+ if (mode === "off") return;
193
+
194
+ const stream: OutStream = options.stream ?? process.stdout;
195
+ const matrix = matrixForText(options.text ?? "ZERO");
196
+
197
+ if (!shouldColor(stream)) {
198
+ stream.write("\n" + bannerLines(options.text ?? "ZERO").join("\n") + "\n\n");
199
+ return;
200
+ }
201
+
202
+ stream.write("\n" + Array.from({ length: ROWS }, () => "").join("\n") + "\n");
203
+
204
+ if (mode === "shimmer") {
205
+ const frames = Math.max(2, options.frames ?? 42);
206
+ const frameMs = Math.max(1, options.frameMs ?? 18);
207
+ for (let f = 0; f < frames; f++) {
208
+ const t = easeOutCubic(f / (frames - 1));
209
+ const visibleCells = Math.max(1, Math.ceil(matrix.totalCells * t));
210
+ stream.write(`\x1b[${ROWS}A\r`);
211
+ for (let r = 0; r < ROWS; r++) {
212
+ stream.write("\x1b[2K" + paintMatrixLine(matrix, r, visibleCells) + "\n");
213
+ }
214
+ sleepSync(frameMs);
215
+ }
216
+ }
217
+
218
+ stream.write(`\x1b[${ROWS}A\r`);
219
+ for (let r = 0; r < ROWS; r++) {
220
+ stream.write("\x1b[2K" + paintMatrixLine(matrix, r, matrix.totalCells) + "\n");
221
+ }
222
+ stream.write("\n");
223
+ }
224
+
225
+ /**
226
+ * The pi extension entry point.
227
+ *
228
+ * The pi argument is accepted for API compatibility. The banner is rendered
229
+ * defensively: visual sugar should never break an interactive session.
230
+ */
231
+ export default function register(_pi?: unknown): void {
232
+ try {
233
+ renderBanner();
234
+ } catch {
235
+ // A banner failure must never break a pi session.
236
+ }
237
+ }