@rosetears/aili-pi 0.1.0 → 0.1.3
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 +15 -6
- package/THIRD_PARTY_NOTICES.md +14 -3
- 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 +580 -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/capabilities.json +1 -1
- package/manifests/live-verification.json +14 -8
- package/manifests/provenance.json +18 -5
- package/manifests/sbom.json +19 -3
- package/notices/pi-sakura-cyberdeck-NOTICE.txt +6 -0
- package/package.json +12 -3
- package/scripts/local-package-e2e.ts +1 -1
- package/scripts/sync-global-skills.d.mts +13 -0
- package/scripts/sync-global-skills.mjs +134 -0
- package/src/runtime/credential-guard.ts +58 -0
- package/src/runtime/doctor.ts +1 -1
- package/src/runtime/index.ts +2 -2
- package/src/runtime/path-boundaries.ts +1 -1
- package/src/runtime/registry.ts +6 -2
- package/src/runtime/rem-head.txt +38 -0
- package/src/runtime/rose-context.ts +1 -1
- package/src/runtime/subagents.ts +74 -403
- package/templates/APPEND_SYSTEM.md +10 -8
- package/themes/rem-cyberdeck.json +32 -0
- package/upstream/opencode-global-agents.lock.json +34 -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
|
|
|
@@ -35,6 +35,8 @@ pi update npm:@rosetears/aili-pi
|
|
|
35
35
|
pi remove npm:@rosetears/aili-pi
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
During a Pi-managed npm install or update, the package replaces only existing same-name directories in `~/.agents/skills/` with its pinned AILI skill snapshot. It does not create package-only skills, alter differently named skills, retain backups, or load its embedded AILI snapshot as a second Pi skill source. The package remains installed for its Extensions, prompts, theme, and bundled `librarian` skill.
|
|
39
|
+
|
|
38
40
|
Removal is destructive for this Package. It does not remove Pi and must not be presented as rollback when replacing a pre-existing AILI installation.
|
|
39
41
|
|
|
40
42
|
### Explicit global resources
|
|
@@ -45,7 +47,13 @@ The package does not write global AILI resources during extension load. After re
|
|
|
45
47
|
/aili-install-global-resources
|
|
46
48
|
```
|
|
47
49
|
|
|
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.
|
|
50
|
+
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/`. The marker block is a Pi-native governance derivation of the pinned `aili-workflows` global AGENTS template: it retains instruction precedence, untrusted-content handling, approval/evidence/verification discipline, bounded delegation, project-rule precedence, and user-language output, while excluding OpenCode-only control planes. It preserves unrelated prompt content, rejects malformed markers or an unowned profile collision, and reports stale profiles without pruning them.
|
|
51
|
+
|
|
52
|
+
## Rem Cyberdeck
|
|
53
|
+
|
|
54
|
+
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`.
|
|
55
|
+
|
|
56
|
+
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.
|
|
49
57
|
|
|
50
58
|
## Commands and modes
|
|
51
59
|
|
|
@@ -63,7 +71,7 @@ That command creates or updates only the AILI marker block in `~/.pi/agent/APPEN
|
|
|
63
71
|
- `pi-web-access@0.13.0` provides its complete upstream web-search, content-fetch, curator, clone/PDF/video, and bundled-skill surface. Its provider fallback, network traffic, config/credential paths, clone cache, temporary curator service, downloads, and optional browser-cookie access are upstream behavior; inspect its tool requests and configuration before use.
|
|
64
72
|
- `pi-quota-status@0.3.0` is enabled by default. It may maintain `~/.pi/agent/pi-quota-status/state.json`; `/quota config` creates its configuration template.
|
|
65
73
|
- `pi-permission-modes@2.2.0` provides the permission UI and sandbox degradation behavior above. AILI does not retain `/aili-mode` or `Ctrl+Shift+Alt+A` as competing controls.
|
|
66
|
-
- `@agwab/pi-subagent@0.4.8` owns child spawn, cancellation,
|
|
74
|
+
- `@agwab/pi-subagent@0.4.8` owns the generic `subagent` tool, child spawn, cancellation, durable run/artifact lifecycle, bounded parallel fan-out, async actions, worktrees, external `cwd`, and sandbox options. The 19 `aili.<role>` profiles are optional named agents; generic or agentless runs do not use an AILI-only result schema. AILI injects an immutable credential-path guard; child Pi processes load the ambient AILI `pi-permission-modes` registration exactly once. The upstream runner excludes recursive `subagent` exposure inside workers.
|
|
67
75
|
|
|
68
76
|
## Optional capability packs
|
|
69
77
|
|
|
@@ -78,13 +86,14 @@ When unavailable, runtime and doctor output use explicit `SKIP`/`WARN` results a
|
|
|
78
86
|
|
|
79
87
|
## Security boundary
|
|
80
88
|
|
|
81
|
-
AILI
|
|
89
|
+
AILI injects a non-removable guard that denies standard Pi file-tool and parsed-bash access to credential, authentication, and private-key paths, including from an external `cwd`, a YOLO permission mode, or a caller-supplied child extension list. Child Pi processes load the ambient AILI `pi-permission-modes` registration exactly once, so vendor permission prompts and sandbox behavior remain active; headless confirmation requests fail closed. The guard does not make arbitrary third-party extension code safe: extensions remain trusted code and may have direct process authority. Neither AILI nor Pi provides a universal OS sandbox or containment: trusted extensions, user-authority processes, ambient network access, and filesystem races remain in the user trust domain. Inspect exact approval targets and do not put credentials in task text.
|
|
82
90
|
|
|
83
|
-
|
|
91
|
+
Use `subagent` lifecycle actions (`status`, `logs`, `wait`, `interrupt`, `mark-background`, and `reconcile`) to inspect durable async work. Upstream fan-out is version-bounded rather than capped by AILI at two; background completion is not evidence until inspected. Provider/model behavior still depends on the configured Pi environment.
|
|
84
92
|
|
|
85
93
|
## Provenance and reproducibility
|
|
86
94
|
|
|
87
95
|
- `upstream/aili-workflows.lock.json` pins the exact canonical 64-skill/471-file snapshot.
|
|
96
|
+
- `upstream/opencode-global-agents.lock.json` pins the global AGENTS source revision/hash and documents its Pi-native derivation.
|
|
88
97
|
- `manifests/skill-compatibility.json` records one compatibility state per skill.
|
|
89
98
|
- `manifests/roles.json` records the 19 generated Pi role profiles.
|
|
90
99
|
- `manifests/provenance.json`, `manifests/sbom.json`, and `THIRD_PARTY_NOTICES.md` record adapted/reference sources and the exact npm lock inventory.
|
|
@@ -104,6 +113,6 @@ npm run validate:release
|
|
|
104
113
|
- **Doctor is non-pass:** inspect the exact `ERROR`, `WARN`, `SKIP`, or `UNVERIFIED` component. Missing optional packs are not core execution success.
|
|
105
114
|
- **Permission shortcut does nothing:** use `/perm`; terminal multiplexers may consume `Alt+M`.
|
|
106
115
|
- **Global resources are non-pass:** run `/aili-install-global-resources` only after reviewing the exact `~/.pi/agent/` targets. A malformed marker or an unowned role collision intentionally leaves files unchanged.
|
|
107
|
-
- **Offline use:** the installed Package embeds the pinned skills and does not fetch `aili-workflows` at runtime. First-time Pi/package installation still requires the relevant package sources.
|
|
116
|
+
- **Offline use:** the installed Package embeds the pinned skills and does not fetch `aili-workflows` at runtime. Its Pi-managed npm lifecycle synchronizes only pre-existing same-name global skills from that embedded fixed snapshot. First-time Pi/package installation still requires the relevant package sources.
|
|
108
117
|
|
|
109
118
|
See `THIRD_PARTY_NOTICES.md` for source and license details. No official endorsement by the Pi maintainers is claimed.
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -9,9 +9,9 @@ This distribution is MIT-licensed. The following adapted sources and locked deve
|
|
|
9
9
|
- Revision: 7eb35f357ad489f5841ee10dac1e44549c1bdb76
|
|
10
10
|
- Version: 0.2.7
|
|
11
11
|
- License: MIT
|
|
12
|
-
- Source files: upstream/aili-workflows.lock.json#files (471 exact skill files), manifests/roles.json#records[].sourcePath (19 exact role source files)
|
|
12
|
+
- Source files: upstream/aili-workflows.lock.json#files (471 exact skill files), manifests/roles.json#records[].sourcePath (19 exact role source files), upstream/opencode-global-agents.lock.json (pinned source template revision/hash)
|
|
13
13
|
- Reused symbols/patterns: 64 canonical skill bodies and owned assets, 19 child-role prompt bodies
|
|
14
|
-
- Local changes: skills/** is an exact byte-for-byte snapshot with no semantic overlay; role prompts are generated as Pi frontmatter with explicit tool/capability ceilings and structured output
|
|
14
|
+
- Local changes: skills/** is an exact byte-for-byte snapshot with no semantic overlay; role prompts are generated as Pi frontmatter with explicit tool/capability ceilings and structured output; templates/APPEND_SYSTEM.md is a Pi-native governance derivation of the pinned global AGENTS template, with OpenCode-only control planes excluded
|
|
15
15
|
|
|
16
16
|
## @agwab/pi-subagent
|
|
17
17
|
|
|
@@ -22,7 +22,7 @@ This distribution is MIT-licensed. The following adapted sources and locked deve
|
|
|
22
22
|
- License: MIT
|
|
23
23
|
- Source files: src/api.ts, src/runners/headless-model.ts, src/artifacts/result.ts
|
|
24
24
|
- Reused symbols/patterns: runSubagent API, headless lifecycle, artifact envelope
|
|
25
|
-
- Local changes: AILI
|
|
25
|
+
- Local changes: AILI registers the full pinned upstream subagent tool schema, injects a non-removable credential guard, and relies on the ambient AILI pi-permission-modes registration exactly once in each child; no upstream source is copied
|
|
26
26
|
|
|
27
27
|
## pi-permission-modes
|
|
28
28
|
|
|
@@ -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
|
+
}
|