@rosetears/aili-pi 0.1.7 → 0.1.9

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,42 +1,52 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "node:fs";
2
11
  import { homedir } from "node:os";
3
- import { join } from "node:path";
12
+ import { basename, dirname, join } from "node:path";
4
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
15
 
6
- const WIDGET_KEY = "sakura-matrix-engine";
7
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "sakura-cyberdeck-matrix.json");
16
+ const WIDGET_KEY = "rose-matrix-engine";
17
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "rose-cyberdeck-matrix.json");
18
+ const LEGACY_CONFIG_PATH = join(homedir(), ".pi", "agent", "sakura-cyberdeck-matrix.json");
8
19
  const RESET = "\x1b[0m";
9
- export const SAKURA_MATRIX_GLYPHS = [..."0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモラリルレロワン"];
10
- const BG: RGB = [20, 17, 26];
11
- const TEXT: RGB = [247, 238, 248];
12
- const CANDY: readonly RGB[] = [
13
- [242, 167, 198], // sakura
14
- [252, 201, 185], // sakura-iro
15
- [239, 195, 230], // petal
16
- [199, 184, 245], // lavender
17
- [159, 211, 242], // sky
18
- [174, 229, 197], // mint
19
- ];
20
- const WORKING_INDICATOR = "◆";
21
20
  const MAX_DROPS = 96;
22
- const PHASE_MESSAGES: Record<Phase, string> = {
23
- thinking: "Weaving the next move…",
24
- working: "Composing the response…",
25
- tool: "Running tools…",
26
- };
21
+ const SHIMMER_STEP_MS = 120;
22
+ const TOOL_GRADIENT: readonly RGB[] = [[188, 167, 255], [125, 228, 255]];
23
+
24
+ export const ROSE_MATRIX_GLYPHS = [..."0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモラリルレロワン"];
25
+ /** The six user-selected rain colors, assigned by the exact weights below. */
26
+ export const ROSE_RAIN_PALETTE: readonly RGB[] = [
27
+ [136, 184, 255], [214, 244, 255], [125, 228, 255],
28
+ [188, 167, 255], [199, 91, 122], [232, 167, 184],
29
+ ];
30
+ export const ROSE_RAIN_WEIGHTS = { blue: 50, ice: 20, cyan: 15, violet: 8, rose: 4, roseSoft: 3 } as const;
31
+ export const ROSE_SHIMMER_INDICATOR = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"] as const;
27
32
 
28
33
  type RGB = readonly [number, number, number];
29
- type Phase = "thinking" | "working" | "tool";
34
+ export type Appearance = "auto" | "dark" | "light";
35
+ export type ResolvedAppearance = Exclude<Appearance, "auto">;
36
+ export type Phase = "requesting" | "thinking" | "working" | "tool";
30
37
  type Timer = ReturnType<typeof setTimeout>;
31
38
 
