zob-harness 0.6.0 → 0.7.0
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.
|
@@ -26,6 +26,7 @@ import { showDelegationOverlay } from "./delegation-overlay.js";
|
|
|
26
26
|
import { finishDelegationRun } from "./delegation-monitor.js";
|
|
27
27
|
import { showGoalTodoOverlay } from "./goal-todo-overlay.js";
|
|
28
28
|
import type { HarnessRuntimeState } from "./state.js";
|
|
29
|
+
import { registerZobIntroCommand } from "./zob-intro.js";
|
|
29
30
|
import {
|
|
30
31
|
asInteractiveAutonomyMode,
|
|
31
32
|
formatInteractiveAutonomyStatus,
|
|
@@ -986,6 +987,8 @@ export function registerHarnessCommands(pi: ExtensionAPI, state: HarnessRuntimeS
|
|
|
986
987
|
refreshIntentClassifierModelCache(ctx);
|
|
987
988
|
});
|
|
988
989
|
|
|
990
|
+
registerZobIntroCommand(pi);
|
|
991
|
+
|
|
989
992
|
// Exact `/new` is handled by Pi before extension input/command hooks. Soft carryover
|
|
990
993
|
// is therefore written from the `session_shutdown` reason="new" hook in events.ts.
|
|
991
994
|
// Keep this registration only for `/new hard`, where users need an explicit clean reset.
|
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { AutocompleteItem, Component, TUI } from "@earendil-works/pi-tui";
|
|
6
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
import { sha256 } from "../core/utils/hashing.js";
|
|
9
|
+
|
|
10
|
+
type LogicalFrame = readonly string[];
|
|
11
|
+
type ZobIntroStyleName = "accent" | "plain";
|
|
12
|
+
|
|
13
|
+
type ZobIntroOptions = {
|
|
14
|
+
blockWidth: number;
|
|
15
|
+
blockHeight: number;
|
|
16
|
+
tickMs: number;
|
|
17
|
+
repeat: number | null;
|
|
18
|
+
style: ZobIntroStyleName;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ZobIntroParseResult =
|
|
22
|
+
| { ok: true; help: false; options: ZobIntroOptions }
|
|
23
|
+
| { ok: true; help: true; options: ZobIntroOptions }
|
|
24
|
+
| { ok: false; errors: string[]; options: ZobIntroOptions };
|
|
25
|
+
|
|
26
|
+
type ZobIntroStyles = {
|
|
27
|
+
accent: (text: string) => string;
|
|
28
|
+
block: (text: string) => string;
|
|
29
|
+
bold: (text: string) => string;
|
|
30
|
+
dim: (text: string) => string;
|
|
31
|
+
exhaustHot: (text: string) => string;
|
|
32
|
+
exhaustDim: (text: string) => string;
|
|
33
|
+
warning: (text: string) => string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const WELCOME_TITLE = "Welcome to the ZOB Harness";
|
|
37
|
+
const START_PROMPT = "Press Enter/Space to continue";
|
|
38
|
+
const FIRST_RUN_MARKER_RELATIVE_PATH = ".pi/tmp/zob-intro-first-run.json";
|
|
39
|
+
const FIRST_RUN_ARGS = "once fast";
|
|
40
|
+
|
|
41
|
+
const DEFAULT_ZOB_INTRO_OPTIONS: ZobIntroOptions = {
|
|
42
|
+
blockWidth: 6,
|
|
43
|
+
blockHeight: 3,
|
|
44
|
+
tickMs: 180,
|
|
45
|
+
repeat: 3,
|
|
46
|
+
style: "accent",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ZOB_INTRO_MIN_CANVAS_COLS = 8;
|
|
50
|
+
const ZOB_INTRO_EXIT_FRAME_COUNT = 10;
|
|
51
|
+
const ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT = 2;
|
|
52
|
+
const ZOB_INTRO_WRAP_PASS_FRAME_COUNT = 8;
|
|
53
|
+
const BUILD_FRAME_TICK_HOLD = 2;
|
|
54
|
+
const WELCOME_TEXT_SHIFT_LEFT = -3;
|
|
55
|
+
|
|
56
|
+
const ZOB_INTRO_TARGET_FRAME = [
|
|
57
|
+
"###.....",
|
|
58
|
+
"#.#####.",
|
|
59
|
+
"###....#",
|
|
60
|
+
"#.#####.",
|
|
61
|
+
"###.....",
|
|
62
|
+
] as const satisfies LogicalFrame;
|
|
63
|
+
|
|
64
|
+
const ZOB_INTRO_MORPH_FRAMES = [
|
|
65
|
+
["###.", "#.#.", "##.#", "#..#"],
|
|
66
|
+
["###.", "#.#.", "##+#", "#.+#"],
|
|
67
|
+
["###.", "#.#.", "###.", "#.#."],
|
|
68
|
+
["###.", "#.#.", "###.", "#.#.", "+..."],
|
|
69
|
+
["###.", "#.#.", "###.", "#.#.", "#..."],
|
|
70
|
+
["###.", "#.#.", "###.", "#.#.", "#+.."],
|
|
71
|
+
["###.", "#.#.", "###.", "#.#.", "##.."],
|
|
72
|
+
["###.....", "#.#.....", "###.....", "#.#.....", "##+....."],
|
|
73
|
+
["###.....", "#.#.....", "###.....", "#.#.....", "###....."],
|
|
74
|
+
["###.....", "#.#+....", "###.+...", "#.#+....", "###....."],
|
|
75
|
+
["###.....", "#.##....", "###.#...", "#.##....", "###....."],
|
|
76
|
+
["###.....", "#.##+...", "###..+..", "#.##+...", "###....."],
|
|
77
|
+
["###.....", "#.###...", "###..#..", "#.###...", "###....."],
|
|
78
|
+
["###.....", "#.###+..", "###...+.", "#.###+..", "###....."],
|
|
79
|
+
["###.....", "#.####..", "###...#.", "#.####..", "###....."],
|
|
80
|
+
["###.....", "#.####+.", "###....+", "#.####+.", "###....."],
|
|
81
|
+
ZOB_INTRO_TARGET_FRAME,
|
|
82
|
+
] as const satisfies readonly LogicalFrame[];
|
|
83
|
+
|
|
84
|
+
function frameWidth(frame: LogicalFrame): number {
|
|
85
|
+
return Math.max(...frame.map((row) => row.length));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function centerOffset(frame: LogicalFrame, width: number): number {
|
|
89
|
+
return Math.max(0, Math.floor((width - frameWidth(frame)) / 2));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function placeFrame(frame: LogicalFrame, offsetX: number, width: number): string[] {
|
|
93
|
+
return frame.map((row) => {
|
|
94
|
+
const cells = Array.from({ length: width }, () => ".");
|
|
95
|
+
for (let sourceX = 0; sourceX < row.length; sourceX += 1) {
|
|
96
|
+
const cell = row[sourceX];
|
|
97
|
+
if (!cell || cell === ".") continue;
|
|
98
|
+
const targetX = offsetX + sourceX;
|
|
99
|
+
if (targetX >= 0 && targetX < width) cells[targetX] = cell;
|
|
100
|
+
}
|
|
101
|
+
return cells.join("");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function writeCells(row: string[], startX: number, pattern: string): void {
|
|
106
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
107
|
+
const targetX = startX + index;
|
|
108
|
+
if (targetX < 0 || targetX >= row.length) continue;
|
|
109
|
+
if (row[targetX] !== ".") continue;
|
|
110
|
+
row[targetX] = pattern[index] ?? ".";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildLaserPattern(length: number, pulse: number, lane: number): string {
|
|
115
|
+
return Array.from({ length }, (_unused, index) => {
|
|
116
|
+
const phase = (index + pulse + lane * 2) % 7;
|
|
117
|
+
if (phase <= 1) return "=";
|
|
118
|
+
if (phase === 2 || phase === 5) return "-";
|
|
119
|
+
if (phase === 3) return "~";
|
|
120
|
+
return ".";
|
|
121
|
+
}).join("");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function withLaserRays(frame: LogicalFrame, rayStartX: number, pulse: number, width: number, boosted = false): string[] {
|
|
125
|
+
const rows = frame.map((row) => row.padEnd(width, ".").slice(0, width).split(""));
|
|
126
|
+
const start = Math.max(0, rayStartX);
|
|
127
|
+
const length = Math.max(0, width - start);
|
|
128
|
+
if (boosted && rows[1]) writeCells(rows[1], start, buildLaserPattern(Math.max(0, length - 2), pulse, 1));
|
|
129
|
+
if (rows[2]) writeCells(rows[2], start, buildLaserPattern(length, pulse + 1, 0));
|
|
130
|
+
if (boosted && rows[3]) writeCells(rows[3], start, buildLaserPattern(Math.max(0, length - 1), pulse + 2, 2));
|
|
131
|
+
return rows.map((row) => row.join(""));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function emptyIntroFrame(width: number): LogicalFrame {
|
|
135
|
+
return Array.from({ length: LOGICAL_ROWS }, () => ".".repeat(width));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildExitFrame(exitIndex: number, width: number, startOffset: number, endOffset: number, pulseOffset = 0, boosted = false): LogicalFrame {
|
|
139
|
+
const targetWidth = frameWidth(ZOB_INTRO_TARGET_FRAME);
|
|
140
|
+
const denominator = Math.max(1, ZOB_INTRO_EXIT_FRAME_COUNT - 1);
|
|
141
|
+
const rawT = Math.min(1, exitIndex / denominator);
|
|
142
|
+
const t = rawT * rawT;
|
|
143
|
+
const offsetX = Math.round(startOffset + (endOffset - startOffset) * t);
|
|
144
|
+
const shiftedWholeLogo = placeFrame(ZOB_INTRO_TARGET_FRAME, offsetX, width);
|
|
145
|
+
return withLaserRays(shiftedWholeLogo, offsetX + targetWidth, exitIndex + pulseOffset, width, boosted);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildWrapPassFrame(passIndex: number, width: number): LogicalFrame {
|
|
149
|
+
const targetWidth = frameWidth(ZOB_INTRO_TARGET_FRAME);
|
|
150
|
+
const startOffset = width + 1;
|
|
151
|
+
const endOffset = -targetWidth - 1;
|
|
152
|
+
const denominator = Math.max(1, ZOB_INTRO_WRAP_PASS_FRAME_COUNT - 1);
|
|
153
|
+
const t = Math.min(1, passIndex / denominator);
|
|
154
|
+
const offsetX = Math.round(startOffset + (endOffset - startOffset) * t);
|
|
155
|
+
const shiftedWholeLogo = placeFrame(ZOB_INTRO_TARGET_FRAME, offsetX, width);
|
|
156
|
+
return withLaserRays(shiftedWholeLogo, offsetX + targetWidth, passIndex + ZOB_INTRO_EXIT_FRAME_COUNT, width, true);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildIntroFrame(frameIndex: number, width: number): LogicalFrame {
|
|
160
|
+
if (frameIndex < ZOB_INTRO_MORPH_FRAMES.length) {
|
|
161
|
+
const frame = ZOB_INTRO_MORPH_FRAMES[frameIndex] ?? ZOB_INTRO_MORPH_FRAMES[0];
|
|
162
|
+
return placeFrame(frame, centerOffset(frame, width), width);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let phaseIndex = Math.max(0, frameIndex - ZOB_INTRO_MORPH_FRAMES.length);
|
|
166
|
+
const targetWidth = frameWidth(ZOB_INTRO_TARGET_FRAME);
|
|
167
|
+
if (phaseIndex < ZOB_INTRO_EXIT_FRAME_COUNT) {
|
|
168
|
+
return buildExitFrame(phaseIndex, width, centerOffset(ZOB_INTRO_TARGET_FRAME, width), -targetWidth - 1);
|
|
169
|
+
}
|
|
170
|
+
phaseIndex -= ZOB_INTRO_EXIT_FRAME_COUNT;
|
|
171
|
+
if (phaseIndex < ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT) return emptyIntroFrame(width);
|
|
172
|
+
phaseIndex -= ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT;
|
|
173
|
+
return buildWrapPassFrame(phaseIndex, width);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ZOB_INTRO_FRAME_COUNT = ZOB_INTRO_MORPH_FRAMES.length + ZOB_INTRO_EXIT_FRAME_COUNT + ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT + ZOB_INTRO_WRAP_PASS_FRAME_COUNT;
|
|
177
|
+
const LOGICAL_ROWS = Math.max(...ZOB_INTRO_MORPH_FRAMES.map((frame) => frame.length));
|
|
178
|
+
|
|
179
|
+
function cloneDefaultOptions(): ZobIntroOptions {
|
|
180
|
+
return { ...DEFAULT_ZOB_INTRO_OPTIONS };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parsePositiveInt(value: string, label: string, min: number, max: number, errors: string[]): number | undefined {
|
|
184
|
+
const parsed = Number.parseInt(value, 10);
|
|
185
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
186
|
+
errors.push(`${label} must be an integer between ${min} and ${max}`);
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
return parsed;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseZobIntroArgs(args: string): ZobIntroParseResult {
|
|
193
|
+
const options = cloneDefaultOptions();
|
|
194
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
195
|
+
const errors: string[] = [];
|
|
196
|
+
let help = false;
|
|
197
|
+
|
|
198
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
199
|
+
const part = parts[index]?.toLowerCase() ?? "";
|
|
200
|
+
if (part === "help" || part === "--help" || part === "-h") {
|
|
201
|
+
help = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (part === "loop") {
|
|
205
|
+
options.repeat = null;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (part === "once") {
|
|
209
|
+
options.repeat = 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (part === "fast") {
|
|
213
|
+
options.tickMs = 90;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (part === "slow") {
|
|
217
|
+
options.tickMs = 300;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (part === "normal") {
|
|
221
|
+
options.tickMs = DEFAULT_ZOB_INTRO_OPTIONS.tickMs;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (part === "plain" || part === "mono") {
|
|
225
|
+
options.style = "plain";
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (part === "accent" || part === "neon") {
|
|
229
|
+
options.style = "accent";
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (part === "--repeat") {
|
|
233
|
+
const next = parts[index + 1];
|
|
234
|
+
if (!next) {
|
|
235
|
+
errors.push("--repeat requires a positive integer or 'loop'");
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
index += 1;
|
|
239
|
+
if (next.toLowerCase() === "loop") options.repeat = null;
|
|
240
|
+
else {
|
|
241
|
+
const repeat = parsePositiveInt(next, "repeat", 1, 99, errors);
|
|
242
|
+
if (repeat !== undefined) options.repeat = repeat;
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (part === "--speed" || part === "--tick-ms") {
|
|
247
|
+
const next = parts[index + 1];
|
|
248
|
+
if (!next) {
|
|
249
|
+
errors.push(`${part} requires a millisecond value`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
index += 1;
|
|
253
|
+
const tickMs = parsePositiveInt(next, "tickMs", 30, 2_000, errors);
|
|
254
|
+
if (tickMs !== undefined) options.tickMs = tickMs;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const blockMatch = /^(\d+)x(\d+)$/.exec(part);
|
|
258
|
+
if (blockMatch) {
|
|
259
|
+
const blockWidth = parsePositiveInt(blockMatch[1] ?? "", "blockWidth", 1, 20, errors);
|
|
260
|
+
const blockHeight = parsePositiveInt(blockMatch[2] ?? "", "blockHeight", 1, 12, errors);
|
|
261
|
+
if (blockWidth !== undefined) options.blockWidth = blockWidth;
|
|
262
|
+
if (blockHeight !== undefined) options.blockHeight = blockHeight;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
errors.push(`unknown option '${part}'`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (errors.length > 0) return { ok: false, errors, options };
|
|
269
|
+
return { ok: true, help, options };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function zobIntroHelpTemplate(): string {
|
|
273
|
+
return [
|
|
274
|
+
"# ZOB intro animation",
|
|
275
|
+
"",
|
|
276
|
+
"Usage:",
|
|
277
|
+
"/zob-intro # play the morph + whole-logo laser exit, default 6x3 terminal pixels",
|
|
278
|
+
"/zob-intro once # play once then close",
|
|
279
|
+
"/zob-intro loop # loop until ESC/Q",
|
|
280
|
+
"/zob-intro fast 6x3 neon # faster and wider terminal pixels",
|
|
281
|
+
"/zob-intro slow 5x3 plain # slower uncolored blocks",
|
|
282
|
+
"/zob-intro reset-first-run # show the startup intro again on next Pi/ZOB launch",
|
|
283
|
+
"",
|
|
284
|
+
"Options:",
|
|
285
|
+
"- once | loop | --repeat N",
|
|
286
|
+
"- fast | normal | slow | --speed MS",
|
|
287
|
+
"- 3x3 | 5x3 | 6x3 | any WxH up to 20x12",
|
|
288
|
+
"- accent/neon | plain/mono",
|
|
289
|
+
"",
|
|
290
|
+
"Controls:",
|
|
291
|
+
"- Enter or Space: start from welcome screen",
|
|
292
|
+
"- ESC or Q: close",
|
|
293
|
+
"- Space after start: pause/resume",
|
|
294
|
+
].join("\n");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function zobIntroArgumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
298
|
+
const query = prefix.trim().toLowerCase();
|
|
299
|
+
const items: AutocompleteItem[] = [
|
|
300
|
+
{ value: "once", label: "once", description: "play one time then close" },
|
|
301
|
+
{ value: "loop", label: "loop", description: "loop until ESC/Q" },
|
|
302
|
+
{ value: "fast", label: "fast", description: "90ms per frame" },
|
|
303
|
+
{ value: "normal", label: "normal", description: "180ms per frame" },
|
|
304
|
+
{ value: "slow", label: "slow", description: "300ms per frame" },
|
|
305
|
+
{ value: "5x3", label: "5x3", description: "narrower terminal-pixel ratio" },
|
|
306
|
+
{ value: "6x3", label: "6x3", description: "default terminal-pixel ratio" },
|
|
307
|
+
{ value: "plain", label: "plain", description: "uncolored terminal foreground" },
|
|
308
|
+
{ value: "neon", label: "neon", description: "theme accent blocks" },
|
|
309
|
+
{ value: "reset-first-run", label: "reset-first-run", description: "show startup intro again on next Pi/ZOB launch" },
|
|
310
|
+
{ value: "help", label: "help", description: "insert usage help" },
|
|
311
|
+
];
|
|
312
|
+
const filtered = query
|
|
313
|
+
? items.filter((item) => item.value.toLowerCase().startsWith(query) || item.label.toLowerCase().includes(query) || item.description?.toLowerCase().includes(query))
|
|
314
|
+
: items;
|
|
315
|
+
return filtered.length > 0 ? filtered.slice(0, 20) : null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function stableFrameHash(): string {
|
|
319
|
+
return sha256(JSON.stringify({ morphFrames: ZOB_INTRO_MORPH_FRAMES, targetFrame: ZOB_INTRO_TARGET_FRAME, exitFrameCount: ZOB_INTRO_EXIT_FRAME_COUNT, wrapPauseFrameCount: ZOB_INTRO_WRAP_PAUSE_FRAME_COUNT, wrapPassFrameCount: ZOB_INTRO_WRAP_PASS_FRAME_COUNT }));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function appendZobIntroLedger(pi: ExtensionAPI, options: ZobIntroOptions, status: "played" | "blocked" | "help" | "reset" | "auto_skipped", errors: string[] = []): void {
|
|
323
|
+
pi.appendEntry("zob-intro-command", {
|
|
324
|
+
schema: "zob.intro-command.v1",
|
|
325
|
+
status,
|
|
326
|
+
frameCount: ZOB_INTRO_FRAME_COUNT,
|
|
327
|
+
logicalCols: "dynamic",
|
|
328
|
+
logicalRows: LOGICAL_ROWS,
|
|
329
|
+
blockWidth: options.blockWidth,
|
|
330
|
+
blockHeight: options.blockHeight,
|
|
331
|
+
tickMs: options.tickMs,
|
|
332
|
+
repeat: options.repeat ?? "loop",
|
|
333
|
+
style: options.style,
|
|
334
|
+
frameHash: stableFrameHash(),
|
|
335
|
+
errorHashes: errors.map((error) => sha256(error)),
|
|
336
|
+
rawFramesStored: false,
|
|
337
|
+
bodyStored: false,
|
|
338
|
+
generatedAt: new Date().toISOString(),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function firstRunMarkerPath(cwd: string): string {
|
|
343
|
+
return join(cwd, FIRST_RUN_MARKER_RELATIVE_PATH);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function hasSeenFirstRunIntro(cwd: string): Promise<boolean> {
|
|
347
|
+
try {
|
|
348
|
+
await readFile(firstRunMarkerPath(cwd), "utf8");
|
|
349
|
+
return true;
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return false;
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function markFirstRunIntroSeen(cwd: string): Promise<void> {
|
|
357
|
+
const markerPath = firstRunMarkerPath(cwd);
|
|
358
|
+
await mkdir(dirname(markerPath), { recursive: true });
|
|
359
|
+
await writeFile(markerPath, `${JSON.stringify({ schema: "zob.intro-first-run-marker.v1", seenAt: new Date().toISOString(), frameHash: stableFrameHash(), bodyStored: false }, null, 2)}\n`, "utf8");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function resetFirstRunIntro(cwd: string): Promise<void> {
|
|
363
|
+
try {
|
|
364
|
+
await unlink(firstRunMarkerPath(cwd));
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) throw error;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
class ZobIntroComponent implements Component {
|
|
371
|
+
private frameIndex = 0;
|
|
372
|
+
private completedCycles = 0;
|
|
373
|
+
private paused = false;
|
|
374
|
+
private interval: ReturnType<typeof setInterval> | undefined;
|
|
375
|
+
private welcomeInterval: ReturnType<typeof setInterval> | undefined;
|
|
376
|
+
private cachedWidth = 0;
|
|
377
|
+
private cachedHeight = 0;
|
|
378
|
+
private cachedVersion = -1;
|
|
379
|
+
private cachedLines: string[] = [];
|
|
380
|
+
private version = 0;
|
|
381
|
+
private closed = false;
|
|
382
|
+
private waitingForStart = true;
|
|
383
|
+
private welcomeTick = 0;
|
|
384
|
+
private buildHoldTick = 0;
|
|
385
|
+
|
|
386
|
+
constructor(
|
|
387
|
+
private readonly tui: Pick<TUI, "requestRender" | "terminal">,
|
|
388
|
+
private readonly styles: ZobIntroStyles,
|
|
389
|
+
private readonly options: ZobIntroOptions,
|
|
390
|
+
private readonly onDone: () => void,
|
|
391
|
+
) {
|
|
392
|
+
this.tui.terminal.clearScreen();
|
|
393
|
+
this.tui.terminal.hideCursor();
|
|
394
|
+
this.startWelcomeReveal();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
handleInput(data: string): void {
|
|
398
|
+
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
399
|
+
this.close();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (this.waitingForStart) {
|
|
403
|
+
if (matchesKey(data, "enter") || matchesKey(data, "space") || data === "\r" || data === "\n" || data === " ") {
|
|
404
|
+
this.waitingForStart = false;
|
|
405
|
+
this.stopWelcomeReveal();
|
|
406
|
+
this.start();
|
|
407
|
+
this.version += 1;
|
|
408
|
+
this.tui.requestRender();
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (matchesKey(data, "space") || data === " ") {
|
|
413
|
+
this.paused = !this.paused;
|
|
414
|
+
this.version += 1;
|
|
415
|
+
this.tui.requestRender();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
invalidate(): void {
|
|
420
|
+
this.cachedWidth = 0;
|
|
421
|
+
this.cachedHeight = 0;
|
|
422
|
+
this.cachedVersion = -1;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
render(width: number): string[] {
|
|
426
|
+
const height = Math.max(1, this.tui.terminal.rows);
|
|
427
|
+
if (this.cachedWidth === width && this.cachedHeight === height && this.cachedVersion === this.version) return this.cachedLines;
|
|
428
|
+
|
|
429
|
+
const welcomeLines = this.waitingForStart ? this.renderWelcomeLines(width) : [];
|
|
430
|
+
const artLines = this.renderArt(this.options.blockWidth, this.options.blockHeight, this.logicalColsForWidth(width));
|
|
431
|
+
const emptyLine = " ".repeat(Math.max(0, width));
|
|
432
|
+
const contentHeight = artLines.length + welcomeLines.length;
|
|
433
|
+
const topPad = Math.max(0, Math.floor((height - contentHeight) / 2));
|
|
434
|
+
const lines: string[] = [];
|
|
435
|
+
|
|
436
|
+
for (let index = 0; index < topPad; index += 1) lines.push(emptyLine);
|
|
437
|
+
for (const artLine of artLines) lines.push(this.centerLine(artLine, width));
|
|
438
|
+
for (const welcomeLine of welcomeLines) lines.push(this.centerLine(welcomeLine, width, WELCOME_TEXT_SHIFT_LEFT));
|
|
439
|
+
while (lines.length < height) lines.push(emptyLine);
|
|
440
|
+
|
|
441
|
+
this.cachedLines = lines.slice(0, height);
|
|
442
|
+
this.cachedWidth = width;
|
|
443
|
+
this.cachedHeight = height;
|
|
444
|
+
this.cachedVersion = this.version;
|
|
445
|
+
return this.cachedLines;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
dispose(): void {
|
|
449
|
+
if (this.interval) {
|
|
450
|
+
clearInterval(this.interval);
|
|
451
|
+
this.interval = undefined;
|
|
452
|
+
}
|
|
453
|
+
this.stopWelcomeReveal();
|
|
454
|
+
this.tui.terminal.showCursor();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private startWelcomeReveal(): void {
|
|
458
|
+
this.welcomeInterval = setInterval(() => {
|
|
459
|
+
if (!this.waitingForStart) return;
|
|
460
|
+
this.welcomeTick += 1;
|
|
461
|
+
this.version += 1;
|
|
462
|
+
this.tui.requestRender();
|
|
463
|
+
}, 55);
|
|
464
|
+
this.welcomeInterval.unref?.();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private stopWelcomeReveal(): void {
|
|
468
|
+
if (!this.welcomeInterval) return;
|
|
469
|
+
clearInterval(this.welcomeInterval);
|
|
470
|
+
this.welcomeInterval = undefined;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private start(): void {
|
|
474
|
+
if (this.interval) return;
|
|
475
|
+
this.interval = setInterval(() => {
|
|
476
|
+
if (this.paused) return;
|
|
477
|
+
if (this.shouldHoldBuildFrame()) return;
|
|
478
|
+
this.advanceFrame();
|
|
479
|
+
this.version += 1;
|
|
480
|
+
this.tui.requestRender();
|
|
481
|
+
}, this.options.tickMs);
|
|
482
|
+
this.interval.unref?.();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private shouldHoldBuildFrame(): boolean {
|
|
486
|
+
if (this.frameIndex >= ZOB_INTRO_MORPH_FRAMES.length) {
|
|
487
|
+
this.buildHoldTick = 0;
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
this.buildHoldTick = (this.buildHoldTick + 1) % BUILD_FRAME_TICK_HOLD;
|
|
491
|
+
return this.buildHoldTick !== 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private advanceFrame(): void {
|
|
495
|
+
this.frameIndex += 1;
|
|
496
|
+
if (this.frameIndex < ZOB_INTRO_FRAME_COUNT) return;
|
|
497
|
+
|
|
498
|
+
this.completedCycles += 1;
|
|
499
|
+
if (this.options.repeat !== null && this.completedCycles >= this.options.repeat) {
|
|
500
|
+
this.frameIndex = ZOB_INTRO_FRAME_COUNT - 1;
|
|
501
|
+
this.close();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.frameIndex = 0;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private close(): void {
|
|
508
|
+
if (this.closed) return;
|
|
509
|
+
this.closed = true;
|
|
510
|
+
this.dispose();
|
|
511
|
+
this.onDone();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private renderWelcomeLines(width: number): string[] {
|
|
515
|
+
const titleVisible = this.revealText(WELCOME_TITLE, this.welcomeTick);
|
|
516
|
+
const promptStartTick = WELCOME_TITLE.length + 8;
|
|
517
|
+
const promptVisible = this.revealText(START_PROMPT, Math.max(0, this.welcomeTick - promptStartTick));
|
|
518
|
+
const title = this.styles.bold(this.styles.accent(titleVisible)) + " ".repeat(Math.max(0, WELCOME_TITLE.length - titleVisible.length));
|
|
519
|
+
const promptPulse = Math.floor(this.welcomeTick / 12) % 2 === 0;
|
|
520
|
+
const promptText = promptPulse ? this.styles.dim(promptVisible) : this.styles.accent(promptVisible);
|
|
521
|
+
const prompt = promptText + " ".repeat(Math.max(0, START_PROMPT.length - promptVisible.length));
|
|
522
|
+
return ["", title, prompt].filter((line) => visibleWidth(line) <= width || line.length > 0);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private revealText(text: string, tick: number): string {
|
|
526
|
+
if (tick <= 0) return "";
|
|
527
|
+
return text.slice(0, Math.min(text.length, tick));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private logicalColsForWidth(width: number): number {
|
|
531
|
+
return Math.max(ZOB_INTRO_MIN_CANVAS_COLS, Math.floor(width / Math.max(1, this.options.blockWidth)));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private renderArt(blockWidth: number, blockHeight: number, logicalCols: number): string[] {
|
|
535
|
+
const frame = buildIntroFrame(this.frameIndex, logicalCols);
|
|
536
|
+
const lines: string[] = [];
|
|
537
|
+
|
|
538
|
+
for (let rowIndex = 0; rowIndex < LOGICAL_ROWS; rowIndex += 1) {
|
|
539
|
+
const logicalRow = (frame[rowIndex] ?? "").padEnd(logicalCols, ".").slice(0, logicalCols);
|
|
540
|
+
for (let subRow = 0; subRow < blockHeight; subRow += 1) {
|
|
541
|
+
let renderedRow = "";
|
|
542
|
+
for (const cell of logicalRow) renderedRow += this.renderCell(cell, subRow, blockWidth, blockHeight);
|
|
543
|
+
lines.push(renderedRow);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return lines;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private renderCell(cell: string, subRow: number, blockWidth: number, blockHeight: number): string {
|
|
550
|
+
const blank = " ".repeat(blockWidth);
|
|
551
|
+
const solid = (glyph: string, color: (text: string) => string): string => {
|
|
552
|
+
const text = glyph.repeat(blockWidth);
|
|
553
|
+
return this.options.style === "plain" ? text : color(text);
|
|
554
|
+
};
|
|
555
|
+
if (cell === "#") return solid("█", this.styles.block);
|
|
556
|
+
if (cell === "*") return solid("▓", this.styles.exhaustHot);
|
|
557
|
+
if (cell === "+") return solid("▒", this.styles.exhaustDim);
|
|
558
|
+
if (cell === "=") {
|
|
559
|
+
const middle = Math.floor(blockHeight / 2);
|
|
560
|
+
return subRow === middle ? solid("━", this.styles.exhaustHot) : blank;
|
|
561
|
+
}
|
|
562
|
+
if (cell === "~") {
|
|
563
|
+
const middle = Math.floor(blockHeight / 2);
|
|
564
|
+
return subRow === middle ? solid("·", this.styles.exhaustDim) : blank;
|
|
565
|
+
}
|
|
566
|
+
if (cell === "-") {
|
|
567
|
+
const middle = Math.floor(blockHeight / 2);
|
|
568
|
+
return subRow === middle ? solid("─", this.styles.exhaustDim) : blank;
|
|
569
|
+
}
|
|
570
|
+
return blank;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private centerLine(line: string, width: number, shiftRight = 0): string {
|
|
574
|
+
if (width <= 0) return "";
|
|
575
|
+
const lineWidth = visibleWidth(line);
|
|
576
|
+
if (lineWidth >= width) return truncateToWidth(line, width, "");
|
|
577
|
+
const leftPad = Math.max(0, Math.min(width - lineWidth, Math.floor((width - lineWidth) / 2) + shiftRight));
|
|
578
|
+
return `${" ".repeat(leftPad)}${line}`;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function handleZobIntroCommand(pi: ExtensionAPI, args: string, ctx: ExtensionContext): Promise<void> {
|
|
583
|
+
const normalized = args.trim().toLowerCase();
|
|
584
|
+
if (normalized === "reset-first-run" || normalized === "reset-startup" || normalized === "reset") {
|
|
585
|
+
await resetFirstRunIntro(ctx.cwd);
|
|
586
|
+
appendZobIntroLedger(pi, DEFAULT_ZOB_INTRO_OPTIONS, "reset");
|
|
587
|
+
ctx.ui.notify("ZOB intro first-run marker reset. Quit and relaunch Pi/ZOB to see the startup intro again.", "info");
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const parsed = parseZobIntroArgs(args);
|
|
592
|
+
if (parsed.ok && parsed.help) {
|
|
593
|
+
appendZobIntroLedger(pi, parsed.options, "help");
|
|
594
|
+
ctx.ui.setEditorText(zobIntroHelpTemplate());
|
|
595
|
+
ctx.ui.notify("ZOB intro help inserted. Use /zob-intro or /zob-intro loop.", "info");
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (!parsed.ok) {
|
|
599
|
+
appendZobIntroLedger(pi, parsed.options, "blocked", parsed.errors);
|
|
600
|
+
ctx.ui.notify(`/zob-intro blocked: ${parsed.errors.join(" | ")}. Use /zob-intro help.`, "warning");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (!ctx.hasUI) {
|
|
604
|
+
appendZobIntroLedger(pi, parsed.options, "blocked", ["interactive UI required"]);
|
|
605
|
+
ctx.ui.notify("/zob-intro requires interactive Pi TUI mode.", "warning");
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
appendZobIntroLedger(pi, parsed.options, "played");
|
|
610
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
611
|
+
const styles: ZobIntroStyles = {
|
|
612
|
+
accent: (text) => theme.fg("accent", text),
|
|
613
|
+
block: (text) => theme.fg("accent", text),
|
|
614
|
+
bold: (text) => theme.bold(text),
|
|
615
|
+
dim: (text) => theme.fg("dim", text),
|
|
616
|
+
exhaustHot: (text) => theme.fg("warning", text),
|
|
617
|
+
exhaustDim: (text) => theme.fg("dim", text),
|
|
618
|
+
warning: (text) => theme.fg("warning", text),
|
|
619
|
+
};
|
|
620
|
+
return new ZobIntroComponent(tui, styles, parsed.options, () => done(undefined));
|
|
621
|
+
}, {
|
|
622
|
+
overlay: true,
|
|
623
|
+
overlayOptions: {
|
|
624
|
+
width: "100%",
|
|
625
|
+
maxHeight: "100%",
|
|
626
|
+
anchor: "center",
|
|
627
|
+
margin: 0,
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function registerZobIntroCommand(pi: ExtensionAPI): void {
|
|
633
|
+
const command = {
|
|
634
|
+
description: "Play the custom ZOB pixel-art terminal intro animation. Options: once|loop|fast|slow|5x3|6x3|plain|neon.",
|
|
635
|
+
getArgumentCompletions: zobIntroArgumentCompletions,
|
|
636
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
637
|
+
await handleZobIntroCommand(pi, args, ctx);
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
pi.registerCommand("zob-intro", command);
|
|
642
|
+
pi.registerCommand("zintro", { ...command, description: "Alias for /zob-intro." });
|
|
643
|
+
pi.registerCommand("intro", { ...command, description: "Alias for /zob-intro." });
|
|
644
|
+
|
|
645
|
+
pi.on("session_start", async (event, ctx) => {
|
|
646
|
+
if (event.reason !== "startup") return;
|
|
647
|
+
if (!ctx.hasUI) return;
|
|
648
|
+
if (await hasSeenFirstRunIntro(ctx.cwd)) {
|
|
649
|
+
appendZobIntroLedger(pi, DEFAULT_ZOB_INTRO_OPTIONS, "auto_skipped");
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
await markFirstRunIntroSeen(ctx.cwd);
|
|
653
|
+
await handleZobIntroCommand(pi, FIRST_RUN_ARGS, ctx);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
pi.on("input", async (event, ctx) => {
|
|
657
|
+
const match = /^\/(zob-intro|zintro|intro)(?:\s+(.*))?$/.exec(event.text.trim());
|
|
658
|
+
if (!match) return { action: "continue" as const };
|
|
659
|
+
await handleZobIntroCommand(pi, match[2] ?? "", ctx);
|
|
660
|
+
return { action: "handled" as const };
|
|
661
|
+
});
|
|
662
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zob-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A governed Agent Factory for Pi: launch communicating agent teams, run tmux-backed factories, validate artifacts, and package repeatable workflows.",
|
|
6
6
|
"license": "MIT",
|