@rosetears/aili-pi 0.1.6 → 0.1.8

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