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