@rosetears/aili-pi 0.1.0 → 0.1.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.
Files changed (46) hide show
  1. package/README.md +7 -1
  2. package/THIRD_PARTY_NOTICES.md +11 -0
  3. package/extensions/header/index.ts +92 -0
  4. package/extensions/matrix/index.ts +375 -0
  5. package/extensions/zentui/config.ts +1014 -0
  6. package/extensions/zentui/extension-status.ts +96 -0
  7. package/extensions/zentui/fixed-editor/cluster.ts +98 -0
  8. package/extensions/zentui/fixed-editor/compositor.ts +719 -0
  9. package/extensions/zentui/fixed-editor/index.ts +223 -0
  10. package/extensions/zentui/fixed-editor/input.ts +85 -0
  11. package/extensions/zentui/fixed-editor/pi-compat.ts +296 -0
  12. package/extensions/zentui/fixed-editor/selection.ts +217 -0
  13. package/extensions/zentui/fixed-editor/terminal-modes.ts +75 -0
  14. package/extensions/zentui/fixed-editor/types.ts +37 -0
  15. package/extensions/zentui/footer-format.ts +279 -0
  16. package/extensions/zentui/footer.ts +595 -0
  17. package/extensions/zentui/format.ts +434 -0
  18. package/extensions/zentui/git.ts +384 -0
  19. package/extensions/zentui/gradient.ts +70 -0
  20. package/extensions/zentui/icons.ts +252 -0
  21. package/extensions/zentui/index.ts +577 -0
  22. package/extensions/zentui/live-context.ts +75 -0
  23. package/extensions/zentui/package-version.ts +650 -0
  24. package/extensions/zentui/project-refresh.ts +104 -0
  25. package/extensions/zentui/project-state.ts +59 -0
  26. package/extensions/zentui/prototype-patch-registry.ts +111 -0
  27. package/extensions/zentui/runtime.ts +841 -0
  28. package/extensions/zentui/selector-border.ts +77 -0
  29. package/extensions/zentui/session-lifecycle.ts +60 -0
  30. package/extensions/zentui/settings-command.ts +897 -0
  31. package/extensions/zentui/state.ts +55 -0
  32. package/extensions/zentui/style.ts +332 -0
  33. package/extensions/zentui/thinking-message.ts +159 -0
  34. package/extensions/zentui/tool-execution.ts +189 -0
  35. package/extensions/zentui/ui.ts +618 -0
  36. package/extensions/zentui/user-message.ts +252 -0
  37. package/licenses/pi-sakura-cyberdeck-MIT.txt +21 -0
  38. package/licenses/pi-zentui-MIT.txt +21 -0
  39. package/manifests/provenance.json +11 -0
  40. package/manifests/sbom.json +17 -1
  41. package/notices/pi-sakura-cyberdeck-NOTICE.txt +6 -0
  42. package/package.json +11 -2
  43. package/src/runtime/doctor.ts +1 -1
  44. package/src/runtime/registry.ts +1 -1
  45. package/src/runtime/rem-head.txt +38 -0
  46. package/themes/rem-cyberdeck.json +32 -0
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  - Node.js 22.19.0 or newer
9
9
  - Pi 0.81.1 or newer with the Package and Extension APIs used by this release
10
10
 
11
- macOS and native Windows are not supported by this bootstrap and fail before installation mutation. Theme, TUI, and font resources are deferred to a separate change.
11
+ macOS and native Windows are not supported by this bootstrap and fail before installation mutation.
12
12
 
13
13
  ## Install
14
14
 
@@ -47,6 +47,12 @@ The package does not write global AILI resources during extension load. After re
47
47
 
48
48
  That command creates or updates only the AILI marker block in `~/.pi/agent/APPEND_SYSTEM.md` and installs the 19 packaged profiles at `~/.pi/agent/agents/aili/`. It preserves unrelated prompt content, rejects malformed markers or an unowned profile collision, and reports stale profiles without pruning them.
49
49
 
50
+ ## Rem Cyberdeck
51
+
52
+ The package supplies the `rem-cyberdeck` theme plus three additional Pi Extensions: a Rem header, matrix animation, and Zentui footer/editor surface. They are derived from `pi-sakura-cyberdeck` at revision `165a1f8011a12a58a6409b56b8a6c0416cd9b589` under MIT; see `THIRD_PARTY_NOTICES.md` and `notices/pi-sakura-cyberdeck-NOTICE.txt`.
53
+
54
+ Zentui enables its experimental fixed-bottom editor by default when the installed Pi TUI exposes the required private layout capabilities. If capability detection or installation fails, it keeps Pi's native editor and emits a warning. The feature uses the terminal alternate screen; its default mouse scrolling can interfere with terminal text selection and tmux scrollback. Use Zentui's `/zentui` settings to disable the fixed editor or mouse scrolling.
55
+
50
56
  ## Commands and modes
