@gonrocca/zero-pi 0.1.9 → 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.
@@ -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 animated startup banner.
1
+ // zero-pi - animated startup banner.
2
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.
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
- purpleDim: [82, 70, 124],
24
- purpleMid: [157, 124, 216],
25
- amber: [255, 180, 84],
26
- peak: [255, 240, 210],
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 of the ANSI Shadow font. */
20
+ /** Number of rows in every glyph. */
30
21
  const ROWS = 6;
31
22
 
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.
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
- /** 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[" "];
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
- lines[r] += glyph[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 });
102
69
  }
103
70
  }
104
- return lines;
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
- * 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;
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
- /** 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 {
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
- 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 += " ";
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 [r, g, b] = colorAt(i, row, spotlight, width);
158
- out += `\x1b[38;2;${r};${g};${b}m${ch}`;
159
- colored = true;
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
- // 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.
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 shimmer
210
- * sweep is animated frame by frame (mode `shimmer`) or skipped (mode `static`),
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 lines = bannerLines(options.text ?? "ZERO");
219
- const width = lines[0].length;
195
+ const matrix = matrixForText(options.text ?? "ZERO");
220
196
 
221
197
  if (!shouldColor(stream)) {
222
- stream.write("\n" + lines.join("\n") + "\n\n");
198
+ stream.write("\n" + bannerLines(options.text ?? "ZERO").join("\n") + "\n\n");
223
199
  return;
224
200
  }
225
201
 
226
- // Reserve the vertical space the banner will occupy, then redraw in place.
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 ?? 28);
231
- const frameMs = Math.max(1, options.frameMs ?? 24);
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
- // 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;
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" + paintLine(lines[r], r, spotlight, width) + "\n");
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" + paintLine(lines[r], r, null, width) + "\n");
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 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.
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 {