32
- interface MatrixConfig {
39
+ export interface MatrixConfig {
40
+ version: 2;
33
41
  enabled: boolean;
34
42
  fps: number;
35
43
  density: number;
36
- height: number;
44
+ height: 4;
45
+ appearance: Appearance;
37
46
  }
38
47
 
39
- interface Drop {
48
+ export interface Drop {
49
+ /** Fixed terminal-cell column; vertical motion never changes this value. */
40
50
  x: number;
41
51
  offset: number;
42
52
  speed: number;
@@ -46,34 +56,113 @@ interface Drop {
46
56
  color: RGB;
47
57
  }
48
58
 
59
+ type ConfigLoadResult = { config: MatrixConfig; migrated: boolean; warning?: string };
60
+
49
61
  const DEFAULT_CONFIG: MatrixConfig = {
62
+ version: 2,
50
63
  enabled: true,
51
- fps: 10,
64
+ fps: 12,
52
65
  density: 0.65,
53
66
  height: 4,
67
+ appearance: "auto",
68
+ };
69
+
70
+ const PHASE_MESSAGES: Record<Phase, string> = {
71
+ requesting: "Connecting to the model…",
72
+ thinking: "Weaving the next move…",
73
+ working: "Composing the response…",
74
+ tool: "Running tools…",
75
+ };
76
+
77
+ const DARK = {
78
+ fade: [16, 18, 29] as RGB,
79
+ base: [136, 184, 255] as RGB,
80
+ highlight: [214, 244, 255] as RGB,
81
+ indicator: [199, 91, 122] as RGB,
82
+ };
83
+ const LIGHT = {
84
+ fade: [250, 247, 242] as RGB,
85
+ base: [92, 115, 151] as RGB,
86
+ highlight: [42, 38, 34] as RGB,
87
+ indicator: [168, 69, 95] as RGB,
54
88
  };
55
89
 
56
90
  function clamp(value: number, min: number, max: number): number {
57
91
  return Math.min(max, Math.max(min, value));
58
92
  }
59
93
 
60
- function loadConfig(): MatrixConfig {
94
+ function isRecord(value: unknown): value is Record<string, unknown> {
95
+ return value !== null && typeof value === "object" && !Array.isArray(value);
96
+ }
97
+
98
+ function isSafeRegularFile(path: string): boolean {
61
99
  try {
62
- if (!existsSync(CONFIG_PATH)) return { ...DEFAULT_CONFIG };
63
- const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<MatrixConfig>;
64
- return {
65
- enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
66
- fps: clamp(Number(parsed.fps) || DEFAULT_CONFIG.fps, 8, 18),
67
- density: clamp(Number(parsed.density) || DEFAULT_CONFIG.density, 0.45, 0.95),
68
- height: clamp(Math.round(Number(parsed.height) || DEFAULT_CONFIG.height), 3, 6),
69
- };
100
+ const link = lstatSync(path);
101
+ return !link.isSymbolicLink() && statSync(path).isFile();
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function parseConfig(value: unknown): MatrixConfig {
108
+ const parsed = isRecord(value) ? value : {};
109
+ return {
110
+ version: 2,
111
+ enabled: typeof parsed.enabled === "boolean" ? parsed.enabled : DEFAULT_CONFIG.enabled,
112
+ fps: clamp(Math.round(Number(parsed.fps) || DEFAULT_CONFIG.fps), 8, 18),
113
+ density: clamp(Number(parsed.density) || DEFAULT_CONFIG.density, 0.45, 0.95),
114
+ height: 4,
115
+ appearance: parsed.appearance === "dark" || parsed.appearance === "light" || parsed.appearance === "auto"
116
+ ? parsed.appearance
117
+ : "auto",
118
+ };
119
+ }
120
+
121
+ function writeConfigAtomically(path: string, config: MatrixConfig): void {
122
+ mkdirSync(dirname(path), { recursive: true });
123
+ if (existsSync(path) && !isSafeRegularFile(path)) {
124
+ throw new Error(`Refusing to overwrite unsafe Matrix config: ${path}`);
125
+ }
126
+ const temp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`);
127
+ try {
128
+ writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
129
+ renameSync(temp, path);
130
+ } catch (error) {
131
+ try { unlinkSync(temp); } catch {}
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ export function loadRoseMatrixConfig(
137
+ path = CONFIG_PATH,
138
+ legacyPath = LEGACY_CONFIG_PATH,
139
+ ): ConfigLoadResult {
140
+ if (existsSync(path)) {
141
+ if (!isSafeRegularFile(path)) return { config: { ...DEFAULT_CONFIG }, migrated: false, warning: "Rose Matrix config is unsafe; using runtime defaults." };
142
+ try {
143
+ return { config: parseConfig(JSON.parse(readFileSync(path, "utf8"))), migrated: false };
144
+ } catch {
145
+ return { config: { ...DEFAULT_CONFIG }, migrated: false, warning: "Rose Matrix config is corrupt; using runtime defaults." };
146
+ }
147
+ }
148
+ if (!existsSync(legacyPath)) return { config: { ...DEFAULT_CONFIG }, migrated: false };
149
+ if (!isSafeRegularFile(legacyPath)) return { config: { ...DEFAULT_CONFIG }, migrated: false, warning: "Legacy Sakura Matrix config is unsafe; using runtime defaults." };
150
+ try {
151
+ const legacy = JSON.parse(readFileSync(legacyPath, "utf8"));
152
+ if (!isRecord(legacy)) throw new Error("not an object");
153
+ const config = parseConfig(legacy);
154
+ writeConfigAtomically(path, config);
155
+ return { config, migrated: true, warning: Number(legacy.height) !== 4 ? "Legacy Matrix height was normalized to four rows." : undefined };
70
156
  } catch {
71
- return { ...DEFAULT_CONFIG };
157
+ return { config: { ...DEFAULT_CONFIG }, migrated: false, warning: "Legacy Sakura Matrix config is corrupt; using runtime defaults." };
72
158
  }
73
159
  }
74
160
 
75
- function saveConfig(config: MatrixConfig): void {
76
- writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
161
+ export function resolveAppearance(appearance: Appearance, themeName: string | undefined): ResolvedAppearance | undefined {
162
+ if (appearance === "dark" || appearance === "light") return appearance;
163
+ if (themeName === "light") return "light";
164
+ if (themeName === "dark" || themeName === "rose-cyberdeck" || themeName === "rem-cyberdeck") return "dark";
165
+ return undefined;
77
166
  }
78
167
 
79
168
  function mulberry32(seed: number): () => number {
@@ -99,27 +188,38 @@ function colorize(char: string, color: RGB, bold = false): string {
99
188
  return `\x1b[${bold ? "1;" : ""}38;2;${color[0]};${color[1]};${color[2]}m${char}${RESET}`;
100
189
  }
101
190
 
102
- function workingIndicatorFrame(): string {
103
- return colorize(WORKING_INDICATOR, CANDY[0] ?? [242, 167, 198], true);
191
+ function fillWidth(content: string, width: number): string {
192
+ const clipped = truncateToWidth(content, Math.max(1, width), "");
193
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
104
194
  }
105
195
 
106
196
  function stableGlyph(seed: number, row: number, timeSlice: number): string {
107
197
  let hash = Math.imul(seed ^ (row + 17), 0x45d9f3b);
108
198
  hash = Math.imul(hash ^ timeSlice, 0x45d9f3b);
109
199
  hash ^= hash >>> 16;
110
- return SAKURA_MATRIX_GLYPHS[Math.abs(hash) % SAKURA_MATRIX_GLYPHS.length] ?? "0";
200
+ return ROSE_MATRIX_GLYPHS[Math.abs(hash) % ROSE_MATRIX_GLYPHS.length] ?? "0";
111
201
  }
112
202
 
113
203
  function selectBoundedColumns(columns: readonly number[]): number[] {
114
204
  if (columns.length <= MAX_DROPS) return [...columns];
115
205
  const lastIndex = columns.length - 1;
116
- return Array.from({ length: MAX_DROPS }, (_, index) => {
117
- const sourceIndex = Math.round((index * lastIndex) / (MAX_DROPS - 1));
118
- return columns[sourceIndex] ?? 0;
119
- });
206
+ return Array.from({ length: MAX_DROPS }, (_, index) => columns[Math.round((index * lastIndex) / (MAX_DROPS - 1))] ?? 0);
120
207
  }
121
208
 
122
- export function createDrops(width: number, density: number, height: number): Drop[] {
209
+ /** Exact 50/20/15/8/4/3 distribution over each deterministic 100-track cycle. */
210
+ export function roseRainColor(index: number): RGB {
211
+ // A coprime stride distributes all weights through the 96-track ceiling,
212
+ // instead of leaving the final Rose/Soft Rose buckets unreachable.
213
+ const bucket = (((index * 37) % 100) + 100) % 100;
214
+ if (bucket < 50) return ROSE_RAIN_PALETTE[0]!;
215
+ if (bucket < 70) return ROSE_RAIN_PALETTE[1]!;
216
+ if (bucket < 85) return ROSE_RAIN_PALETTE[2]!;
217
+ if (bucket < 93) return ROSE_RAIN_PALETTE[3]!;
218
+ if (bucket < 97) return ROSE_RAIN_PALETTE[4]!;
219
+ return ROSE_RAIN_PALETTE[5]!;
220
+ }
221
+
222
+ export function createDrops(width: number, density: number, height = 4): Drop[] {
123
223
  const random = mulberry32((width * 2654435761) ^ 0x53414b55);
124
224
  const columns = Array.from({ length: Math.ceil(width / 2) }, (_, index) => index * 2);
125
225
  const active = columns.filter(() => random() < density);
@@ -131,52 +231,126 @@ export function createDrops(width: number, density: number, height: number): Dro
131
231
  return {
132
232
  x,
133
233
  offset: random() * cycle,
134
- speed: 5.5 + random() * 7.5,
234
+ // Between the released waterfall (5.5–13) and dense preview (18).
235
+ speed: 8 + random() * 8,
135
236
  length,
136
237
  gap,
137
238
  seed: Math.floor(random() * 0x7fffffff) ^ (index * 7919),
138
- color: CANDY[index % CANDY.length] ?? [242, 167, 198],
239
+ color: roseRainColor(index),
139
240
  };
140
241
  });
141
242
  }
142
243
 
143
- export function renderSakuraMatrix(
244
+ function appearanceColor(color: RGB, appearance: ResolvedAppearance): RGB {
245
+ if (appearance === "dark") return color;
246
+ if (color[0] === 199 && color[1] === 91) return LIGHT.indicator;
247
+ if (color[0] === 232 && color[1] === 167) return [168, 69, 95];
248
+ if (color[0] === 188 && color[1] === 167) return [119, 106, 151];
249
+ if (color[0] === 214 || color[1] === 228) return [78, 120, 129];
250
+ return LIGHT.base;
251
+ }
252
+
253
+ function repairBlankRows(grid: string[][], width: number, appearance: ResolvedAppearance, elapsedSeconds: number): void {
254
+ const blanks = grid.map((row) => row.every((cell) => cell === " "));
255
+ if (!blanks.some(Boolean)) return;
256
+ const seed = Math.floor(elapsedSeconds * 8) ^ (width * 7919) ^ 0x524f5345;
257
+ const column = Math.max(0, Math.min(width - 1, Math.abs(seed) % Math.max(1, width)));
258
+ const fallback = appearance === "dark" ? [125, 228, 255] as RGB : [78, 120, 129] as RGB;
259
+ const occupied = grid.findIndex((row) => row.some((cell) => cell !== " "));
260
+ for (let row = 0; row < grid.length; row += 1) {
261
+ if (!blanks[row]) continue;
262
+ const glyph = stableGlyph(seed + row * 97 + Math.max(0, occupied), row, Math.floor(elapsedSeconds * 8));
263
+ grid[row]![column] = colorize(glyph, fallback, row === 0 || row === grid.length - 1);
264
+ }
265
+ }
266
+
267
+ export function renderRoseMatrix(
144
268
  width: number,
145
269
  height: number,
146
270
  elapsedSeconds: number,
147
271
  phase: Phase,
148
272
  drops: readonly Drop[],
273
+ appearance: ResolvedAppearance = "dark",
149
274
  ): string[] {
150
275
  const safeWidth = Math.max(1, width);
151
- const safeHeight = clamp(height, 3, 6);
276
+ const safeHeight = 4;
152
277
  const grid: string[][] = Array.from({ length: safeHeight }, () => Array(safeWidth).fill(" "));
153
278
  const timeSlice = Math.floor(elapsedSeconds * 8);
154
279
  const phaseSpeed = phase === "tool" ? 1.12 : phase === "thinking" ? 1.06 : 1;
280
+ const palette = appearance === "dark" ? DARK : LIGHT;
155
281
 
156
282
  for (const drop of drops) {
157
283
  const cycle = safeHeight + drop.length + drop.gap;
158
284
  const head = ((drop.offset + elapsedSeconds * drop.speed * phaseSpeed) % cycle) - drop.gap;
159
- for (let trail = 0; trail < drop.length; trail++) {
285
+ for (let trail = 0; trail < drop.length; trail += 1) {
160
286
  const row = Math.floor(head - trail);
161
287
  if (row < 0 || row >= safeHeight || drop.x >= safeWidth) continue;
162
288
  const glyph = stableGlyph(drop.seed + trail * 97, row, timeSlice);
289
+ const trackColor = appearanceColor(drop.color, appearance);
163
290
  const color = trail === 0
164
- ? mix(drop.color, TEXT, 0.58)
291
+ ? mix(trackColor, palette.highlight, 0.58)
165
292
  : trail === 1
166
- ? drop.color
167
- : mix(drop.color, BG, clamp((trail - 1) * 0.16, 0, 0.72));
168
- const gridRow = grid[row];
169
- if (gridRow) gridRow[drop.x] = colorize(glyph, color, trail <= 1);
293
+ ? trackColor
294
+ : mix(trackColor, palette.fade, clamp((trail - 1) * 0.16, 0, 0.72));
295
+ grid[row]![drop.x] = colorize(glyph, color, trail <= 1);
170
296
  }
171
297
  }
298
+ repairBlankRows(grid, safeWidth, appearance, elapsedSeconds);
299
+ return grid.map((row) => fillWidth(`${row.join("")}${RESET}`, safeWidth));
300
+ }
301
+
302
+ function shimmerIndex(elapsedMs: number): number {
303
+ return Math.floor(Math.max(0, elapsedMs) / SHIMMER_STEP_MS) % ROSE_SHIMMER_INDICATOR.length;
304
+ }
305
+
306
+ function formatElapsed(elapsedMs: number): string | undefined {
307
+ const seconds = Math.floor(Math.max(0, elapsedMs) / 1000);
308
+ if (seconds < 30) return undefined;
309
+ if (seconds < 60) return `${seconds}s`;
310
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
311
+ }
312
+
313
+ export function renderRoseShimmer(
314
+ width: number,
315
+ phase: Phase,
316
+ elapsedMs: number,
317
+ outputTokens: number | undefined,
318
+ appearance: ResolvedAppearance,
319
+ ): string {
320
+ const palette = appearance === "dark" ? DARK : LIGHT;
321
+ const indicator = colorize(ROSE_SHIMMER_INDICATOR[shimmerIndex(elapsedMs)]!, palette.indicator, true);
322
+ const message = PHASE_MESSAGES[phase];
323
+ const positions = Math.max(1, message.length - 3);
324
+ const raw = Math.floor(elapsedMs / SHIMMER_STEP_MS) % (positions * 2);
325
+ const start = raw < positions ? raw : positions * 2 - raw - 1;
326
+ const text = [...message].map((char, index) => {
327
+ const base = phase === "tool"
328
+ ? TOOL_GRADIENT[index % TOOL_GRADIENT.length]!
329
+ : palette.base;
330
+ return colorize(char, index >= start && index < start + 4 ? palette.highlight : appearanceColor(base, appearance));
331
+ }).join("");
332
+ const suffix = [formatElapsed(elapsedMs), outputTokens && outputTokens > 0 ? `${outputTokens} output tokens` : undefined]
333
+ .filter((value): value is string => Boolean(value))
334
+ .join(" · ");
335
+ const body = suffix ? `${indicator} ${text} ${suffix}` : `${indicator} ${text}`;
336
+ return fillWidth(body, Math.max(1, width));
337
+ }
338
+
339
+ function assistantUsage(message: unknown): number | undefined {
340
+ if (!isRecord(message) || !isRecord(message.usage)) return undefined;
341
+ const output = message.usage.output;
342
+ return typeof output === "number" && Number.isFinite(output) && output > 0 ? Math.floor(output) : undefined;
343
+ }
172
344
 
173
- return grid.map((row) => `${row.join("")}${RESET}`);
345
+ function isAssistantMessage(message: unknown): boolean {
346
+ return isRecord(message) && message.role === "assistant";
174
347
  }
175
348
 
176
- export default function sakuraMatrixExtension(pi: ExtensionAPI): void {
177
- const config = loadConfig();
349
+ export default function roseMatrixExtension(pi: ExtensionAPI): void {
350
+ const loaded = loadRoseMatrixConfig();
351
+ const config = loaded.config;
178
352
  let activeContext: ExtensionContext | undefined;
179
- let phase: Phase = "working";
353
+ let phase: Phase = "requesting";
180
354
  let active = false;
181
355
  let timer: Timer | undefined;
182
356
  let startedAt = 0;
@@ -187,49 +361,58 @@ export default function sakuraMatrixExtension(pi: ExtensionAPI): void {
187
361
  let requestRender: (() => void) | undefined;
188
362
  let cachedKey = "";
189
363
  let cachedLines: string[] = [];
364
+ let currentAppearance: ResolvedAppearance | undefined;
365
+ let pendingConfigWarning = loaded.warning;
366
+ let warningIssued = false;
367
+ let completedOutputTokens = 0;
368
+ let currentOutputTokens = 0;
369
+ let currentMessageFinalized = false;
370
+ const activeToolIds = new Set<string>();
190
371
  const dropsByWidth = new Map<number, Drop[]>();
191
372
 
192
- const invalidate = () => {
193
- cachedKey = "";
194
- cachedLines = [];
195
- };
373
+ const invalidate = () => { cachedKey = ""; cachedLines = []; };
374
+ const totalOutputTokens = () => completedOutputTokens + currentOutputTokens || undefined;
196
375
 
197
- const component = {
198
- render(width: number): string[] {
199
- const safeWidth = Math.max(1, width);
200
- const key = `${safeWidth}:${config.height}:${frame}:${phase}`;
201
- if (key === cachedKey) return cachedLines;
202
- let drops = dropsByWidth.get(safeWidth);
203
- if (!drops) {
204
- drops = createDrops(safeWidth, config.density, config.height);
205
- if (dropsByWidth.size >= 4) {
206
- dropsByWidth.delete(dropsByWidth.keys().next().value ?? safeWidth);
207
- }
208
- dropsByWidth.set(safeWidth, drops);
209
- }
210
- cachedLines = renderSakuraMatrix(
211
- safeWidth,
212
- config.height,
213
- Math.max(0, performance.now() - startedAt) / 1000,
214
- phase,
215
- drops,
216
- );
217
- cachedKey = key;
218
- return cachedLines;
219
- },
220
- invalidate(): void {
221
- dropsByWidth.clear();
222
- invalidate();
223
- },
224
- };
225
-
226
- const clearTimer = () => {
376
+ const stop = () => {
377
+ generation += 1;
378
+ active = false;
227
379
  if (timer) clearTimeout(timer);
228
380
  timer = undefined;
381
+ const ctx = activeContext;
382
+ activeContext = undefined;
383
+ requestRender = undefined;
384
+ lastHostUpdateAt = 0;
385
+ activeToolIds.clear();
386
+ completedOutputTokens = 0;
387
+ currentOutputTokens = 0;
388
+ currentMessageFinalized = false;
389
+ dropsByWidth.clear();
390
+ invalidate();
391
+ if (!ctx) return;
392
+ try {
393
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
394
+ ctx.ui.setWorkingMessage();
395
+ ctx.ui.setWorkingIndicator();
396
+ ctx.ui.setWorkingVisible(true);
397
+ } catch { /* disposal is idempotent */ }
229
398
  };
230
399
 
400
+ const resolveCurrentAppearance = (): ResolvedAppearance | undefined =>
401
+ resolveAppearance(config.appearance, activeContext?.ui.theme.name);
402
+
231
403
  const schedule = (token: number) => {
232
404
  if (!active || token !== generation) return;
405
+ const resolved = resolveCurrentAppearance();
406
+ if (!resolved) {
407
+ const ctx = activeContext;
408
+ stop();
409
+ if (!warningIssued) {
410
+ warningIssued = true;
411
+ ctx?.ui.notify("Rose Matrix needs a known theme; run /rose-matrix appearance dark|light.", "warning");
412
+ }
413
+ return;
414
+ }
415
+ if (resolved !== currentAppearance) { currentAppearance = resolved; invalidate(); }
233
416
  const frameMs = 1000 / config.fps;
234
417
  const now = performance.now();
235
418
  if (now - nextDeadline > frameMs * 3) nextDeadline = now;
@@ -238,52 +421,69 @@ export default function sakuraMatrixExtension(pi: ExtensionAPI): void {
238
421
  if (!active || token !== generation) return;
239
422
  frame += 1;
240
423
  invalidate();
241
- // Streaming and tool updates already schedule a host render. Avoid adding
242
- // a second full TUI pass when one occurred within this frame window.
424
+ // Agent/tool streaming already asks Pi to render. Avoid a redundant full
425
+ // TUI pass inside that same frame while still advancing one shared clock.
243
426
  if (performance.now() - lastHostUpdateAt >= frameMs) requestRender?.();
244
427
  schedule(token);
245
428
  }, Math.max(16, nextDeadline - performance.now()));
246
429
  timer.unref?.();
247
430
  };
248
431
 
249
- const stop = () => {
250
- generation += 1;
251
- active = false;
252
- clearTimer();
253
- const ctx = activeContext;
254
- activeContext = undefined;
255
- requestRender = undefined;
256
- lastHostUpdateAt = 0;
257
- invalidate();
258
- if (!ctx) return;
259
- try {
260
- ctx.ui.setWidget(WIDGET_KEY, undefined);
261
- ctx.ui.setWorkingMessage();
262
- ctx.ui.setWorkingIndicator();
263
- ctx.ui.setWorkingVisible(true);
264
- } catch {
265
- // UI may already be disposed during shutdown; cleanup remains idempotent.
266
- }
432
+ const component = {
433
+ render(width: number): string[] {
434
+ const safeWidth = Math.max(1, width);
435
+ const appearance = currentAppearance ?? "dark";
436
+ const key = `${safeWidth}:${frame}:${phase}:${appearance}:${totalOutputTokens() ?? 0}`;
437
+ if (key === cachedKey) return cachedLines;
438
+ let drops = dropsByWidth.get(safeWidth);
439
+ if (!drops) {
440
+ drops = createDrops(safeWidth, config.density, 4);
441
+ if (dropsByWidth.size >= 4) dropsByWidth.delete(dropsByWidth.keys().next().value ?? safeWidth);
442
+ dropsByWidth.set(safeWidth, drops);
443
+ }
444
+ const elapsedMs = Math.max(0, performance.now() - startedAt);
445
+ cachedLines = [
446
+ renderRoseShimmer(safeWidth, phase, elapsedMs, totalOutputTokens(), appearance),
447
+ ...renderRoseMatrix(safeWidth, 4, elapsedMs / 1000, phase, drops, appearance),
448
+ ];
449
+ cachedKey = key;
450
+ return cachedLines;
451
+ },
452
+ invalidate(): void { dropsByWidth.clear(); invalidate(); },
267
453
  };
268
454
 
269
- const start = (ctx: ExtensionContext, initialPhase: Phase = "working") => {
455
+ const start = (ctx: ExtensionContext, initialPhase: Phase = "requesting") => {
270
456
  stop();
271
457
  if (!config.enabled || ctx.mode !== "tui") return;
272
458
  activeContext = ctx;
459
+ const resolved = resolveCurrentAppearance();
460
+ if (!resolved) {
461
+ activeContext = undefined;
462
+ if (!warningIssued) {
463
+ warningIssued = true;
464
+ ctx.ui.notify("Rose Matrix needs a known theme; run /rose-matrix appearance dark|light.", "warning");
465
+ }
466
+ return;
467
+ }
273
468
  active = true;
469
+ currentAppearance = resolved;
470
+ if (pendingConfigWarning) {
471
+ ctx.ui.notify(pendingConfigWarning, "warning");
472
+ pendingConfigWarning = undefined;
473
+ }
274
474
  phase = initialPhase;
275
475
  frame = 0;
276
476
  startedAt = performance.now();
277
477
  nextDeadline = startedAt;
278
478
  lastHostUpdateAt = 0;
479
+ activeToolIds.clear();
480
+ completedOutputTokens = 0;
481
+ currentOutputTokens = 0;
482
+ currentMessageFinalized = false;
279
483
  dropsByWidth.clear();
280
484
  invalidate();
281
485
  const token = generation;
282
- ctx.ui.setWorkingVisible(true);
283
- // The matrix owns the only animation clock; a static indicator prevents a
284
- // second independent timer from forcing redundant full-screen renders.
285
- ctx.ui.setWorkingIndicator({ frames: [workingIndicatorFrame()] });
286
- ctx.ui.setWorkingMessage(PHASE_MESSAGES[phase]);
486
+ ctx.ui.setWorkingVisible(false);
287
487
  ctx.ui.setWidget(WIDGET_KEY, (tui) => {
288
488
  requestRender = () => tui.requestRender();
289
489
  return component;
@@ -291,16 +491,33 @@ export default function sakuraMatrixExtension(pi: ExtensionAPI): void {
291
491
  schedule(token);
292
492
  };
293
493
 
294
- const noteHostUpdate = () => {
295
- lastHostUpdateAt = performance.now();
296
- };
494
+ const noteHostUpdate = () => { lastHostUpdateAt = performance.now(); };
297
495
 
298
496
  const setPhase = (next: Phase) => {
299
497
  noteHostUpdate();
300
- if (!active || phase === next) return;
301
- phase = next;
302
- activeContext?.ui.setWorkingMessage(PHASE_MESSAGES[phase]);
303
- frame += 1;
498
+ if (!active || (activeToolIds.size > 0 && next !== "tool")) return;
499
+ if (phase !== next) { phase = next; frame += 1; invalidate(); }
500
+ };
501
+
502
+ const updateUsage = (message: unknown) => {
503
+ const usage = assistantUsage(message);
504
+ if (usage !== undefined && usage >= currentOutputTokens) {
505
+ currentOutputTokens = usage;
506
+ invalidate();
507
+ }
508
+ };
509
+
510
+ const beginAssistant = () => {
511
+ currentOutputTokens = 0;
512
+ currentMessageFinalized = false;
513
+ invalidate();
514
+ };
515
+ const finalizeAssistant = (message: unknown) => {
516
+ if (currentMessageFinalized) return;
517
+ updateUsage(message);
518
+ completedOutputTokens += currentOutputTokens;
519
+ currentOutputTokens = 0;
520
+ currentMessageFinalized = true;
304
521
  invalidate();
305
522
  };
306
523
 
@@ -308,76 +525,72 @@ export default function sakuraMatrixExtension(pi: ExtensionAPI): void {
308
525
  pi.on("agent_end", () => stop());
309
526
  pi.on("session_before_switch", () => stop());
310
527
  pi.on("session_shutdown", () => stop());
311
-
528
+ pi.on("message_start", (event) => { if (isAssistantMessage(event.message)) beginAssistant(); });
529
+ pi.on("message_end", (event) => { if (isAssistantMessage(event.message)) finalizeAssistant(event.message); });
312
530
  pi.on("message_update", (event) => {
313
531
  noteHostUpdate();
314
- const streamEvent = event.assistantMessageEvent as { type?: string } | undefined;
315
- if (!streamEvent?.type) return;
316
- if (streamEvent.type === "thinking_start" || streamEvent.type === "thinking_delta") {
317
- setPhase("thinking");
318
- } else if (streamEvent.type === "thinking_end" || streamEvent.type === "text_delta") {
319
- setPhase("working");
320
- }
532
+ const streamEvent = event.assistantMessageEvent;
533
+ updateUsage("partial" in streamEvent ? streamEvent.partial : "message" in streamEvent ? streamEvent.message : "error" in streamEvent ? streamEvent.error : undefined);
534
+ if (streamEvent.type === "thinking_start" || streamEvent.type === "thinking_delta") setPhase("thinking");
535
+ else if (streamEvent.type === "thinking_end") setPhase("requesting");
536
+ else if (streamEvent.type === "text_start" || streamEvent.type === "text_delta") setPhase("working");
537
+ else if (streamEvent.type === "done") finalizeAssistant(streamEvent.message);
538
+ else if (streamEvent.type === "error") finalizeAssistant(streamEvent.error);
321
539
  });
322
-
323
- pi.on("tool_execution_start", () => setPhase("tool"));
324
- pi.on("tool_execution_update", () => noteHostUpdate());
325
- pi.on("tool_execution_end", () => setPhase("working"));
326
-
327
- pi.registerCommand("sakura-matrix", {
328
- description: "Sakura Matrix animation: status, on, off, preview, fps <8-18>, density <0.45-0.95>",
329
- handler: async (args, ctx) => {
330
- const [command = "status", value] = args.trim().toLowerCase().split(/\s+/);
331
- if (command === "on") {
332
- config.enabled = true;
333
- saveConfig(config);
334
- ctx.ui.notify("Sakura Matrix enabled", "info");
335
- return;
336
- }
337
- if (command === "off") {
338
- config.enabled = false;
339
- saveConfig(config);
340
- stop();
341
- ctx.ui.notify("Sakura Matrix disabled", "info");
342
- return;
343
- }
344
- if (command === "preview") {
345
- start(ctx, "thinking");
346
- const previewToken = generation;
347
- const previewTimer = setTimeout(() => {
348
- if (generation === previewToken) stop();
349
- }, 5000);
350
- previewTimer.unref?.();
351
- ctx.ui.notify("Sakura Matrix preview: 5 seconds", "info");
352
- return;
353
- }
354
- if (command === "fps") {
355
- const fps = Number(value);
356
- if (!Number.isFinite(fps) || fps < 8 || fps > 18) {
357
- ctx.ui.notify("Usage: /sakura-matrix fps <8-18>", "error");
358
- return;
359
- }
360
- config.fps = Math.round(fps);
361
- saveConfig(config);
362
- ctx.ui.notify(`Sakura Matrix FPS: ${config.fps}`, "info");
363
- return;
364
- }
365
- if (command === "density") {
366
- const density = Number(value);
367
- if (!Number.isFinite(density) || density < 0.45 || density > 0.95) {
368
- ctx.ui.notify("Usage: /sakura-matrix density <0.45-0.95>", "error");
369
- return;
370
- }
371
- config.density = Math.round(density * 100) / 100;
372
- dropsByWidth.clear();
373
- saveConfig(config);
374
- ctx.ui.notify(`Sakura Matrix density: ${config.density}`, "info");
375
- return;
376
- }
377
- ctx.ui.notify(
378
- `Sakura Matrix: ${config.enabled ? "on" : "off"} · ${config.fps} FPS · ${config.height} lines · density ${config.density}`,
379
- "info",
380
- );
381
- },
540
+ pi.on("tool_execution_start", (event) => { activeToolIds.add(event.toolCallId); setPhase("tool"); });
541
+ pi.on("tool_execution_update", (event) => { noteHostUpdate(); if (activeToolIds.has(event.toolCallId)) setPhase("tool"); });
542
+ pi.on("tool_execution_end", (event) => {
543
+ if (!activeToolIds.delete(event.toolCallId)) return;
544
+ setPhase(activeToolIds.size > 0 ? "tool" : "requesting");
382
545
  });
546
+
547
+ const handleCommand = async (args: string, ctx: ExtensionContext, deprecated = false) => {
548
+ const [command = "status", value] = args.trim().toLowerCase().split(/\s+/);
549
+ if (deprecated) ctx.ui.notify("/sakura-matrix is deprecated; use /rose-matrix.", "warning");
550
+ if (command === "on" || command === "off") {
551
+ config.enabled = command === "on";
552
+ writeConfigAtomically(CONFIG_PATH, config);
553
+ if (!config.enabled) stop();
554
+ ctx.ui.notify(`Rose Matrix ${config.enabled ? "enabled" : "disabled"}`, "info");
555
+ return;
556
+ }
557
+ if (command === "preview") {
558
+ start(ctx, "thinking");
559
+ const token = generation;
560
+ const previewTimer = setTimeout(() => { if (generation === token) stop(); }, 5000);
561
+ previewTimer.unref?.();
562
+ ctx.ui.notify("Rose Matrix preview: 5 seconds", "info");
563
+ return;
564
+ }
565
+ if (command === "fps") {
566
+ const fps = Number(value);
567
+ if (!Number.isFinite(fps) || fps < 8 || fps > 18) { ctx.ui.notify("Usage: /rose-matrix fps <8-18>", "error"); return; }
568
+ config.fps = Math.round(fps);
569
+ writeConfigAtomically(CONFIG_PATH, config);
570
+ ctx.ui.notify(`Rose Matrix FPS: ${config.fps}`, "info");
571
+ return;
572
+ }
573
+ if (command === "density") {
574
+ const density = Number(value);
575
+ if (!Number.isFinite(density) || density < 0.45 || density > 0.95) { ctx.ui.notify("Usage: /rose-matrix density <0.45-0.95>", "error"); return; }
576
+ config.density = Math.round(density * 100) / 100;
577
+ dropsByWidth.clear();
578
+ writeConfigAtomically(CONFIG_PATH, config);
579
+ ctx.ui.notify(`Rose Matrix density: ${config.density}`, "info");
580
+ return;
581
+ }
582
+ if (command === "appearance") {
583
+ if (value !== "auto" && value !== "dark" && value !== "light") { ctx.ui.notify("Usage: /rose-matrix appearance <auto|dark|light>", "error"); return; }
584
+ config.appearance = value;
585
+ writeConfigAtomically(CONFIG_PATH, config);
586
+ currentAppearance = resolveAppearance(config.appearance, ctx.ui.theme.name);
587
+ invalidate();
588
+ ctx.ui.notify(`Rose Matrix appearance: ${value}`, "info");
589
+ return;
590
+ }
591
+ ctx.ui.notify(`Rose Matrix: ${config.enabled ? "on" : "off"} · ${config.fps} FPS · 4 lines · density ${config.density} · ${config.appearance}`, "info");
592
+ };
593
+
594
+ pi.registerCommand("rose-matrix", { description: "Rose Matrix: status, on, off, preview, fps <8-18>, density <0.45-0.95>, appearance <auto|dark|light>", handler: (args, ctx) => handleCommand(args, ctx) });
595
+ pi.registerCommand("sakura-matrix", { description: "Deprecated alias for /rose-matrix", handler: (args, ctx) => handleCommand(args, ctx, true) });
383
596
  }