51
57
 
52
58
  - `/ideate`, `/define`, `/build`, `/ship`: the four AILI delivery modes.
@@ -57,6 +57,17 @@ This distribution is MIT-licensed. The following adapted sources and locked deve
57
57
  - Reused symbols/patterns: web_search, fetch_content, get_search_content, curator and bundled librarian skill
58
58
  - Local changes: AILI initializes the complete pinned upstream surface through its sole Extension entry; no upstream source is copied
59
59
 
60
+ ## pi-sakura-cyberdeck
61
+
62
+ - Status: adapted
63
+ - Source: https://github.com/beautifulrem/pi-sakura-cyberdeck.git
64
+ - Revision: 165a1f8011a12a58a6409b56b8a6c0416cd9b589
65
+ - Version: undefined
66
+ - License: MIT
67
+ - Source files: extensions/header/index.ts, extensions/matrix/index.ts, extensions/zentui/**
68
+ - Reused symbols/patterns: header, matrix animation, Zentui footer, fixed editor compositor
69
+ - Local changes: registered as three additional Pi Package Extensions; header avatar loads the supplied Rem asset; Sakura palette values are replaced with the Rem palette; relative import specifiers and session lifecycle event are adapted for this package's NodeNext TypeScript contract
70
+
60
71
  ## npm dependency inventory
61
72
 
62
73
  The exact 350-entry package-lock inventory, versions, integrity values, dependency scope, and declared licenses is recorded in `manifests/sbom.json`.
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+
5
+ const RESET = "\x1b[0m";
6
+ const BOLD = "\x1b[1m";
7
+
8
+ type RGB = readonly [number, number, number];
9
+
10
+ function rgb([r, g, b]: RGB, text: string, bold = false): string {
11
+ return `${bold ? BOLD : ""}\x1b[38;2;${r};${g};${b}m${text}${RESET}`;
12
+ }
13
+
14
+ function gradient(text: string, from: RGB, to: RGB, bold = false): string {
15
+ const chars = [...text];
16
+ const span = Math.max(1, chars.length - 1);
17
+ return chars.map((char, index) => {
18
+ if (char === " ") return char;
19
+ const t = index / span;
20
+ const color: RGB = [
21
+ Math.round(from[0] + (to[0] - from[0]) * t),
22
+ Math.round(from[1] + (to[1] - from[1]) * t),
23
+ Math.round(from[2] + (to[2] - from[2]) * t),
24
+ ];
25
+ return rgb(color, char, bold);
26
+ }).join("");
27
+ }
28
+
29
+ const ANIME_ART = readFileSync(fileURLToPath(new URL("../../src/runtime/rem-head.txt", import.meta.url)), "utf8").trimEnd().split("\n") as readonly string[];
30
+
31
+ function getAvailableRows(tui: unknown): number {
32
+ try {
33
+ const terminal = (tui as { terminal?: { rows?: unknown } }).terminal;
34
+ const rows = terminal?.rows;
35
+ return typeof rows === "number" && Number.isFinite(rows) ? Math.max(0, Math.floor(rows)) : 0;
36
+ } catch {
37
+ return 0;
38
+ }
39
+ }
40
+
41
+ function renderHeader(width: number, availableRows = 0): string[] {
42
+ if (width <= 0) return [];
43
+
44
+ const sakura: RGB = [136, 184, 255];
45
+ const peach: RGB = [125, 228, 255];
46
+ const lavender: RGB = [188, 167, 255];
47
+ const sky: RGB = [214, 244, 255];
48
+ const telemetry = "◈ REM CYBERDECK ◈";
49
+ const artWidth = Math.max(...ANIME_ART.map((line) => [...line].length));
50
+ const visibleArtWidth = Math.min(width, artWidth);
51
+ const artPad = " ".repeat(Math.max(0, Math.floor((width - visibleArtWidth) / 2) - 2));
52
+ // Keep the divider visually subordinate: inset it symmetrically from the artwork.
53
+ const railInset = visibleArtWidth >= 8 ? Math.max(2, Math.round(visibleArtWidth * 0.15)) : 0;
54
+ const railWidth = Math.max(1, visibleArtWidth - railInset * 2);
55
+ const rail = "━".repeat(railWidth);
56
+ const railPad = " ".repeat(Math.max(0, Math.min(width - railWidth, Math.floor((width - railWidth) / 2) + 1)));
57
+ const visibleTelemetry = [...telemetry].slice(0, width).join("");
58
+ const telemetryWidth = [...visibleTelemetry].length;
59
+ const telemetryPad = " ".repeat(Math.max(0, Math.min(width - telemetryWidth, Math.floor((width - telemetryWidth) / 2) + 1)));
60
+
61
+ const art = ANIME_ART.map((line) => {
62
+ const clipped = [...line].slice(0, visibleArtWidth).join("");
63
+ return `${artPad}${gradient(clipped, sakura, sky)}`;
64
+ });
65
+
66
+ const visualHeight = ANIME_ART.length + 3; // artwork + gap + divider + label
67
+ const extraTopPadding = Math.max(0, Math.floor((availableRows - visualHeight) / 2) - 1);
68
+
69
+ return [
70
+ ...Array(extraTopPadding).fill(""),
71
+ "",
72
+ ...art,
73
+ "",
74
+ `${railPad}${gradient(rail, sakura, sky)}`,
75
+ `${telemetryPad}${gradient(visibleTelemetry, lavender, peach, true)}`,
76
+ "",
77
+ ];
78
+ }
79
+
80
+ export default function sakuraCyberdeckHeader(pi: ExtensionAPI): void {
81
+ pi.on("session_start", (_event, ctx) => {
82
+ if (!ctx.hasUI) return;
83
+ ctx.ui.setHeader((tui) => ({
84
+ render: (width) => renderHeader(width, getAvailableRows(tui)),
85
+ invalidate() {},
86
+ }));
87
+ });
88
+
89
+ pi.on("session_shutdown", (_event, ctx) => {
90
+ if (ctx.hasUI) ctx.ui.setHeader(undefined);
91
+ });
92
+ }
@@ -0,0 +1,375 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ const WIDGET_KEY = "sakura-matrix-engine";
7
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "sakura-cyberdeck-matrix.json");
8
+ const RESET = "\x1b[0m";
9
+ const GLYPHS = [..."0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモラリルレロワン"];
10
+ // Matrix glyphs use foreground colors only. Blend trails into the user's
11
+ // white terminal background to simulate transparency without terminal alpha.
12
+ const BG: RGB = [255, 255, 255];
13
+ const TEXT: RGB = [255, 255, 255];
14
+ const CANDY: readonly RGB[] = [
15
+ [136, 184, 255], // sakura
16
+ [125, 228, 255], // sakura-iro
17
+ [188, 167, 255], // petal
18
+ [188, 167, 255], // lavender
19
+ [125, 228, 255], // sky
20
+ [140, 230, 194], // mint
21
+ ];
22
+ const WORKING_INDICATOR = "◆";
23
+ const PHASE_MESSAGES: Record<Phase, string> = {
24
+ thinking: "Weaving the next move…",
25
+ working: "Composing the response…",
26
+ tool: "Running tools…",
27
+ };
28
+
29
+ type RGB = readonly [number, number, number];
30
+ type Phase = "thinking" | "working" | "tool";
31
+ type Timer = ReturnType<typeof setTimeout>;
32
+
33
+ interface MatrixConfig {
34
+ enabled: boolean;
35
+ fps: number;
36
+ density: number;
37
+ height: number;
38
+ }
39
+
40
+ interface Drop {
41
+ x: number;
42
+ offset: number;
43
+ speed: number;
44
+ length: number;
45
+ gap: number;
46
+ seed: number;
47
+ color: RGB;
48
+ }
49
+
50
+ const DEFAULT_CONFIG: MatrixConfig = {
51
+ enabled: true,
52
+ fps: 10,
53
+ density: 0.65,
54
+ height: 4,
55
+ };
56
+
57
+ function clamp(value: number, min: number, max: number): number {
58
+ return Math.min(max, Math.max(min, value));
59
+ }
60
+
61
+ function loadConfig(): MatrixConfig {
62
+ try {
63
+ if (!existsSync(CONFIG_PATH)) return { ...DEFAULT_CONFIG };
64
+ const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<MatrixConfig>;
65
+ return {
66
+ enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
67
+ fps: clamp(Number(parsed.fps) || DEFAULT_CONFIG.fps, 8, 18),
68
+ density: clamp(Number(parsed.density) || DEFAULT_CONFIG.density, 0.45, 0.95),
69
+ height: clamp(Math.round(Number(parsed.height) || DEFAULT_CONFIG.height), 3, 6),
70
+ };
71
+ } catch {
72
+ return { ...DEFAULT_CONFIG };
73
+ }
74
+ }
75
+
76
+ function saveConfig(config: MatrixConfig): void {
77
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
78
+ }
79
+
80
+ function mulberry32(seed: number): () => number {
81
+ let value = seed >>> 0;
82
+ return () => {
83
+ value += 0x6d2b79f5;
84
+ let n = value;
85
+ n = Math.imul(n ^ (n >>> 15), n | 1);
86
+ n ^= n + Math.imul(n ^ (n >>> 7), n | 61);
87
+ return ((n ^ (n >>> 14)) >>> 0) / 4294967296;
88
+ };
89
+ }
90
+
91
+ function mix(from: RGB, to: RGB, amount: number): RGB {
92
+ return [
93
+ Math.round(from[0] + (to[0] - from[0]) * amount),
94
+ Math.round(from[1] + (to[1] - from[1]) * amount),
95
+ Math.round(from[2] + (to[2] - from[2]) * amount),
96
+ ];
97
+ }
98
+
99
+ function colorize(char: string, color: RGB, bold = false): string {
100
+ return `\x1b[${bold ? "1;" : ""}38;2;${color[0]};${color[1]};${color[2]}m${char}${RESET}`;
101
+ }
102
+
103
+ function workingIndicatorFrame(): string {
104
+ return colorize(WORKING_INDICATOR, CANDY[0] ?? [136, 184, 255], true);
105
+ }
106
+
107
+ function stableGlyph(seed: number, row: number, timeSlice: number): string {
108
+ let hash = Math.imul(seed ^ (row + 17), 0x45d9f3b);
109
+ hash = Math.imul(hash ^ timeSlice, 0x45d9f3b);
110
+ hash ^= hash >>> 16;
111
+ return GLYPHS[Math.abs(hash) % GLYPHS.length] ?? "0";
112
+ }
113
+
114
+ export function createDrops(width: number, density: number, height: number): Drop[] {
115
+ const random = mulberry32((width * 2654435761) ^ 0x53414b55);
116
+ const columns = Array.from({ length: Math.ceil(width / 2) }, (_, index) => index * 2);
117
+ const active = columns.filter(() => random() < density);
118
+ const selected = active.length >= 8 ? active : columns.slice(0, Math.min(columns.length, 8));
119
+ return selected.slice(0, 96).map((x, index) => {
120
+ const length = 3 + Math.floor(random() * 5);
121
+ const gap = 1 + Math.floor(random() * 5);
122
+ const cycle = height + length + gap;
123
+ return {
124
+ x,
125
+ offset: random() * cycle,
126
+ speed: 5.5 + random() * 7.5,
127
+ length,
128
+ gap,
129
+ seed: Math.floor(random() * 0x7fffffff) ^ (index * 7919),
130
+ color: CANDY[index % CANDY.length] ?? [136, 184, 255],
131
+ };
132
+ });
133
+ }
134
+
135
+ export function renderSakuraMatrix(
136
+ width: number,
137
+ height: number,
138
+ elapsedSeconds: number,
139
+ phase: Phase,
140
+ drops: readonly Drop[],
141
+ ): string[] {
142
+ const safeWidth = Math.max(1, width);
143
+ const safeHeight = clamp(height, 3, 6);
144
+ const grid: string[][] = Array.from({ length: safeHeight }, () => Array(safeWidth).fill(" "));
145
+ const timeSlice = Math.floor(elapsedSeconds * 8);
146
+ const phaseSpeed = phase === "tool" ? 1.12 : phase === "thinking" ? 1.06 : 1;
147
+
148
+ for (const drop of drops) {
149
+ const cycle = safeHeight + drop.length + drop.gap;
150
+ const head = ((drop.offset + elapsedSeconds * drop.speed * phaseSpeed) % cycle) - drop.gap;
151
+ for (let trail = 0; trail < drop.length; trail++) {
152
+ const row = Math.floor(head - trail);
153
+ if (row < 0 || row >= safeHeight || drop.x >= safeWidth) continue;
154
+ const glyph = stableGlyph(drop.seed + trail * 97, row, timeSlice);
155
+ const color = trail === 0
156
+ ? mix(drop.color, TEXT, 0.58)
157
+ : trail === 1
158
+ ? drop.color
159
+ : mix(drop.color, BG, clamp((trail - 1) * 0.16, 0, 0.72));
160
+ const gridRow = grid[row];
161
+ if (gridRow) gridRow[drop.x] = colorize(glyph, color, trail <= 1);
162
+ }
163
+ }
164
+
165
+ return grid.map((row) => `${row.join("")}${RESET}`);
166
+ }
167
+
168
+ export default function sakuraMatrixExtension(pi: ExtensionAPI): void {
169
+ const config = loadConfig();
170
+ let activeContext: ExtensionContext | undefined;
171
+ let phase: Phase = "working";
172
+ let active = false;
173
+ let timer: Timer | undefined;
174
+ let startedAt = 0;
175
+ let nextDeadline = 0;
176
+ let lastHostUpdateAt = 0;
177
+ let frame = 0;
178
+ let generation = 0;
179
+ let requestRender: (() => void) | undefined;
180
+ let cachedKey = "";
181
+ let cachedLines: string[] = [];
182
+ const dropsByWidth = new Map<number, Drop[]>();
183
+
184
+ const invalidate = () => {
185
+ cachedKey = "";
186
+ cachedLines = [];
187
+ };
188
+
189
+ const component = {
190
+ render(width: number): string[] {
191
+ const safeWidth = Math.max(1, width);
192
+ const key = `${safeWidth}:${config.height}:${frame}:${phase}`;
193
+ if (key === cachedKey) return cachedLines;
194
+ let drops = dropsByWidth.get(safeWidth);
195
+ if (!drops) {
196
+ drops = createDrops(safeWidth, config.density, config.height);
197
+ if (dropsByWidth.size >= 4) {
198
+ dropsByWidth.delete(dropsByWidth.keys().next().value ?? safeWidth);
199
+ }
200
+ dropsByWidth.set(safeWidth, drops);
201
+ }
202
+ cachedLines = renderSakuraMatrix(
203
+ safeWidth,
204
+ config.height,
205
+ Math.max(0, performance.now() - startedAt) / 1000,
206
+ phase,
207
+ drops,
208
+ );
209
+ cachedKey = key;
210
+ return cachedLines;
211
+ },
212
+ invalidate(): void {
213
+ dropsByWidth.clear();
214
+ invalidate();
215
+ },
216
+ };
217
+
218
+ const clearTimer = () => {
219
+ if (timer) clearTimeout(timer);
220
+ timer = undefined;
221
+ };
222
+
223
+ const schedule = (token: number) => {
224
+ if (!active || token !== generation) return;
225
+ const frameMs = 1000 / config.fps;
226
+ const now = performance.now();
227
+ if (now - nextDeadline > frameMs * 3) nextDeadline = now;
228
+ nextDeadline += frameMs;
229
+ timer = setTimeout(() => {
230
+ if (!active || token !== generation) return;
231
+ frame += 1;
232
+ invalidate();
233
+ // Streaming and tool updates already schedule a host render. Avoid adding
234
+ // a second full TUI pass when one occurred within this frame window.
235
+ if (performance.now() - lastHostUpdateAt >= frameMs) requestRender?.();
236
+ schedule(token);
237
+ }, Math.max(16, nextDeadline - performance.now()));
238
+ timer.unref?.();
239
+ };
240
+
241
+ const stop = () => {
242
+ generation += 1;
243
+ active = false;
244
+ clearTimer();
245
+ const ctx = activeContext;
246
+ activeContext = undefined;
247
+ requestRender = undefined;
248
+ lastHostUpdateAt = 0;
249
+ invalidate();
250
+ if (!ctx) return;
251
+ try {
252
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
253
+ ctx.ui.setWorkingMessage();
254
+ ctx.ui.setWorkingIndicator();
255
+ ctx.ui.setWorkingVisible(true);
256
+ } catch {
257
+ // UI may already be disposed during shutdown; cleanup remains idempotent.
258
+ }
259
+ };
260
+
261
+ const start = (ctx: ExtensionContext, initialPhase: Phase = "working") => {
262
+ stop();
263
+ if (!config.enabled || ctx.mode !== "tui") return;
264
+ activeContext = ctx;
265
+ active = true;
266
+ phase = initialPhase;
267
+ frame = 0;
268
+ startedAt = performance.now();
269
+ nextDeadline = startedAt;
270
+ lastHostUpdateAt = 0;
271
+ dropsByWidth.clear();
272
+ invalidate();
273
+ const token = generation;
274
+ ctx.ui.setWorkingVisible(true);
275
+ // The matrix owns the only animation clock; a static indicator prevents a
276
+ // second independent timer from forcing redundant full-screen renders.
277
+ ctx.ui.setWorkingIndicator({ frames: [workingIndicatorFrame()] });
278
+ ctx.ui.setWorkingMessage(PHASE_MESSAGES[phase]);
279
+ ctx.ui.setWidget(WIDGET_KEY, (tui) => {
280
+ requestRender = () => tui.requestRender();
281
+ return component;
282
+ });
283
+ schedule(token);
284
+ };
285
+
286
+ const noteHostUpdate = () => {
287
+ lastHostUpdateAt = performance.now();
288
+ };
289
+
290
+ const setPhase = (next: Phase) => {
291
+ noteHostUpdate();
292
+ if (!active || phase === next) return;
293
+ phase = next;
294
+ activeContext?.ui.setWorkingMessage(PHASE_MESSAGES[phase]);
295
+ frame += 1;
296
+ invalidate();
297
+ };
298
+
299
+ pi.on("agent_start", (_event, ctx) => start(ctx));
300
+ pi.on("agent_end", () => stop());
301
+ pi.on("session_before_switch", () => stop());
302
+ pi.on("session_shutdown", () => stop());
303
+
304
+ pi.on("message_update", (event) => {
305
+ noteHostUpdate();
306
+ const streamEvent = event.assistantMessageEvent as { type?: string } | undefined;
307
+ if (!streamEvent?.type) return;
308
+ if (streamEvent.type === "thinking_start" || streamEvent.type === "thinking_delta") {
309
+ setPhase("thinking");
310
+ } else if (streamEvent.type === "thinking_end" || streamEvent.type === "text_delta") {
311
+ setPhase("working");
312
+ }
313
+ });
314
+
315
+ pi.on("tool_execution_start", () => setPhase("tool"));
316
+ pi.on("tool_execution_update", () => noteHostUpdate());
317
+ pi.on("tool_execution_end", () => setPhase("working"));
318
+
319
+ pi.registerCommand("sakura-matrix", {
320
+ description: "Sakura Matrix animation: status, on, off, preview, fps <8-18>, density <0.45-0.95>",
321
+ handler: async (args, ctx) => {
322
+ const [command = "status", value] = args.trim().toLowerCase().split(/\s+/);
323
+ if (command === "on") {
324
+ config.enabled = true;
325
+ saveConfig(config);
326
+ ctx.ui.notify("Sakura Matrix enabled", "info");
327
+ return;
328
+ }
329
+ if (command === "off") {
330
+ config.enabled = false;
331
+ saveConfig(config);
332
+ stop();
333
+ ctx.ui.notify("Sakura Matrix disabled", "info");
334
+ return;
335
+ }
336
+ if (command === "preview") {
337
+ start(ctx, "thinking");
338
+ const previewToken = generation;
339
+ const previewTimer = setTimeout(() => {
340
+ if (generation === previewToken) stop();
341
+ }, 5000);
342
+ previewTimer.unref?.();
343
+ ctx.ui.notify("Sakura Matrix preview: 5 seconds", "info");
344
+ return;
345
+ }
346
+ if (command === "fps") {
347
+ const fps = Number(value);
348
+ if (!Number.isFinite(fps) || fps < 8 || fps > 18) {
349
+ ctx.ui.notify("Usage: /sakura-matrix fps <8-18>", "error");
350
+ return;
351
+ }
352
+ config.fps = Math.round(fps);
353
+ saveConfig(config);
354
+ ctx.ui.notify(`Sakura Matrix FPS: ${config.fps}`, "info");
355
+ return;
356
+ }
357
+ if (command === "density") {
358
+ const density = Number(value);
359
+ if (!Number.isFinite(density) || density < 0.45 || density > 0.95) {
360
+ ctx.ui.notify("Usage: /sakura-matrix density <0.45-0.95>", "error");
361
+ return;
362
+ }
363
+ config.density = Math.round(density * 100) / 100;
364
+ dropsByWidth.clear();
365
+ saveConfig(config);
366
+ ctx.ui.notify(`Sakura Matrix density: ${config.density}`, "info");
367
+ return;
368
+ }
369
+ ctx.ui.notify(
370
+ `Sakura Matrix: ${config.enabled ? "on" : "off"} · ${config.fps} FPS · ${config.height} lines · density ${config.density}`,
371
+ "info",
372
+ );
373
+ },
374
+ });
375
+ }