@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.
- package/README.md +7 -3
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/extensions/header/index.ts +26 -16
- package/extensions/matrix/index.ts +408 -195
- package/extensions/zentui/config.ts +21 -19
- package/extensions/zentui/format.ts +15 -1
- package/extensions/zentui/gradient.ts +48 -49
- package/extensions/zentui/thinking-message.ts +3 -3
- package/extensions/zentui/tool-execution.ts +9 -6
- package/extensions/zentui/ui.ts +3 -3
- package/manifests/provenance.json +1 -1
- package/manifests/sbom.json +2 -2
- package/notices/pi-sakura-cyberdeck-NOTICE.txt +3 -3
- package/package.json +2 -2
- package/src/runtime/rose-theme.ts +26 -0
- package/themes/{rem-cyberdeck.json → rose-cyberdeck.json} +10 -11
- /package/src/runtime/{rem-head.txt → rose-head.txt} +0 -0
|
@@ -1,42 +1,52 @@
|
|
|
1
|
-
import {
|
|
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 = "
|
|
7
|
-
const CONFIG_PATH = join(homedir(), ".pi", "agent", "
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
76
|
-
|
|
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
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
239
|
+
color: roseRainColor(index),
|
|
139
240
|
};
|
|
140
241
|
});
|
|
141
242
|
}
|
|
142
243
|
|
|
143
|
-
|
|
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 =
|
|
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(
|
|
291
|
+
? mix(trackColor, palette.highlight, 0.58)
|
|
165
292
|
: trail === 1
|
|
166
|
-
?
|
|
167
|
-
: mix(
|
|
168
|
-
|
|
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
|
-
|
|
345
|
+
function isAssistantMessage(message: unknown): boolean {
|
|
346
|
+
return isRecord(message) && message.role === "assistant";
|
|
174
347
|
}
|
|
175
348
|
|
|
176
|
-
export default function
|
|
177
|
-
const
|
|
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 = "
|
|
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
|
-
|
|
194
|
-
cachedLines = [];
|
|
195
|
-
};
|
|
373
|
+
const invalidate = () => { cachedKey = ""; cachedLines = []; };
|
|
374
|
+
const totalOutputTokens = () => completedOutputTokens + currentOutputTokens || undefined;
|
|
196
375
|
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
//
|
|
242
|
-
//
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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 = "
|
|
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(
|
|
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 ||
|
|
301
|
-
phase = next;
|
|
302
|
-
|
|
303
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
if (streamEvent.type === "thinking_start" || streamEvent.type === "thinking_delta")
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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("
|
|
324
|
-
pi.on("
|
|
325
|
-
|
|
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
|
}
|