@pi-archimedes/core 2.7.3 → 2.9.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.
- package/README.md +6 -27
- package/package.json +8 -6
- package/src/bridge/channel.test.ts +52 -0
- package/src/bridge/channel.ts +322 -0
- package/src/bridge/events.ts +197 -0
- package/src/bridge/index.test.ts +722 -0
- package/src/bridge/index.ts +166 -0
- package/src/bridge/self-usage.test.ts +146 -0
- package/src/bridge/self-usage.ts +37 -0
- package/src/bus.ts +8 -1
- package/src/config.test.ts +8 -103
- package/src/config.ts +4 -66
- package/src/index.test.ts +19 -350
- package/src/index.ts +12 -269
- package/src/tool-render.ts +1 -1
- package/src/editor/index.test.ts +0 -820
- package/src/editor/index.ts +0 -379
- package/src/editor/spin-quips.test.ts +0 -106
- package/src/editor/spin-quips.ts +0 -44
- package/src/editor/spin.test.ts +0 -702
- package/src/editor/spin.ts +0 -471
- package/src/startup/capture.ts +0 -47
- package/src/startup/index.ts +0 -330
- package/src/startup/logo.test.ts +0 -260
- package/src/startup/logo.ts +0 -85
- package/src/startup/sections.test.ts +0 -314
- package/src/startup/sections.ts +0 -284
- package/src/startup/version.test.ts +0 -98
- package/src/startup/version.ts +0 -26
- package/src/thinking/patch.test.ts +0 -389
- package/src/thinking/patch.ts +0 -288
- package/src/thinking/theme.test.ts +0 -216
- package/src/thinking/theme.ts +0 -156
- package/src/thinking/transform.test.ts +0 -89
- package/src/thinking/transform.ts +0 -21
- package/src/thinking/unindent.test.ts +0 -74
- package/src/thinking/unindent.ts +0 -74
package/src/startup/index.ts
DELETED
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
import { VERSION, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { getShinedLogo, TRUECOLOR, LOGO_PAD, LOGO_SETTLE_FRAME } from "./logo.js";
|
|
3
|
-
import { loadCoreConfig } from "../config.js";
|
|
4
|
-
import { detectSection, parseSectionText, parseModelScope, formatColumns, buildItemWrapper, type ParsedSection, SECTION_KEYS } from "./sections.js";
|
|
5
|
-
import { fetchLatestVersion, compareVersions } from "./version.js";
|
|
6
|
-
import { stripAnsi } from "../text.js";
|
|
7
|
-
import { Text, Spacer, Container, type TUI, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
8
|
-
|
|
9
|
-
// Symbol keys (survive hot-reload)
|
|
10
|
-
const LISTING_REF = Symbol.for("splashscreen:listingRef");
|
|
11
|
-
const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
|
|
12
|
-
const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
|
|
13
|
-
const PATCHED_CLEAR = Symbol.for("splashscreen:clearPatched");
|
|
14
|
-
const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
|
|
15
|
-
const ORIG_ADD_CHILD = Symbol.for("splashscreen:origAddChild");
|
|
16
|
-
|
|
17
|
-
// Animation constants
|
|
18
|
-
const MAX_RENDER_WIDTH = 9999;
|
|
19
|
-
const MIN_HEADER_LINES = 11;
|
|
20
|
-
const REVEAL_DEBOUNCE_MS = 150;
|
|
21
|
-
const RAMP_FRAMES = 22;
|
|
22
|
-
const STAGGER_FRAMES = 0;
|
|
23
|
-
const BASE_FADE_DELAY = 3;
|
|
24
|
-
const MAX_STAGGER = BASE_FADE_DELAY + 5 * STAGGER_FRAMES;
|
|
25
|
-
|
|
26
|
-
export interface ListingRef {
|
|
27
|
-
sections: ParsedSection[];
|
|
28
|
-
frame: number;
|
|
29
|
-
revealed: boolean;
|
|
30
|
-
revealedAt: number;
|
|
31
|
-
scaffoldAt: number;
|
|
32
|
-
latestVersion?: string;
|
|
33
|
-
settled: boolean;
|
|
34
|
-
cachedLines?: string[];
|
|
35
|
-
cachedWidth?: number;
|
|
36
|
-
cachedHeight?: number;
|
|
37
|
-
maxHeaderHeight?: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── renderHeader() — composes logo + sections + padding ────────────────
|
|
41
|
-
|
|
42
|
-
export function renderHeader(theme: Theme, ref: ListingRef, width: number, height: number): string[] {
|
|
43
|
-
const dim = (t: string) => theme.fg("dim", t);
|
|
44
|
-
const accent = (t: string) => theme.fg("accent", t);
|
|
45
|
-
const logoLines = getShinedLogo(ref.frame, loadCoreConfig().animationStyle);
|
|
46
|
-
|
|
47
|
-
// Use cached text lines if settled (no more animations)
|
|
48
|
-
let listingLines: string[];
|
|
49
|
-
|
|
50
|
-
if (ref.settled && ref.cachedLines && ref.cachedWidth === width && ref.cachedHeight === height) {
|
|
51
|
-
listingLines = ref.cachedLines;
|
|
52
|
-
} else {
|
|
53
|
-
const sectionsToRender: { name: "Version" | ParsedSection["name"]; items: string[] }[] = [];
|
|
54
|
-
|
|
55
|
-
if (!ref.revealed) {
|
|
56
|
-
// Logo only — sections appear together on reveal
|
|
57
|
-
} else {
|
|
58
|
-
const latest = ref.latestVersion ?? VERSION;
|
|
59
|
-
const hasUpdate = compareVersions(latest, VERSION) > 0;
|
|
60
|
-
const latestStr = hasUpdate ? `Latest: ${accent("v" + latest)}` : `Latest: v${latest}`;
|
|
61
|
-
sectionsToRender.push({ name: "Version", items: [`Local: v${VERSION}`, latestStr] });
|
|
62
|
-
|
|
63
|
-
// Display sections in SECTION_KEYS order, skip empty
|
|
64
|
-
const byName = new Map(ref.sections.map(s => [s.name, s]));
|
|
65
|
-
for (const key of SECTION_KEYS) {
|
|
66
|
-
const sec = byName.get(key);
|
|
67
|
-
if (sec && sec.items.length > 0) sectionsToRender.push(sec);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const listingMaxW = Math.floor(width * 0.7);
|
|
72
|
-
listingLines = formatColumns(sectionsToRender, theme, listingMaxW, ref);
|
|
73
|
-
|
|
74
|
-
// Cache once text animations are done
|
|
75
|
-
const textAge = ref.revealed ? ref.frame - ref.revealedAt : 0;
|
|
76
|
-
const textDone = ref.revealed && textAge > RAMP_FRAMES + MAX_STAGGER;
|
|
77
|
-
const logoDone = ref.frame >= LOGO_SETTLE_FRAME;
|
|
78
|
-
|
|
79
|
-
if (textDone && logoDone) {
|
|
80
|
-
ref.settled = true;
|
|
81
|
-
ref.cachedLines = listingLines;
|
|
82
|
-
ref.cachedWidth = width;
|
|
83
|
-
ref.cachedHeight = height;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Build content: logo + separator + listing
|
|
88
|
-
const contentLines: string[] = [];
|
|
89
|
-
|
|
90
|
-
// Logo centered above listing (same center as listing text)
|
|
91
|
-
const listingMaxW = Math.floor(width * 0.7);
|
|
92
|
-
const listingLeftPad = Math.floor((width - listingMaxW) / 2);
|
|
93
|
-
for (const logoRow of logoLines) {
|
|
94
|
-
const pad = Math.floor((listingMaxW - visibleWidth(logoRow)) / 2);
|
|
95
|
-
contentLines.push(" ".repeat(LOGO_PAD) + " ".repeat(listingLeftPad + pad) + logoRow);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Separator
|
|
99
|
-
contentLines.push("");
|
|
100
|
-
|
|
101
|
-
// Listing
|
|
102
|
-
for (const listRow of listingLines) {
|
|
103
|
-
contentLines.push(" ".repeat(LOGO_PAD) + " ".repeat(listingLeftPad) + listRow);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Pad top and bottom to fill height (+5 top bias for visual centering)
|
|
107
|
-
const VERTICAL_CENTER_BIAS = 5; // Shift content slightly upward for visual balance
|
|
108
|
-
const contentHeight = contentLines.length;
|
|
109
|
-
const remaining = Math.max(0, height - contentHeight);
|
|
110
|
-
const topPad = Math.floor(remaining / 2) + VERTICAL_CENTER_BIAS;
|
|
111
|
-
const bottomPad = remaining - topPad;
|
|
112
|
-
|
|
113
|
-
const result: string[] = [];
|
|
114
|
-
for (let i = 0; i < topPad; i++) result.push("");
|
|
115
|
-
for (const line of contentLines) result.push(line);
|
|
116
|
-
for (let i = 0; i < bottomPad; i++) result.push("");
|
|
117
|
-
|
|
118
|
-
return result;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ── Chat container discovery ────────────────────────────────────────────
|
|
122
|
-
|
|
123
|
-
// Fragile: relies on TUI child ordering (header, chat, footer) which is an
|
|
124
|
-
// internal layout detail of pi's InteractiveMode. If upstream changes the
|
|
125
|
-
// child structure, this will need updating.
|
|
126
|
-
//
|
|
127
|
-
// Strategy (most specific → least specific):
|
|
128
|
-
// 1. Container with "Scrollable" in constructor name
|
|
129
|
-
// 2. Middle child — TUI layout is [headerContainer, chatContainer, footer]
|
|
130
|
-
// This is position-stable even when chatContainer is empty (e.g. after clear)
|
|
131
|
-
// 3. Container with most children (chat usually has the most)
|
|
132
|
-
// 4. First Container found
|
|
133
|
-
function findChatContainer(tui: TUI): Container | undefined {
|
|
134
|
-
// Strategy 1: Look for Scrollable container
|
|
135
|
-
for (const child of tui.children) {
|
|
136
|
-
if (child instanceof Container && child.constructor.name.includes("Scrollable")) {
|
|
137
|
-
return child;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Strategy 2: Middle child — TUI layout is [headerContainer, chatContainer, footer]
|
|
142
|
-
if (tui.children.length >= 3) {
|
|
143
|
-
const middle = tui.children[1];
|
|
144
|
-
if (middle instanceof Container) return middle;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Strategy 3: Find the Container with the most children (chat area)
|
|
148
|
-
const containers = tui.children.filter((c): c is Container => c instanceof Container);
|
|
149
|
-
if (containers.length > 1) {
|
|
150
|
-
const largest = containers.reduce((best, c) =>
|
|
151
|
-
c.children.length > best.children.length ? c : best,
|
|
152
|
-
);
|
|
153
|
-
if (largest.children.length > 0) return largest;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Strategy 4: First Container
|
|
157
|
-
if (containers.length > 0) return containers[0];
|
|
158
|
-
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Pi 0.84+ moved resource sections from chatContainer to loadedResourcesContainer
|
|
163
|
-
// (child of documentContainer). Find it by locating documentContainer (the first TUI
|
|
164
|
-
// child that contains Container sub-children), then taking its second Container child.
|
|
165
|
-
//
|
|
166
|
-
// Pi 0.84+ TUI layout (regular mode, 7 top-level children):
|
|
167
|
-
// documentContainer → [headerContainer(0), loadedResourcesContainer(1), chatContainer(2)]
|
|
168
|
-
function findLoadedResourcesContainer(tui: TUI): Container | undefined {
|
|
169
|
-
// Only run this heuristic on Pi >= 0.84.0 where loadedResourcesContainer exists.
|
|
170
|
-
// On older Pi versions, return undefined so the caller falls back to findChatContainer().
|
|
171
|
-
if (compareVersions(VERSION, "0.84.0") < 0) return undefined;
|
|
172
|
-
|
|
173
|
-
// Find documentContainer: first tui child that has Container sub-children
|
|
174
|
-
const docContainer = tui.children.find(
|
|
175
|
-
(c) => c instanceof Container && c.children.some((child) => child instanceof Container)
|
|
176
|
-
) as Container | undefined;
|
|
177
|
-
if (!docContainer) return undefined;
|
|
178
|
-
|
|
179
|
-
const containerChildren = docContainer.children.filter(
|
|
180
|
-
(c): c is Container => c instanceof Container
|
|
181
|
-
);
|
|
182
|
-
// Pi 0.84+: [headerContainer(0), loadedResourcesContainer(1), chatContainer(2)]
|
|
183
|
-
// The index [1] assumption is load-bearing — tied to Pi 0.84+ initialization order.
|
|
184
|
-
if (containerChildren.length < 2) return undefined;
|
|
185
|
-
return containerChildren[1]; // loadedResourcesContainer
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function patchStartupListing(
|
|
189
|
-
tui: TUI,
|
|
190
|
-
_theme: Theme,
|
|
191
|
-
ref: ListingRef,
|
|
192
|
-
): void {
|
|
193
|
-
const resourcesContainer = findLoadedResourcesContainer(tui) ?? findChatContainer(tui);
|
|
194
|
-
if (!resourcesContainer) {
|
|
195
|
-
console.warn("[archimedes] Could not find resources container — startup listing disabled. This may indicate a pi TUI structure change.");
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
const rc = resourcesContainer as any;
|
|
199
|
-
|
|
200
|
-
// Always update ref + restart animation (critical for /reload)
|
|
201
|
-
rc[LISTING_REF] = ref;
|
|
202
|
-
ref.frame = 0;
|
|
203
|
-
ref.revealed = false;
|
|
204
|
-
ref.revealedAt = 0;
|
|
205
|
-
ref.scaffoldAt = 0;
|
|
206
|
-
ref.settled = false;
|
|
207
|
-
delete ref.cachedLines;
|
|
208
|
-
delete ref.cachedWidth;
|
|
209
|
-
delete ref.maxHeaderHeight;
|
|
210
|
-
|
|
211
|
-
if (rc[ANIM_INTERVAL]) clearInterval(rc[ANIM_INTERVAL]);
|
|
212
|
-
if (rc[DEBOUNCE_TIMER]) clearTimeout(rc[DEBOUNCE_TIMER]);
|
|
213
|
-
|
|
214
|
-
const interval = setInterval(() => {
|
|
215
|
-
try {
|
|
216
|
-
const current: ListingRef = rc[LISTING_REF];
|
|
217
|
-
if (!current) {
|
|
218
|
-
clearInterval(interval);
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
current.frame++;
|
|
222
|
-
if (current.settled && current.frame >= LOGO_SETTLE_FRAME) {
|
|
223
|
-
clearInterval(interval);
|
|
224
|
-
rc[ANIM_INTERVAL] = null;
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
tui.requestRender();
|
|
228
|
-
} catch {
|
|
229
|
-
clearInterval(interval);
|
|
230
|
-
}
|
|
231
|
-
}, 16);
|
|
232
|
-
|
|
233
|
-
rc[ANIM_INTERVAL] = interval;
|
|
234
|
-
|
|
235
|
-
// Fetch latest version from npm
|
|
236
|
-
fetchLatestVersion().then(v => {
|
|
237
|
-
if (v) {
|
|
238
|
-
const current: ListingRef = rc[LISTING_REF];
|
|
239
|
-
current.latestVersion = v;
|
|
240
|
-
// Invalidate cache so version updates on next render
|
|
241
|
-
delete current.cachedLines;
|
|
242
|
-
current.settled = false;
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// Patch clear() to intercept container rebuild
|
|
247
|
-
if (!rc[PATCHED_CLEAR]) {
|
|
248
|
-
rc[PATCHED_CLEAR] = true;
|
|
249
|
-
const origClear = resourcesContainer.clear.bind(resourcesContainer);
|
|
250
|
-
resourcesContainer.clear = () => {
|
|
251
|
-
return origClear();
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Only patch addChild once — the closure reads rc[LISTING_REF] dynamically
|
|
256
|
-
if (rc[PATCHED_LISTING]) {
|
|
257
|
-
resourcesContainer.clear();
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
rc[PATCHED_LISTING] = true;
|
|
261
|
-
|
|
262
|
-
const origAddChild = resourcesContainer.addChild.bind(resourcesContainer);
|
|
263
|
-
rc[ORIG_ADD_CHILD] = origAddChild; // Store for cleanup on shutdown
|
|
264
|
-
resourcesContainer.clear();
|
|
265
|
-
|
|
266
|
-
resourcesContainer.addChild = (component: Component) => {
|
|
267
|
-
try {
|
|
268
|
-
const currentRef: ListingRef = rc[LISTING_REF];
|
|
269
|
-
|
|
270
|
-
if (component instanceof Text) {
|
|
271
|
-
// pi ≥0.67.6 wraps startup sections in ExpandableText; collapsed body
|
|
272
|
-
// is a lossy comma-joined base-name list. Parse the expanded text so
|
|
273
|
-
// Extensions keep real names instead of "index.ts/index.js/index".
|
|
274
|
-
const getExpanded = (component as any).getExpandedText;
|
|
275
|
-
const plain = typeof getExpanded === "function"
|
|
276
|
-
? stripAnsi(getExpanded.call(component))
|
|
277
|
-
: stripAnsi(component.render(MAX_RENDER_WIDTH).join("\n"));
|
|
278
|
-
|
|
279
|
-
const section = parseSectionText(plain) ?? parseModelScope(plain);
|
|
280
|
-
if (section) {
|
|
281
|
-
const existing = currentRef.sections.find(s => s.name === section.name);
|
|
282
|
-
if (existing) {
|
|
283
|
-
existing.items = [...new Set([...existing.items, ...section.items])];
|
|
284
|
-
} else {
|
|
285
|
-
currentRef.sections.push(section);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Invalidate cache so late-arriving sections show up
|
|
289
|
-
currentRef.settled = false;
|
|
290
|
-
delete currentRef.cachedLines;
|
|
291
|
-
|
|
292
|
-
if (currentRef.revealed) {
|
|
293
|
-
// Already revealed — show new section immediately
|
|
294
|
-
tui.requestRender();
|
|
295
|
-
} else {
|
|
296
|
-
// Batch initial sections — reset debounce on each arrival
|
|
297
|
-
if (rc[DEBOUNCE_TIMER]) clearTimeout(rc[DEBOUNCE_TIMER]);
|
|
298
|
-
rc[DEBOUNCE_TIMER] = setTimeout(() => {
|
|
299
|
-
const ref: ListingRef = rc[LISTING_REF];
|
|
300
|
-
ref.revealed = true;
|
|
301
|
-
ref.revealedAt = ref.frame;
|
|
302
|
-
ref.scaffoldAt = ref.frame;
|
|
303
|
-
tui.requestRender();
|
|
304
|
-
rc[DEBOUNCE_TIMER] = null;
|
|
305
|
-
}, REVEAL_DEBOUNCE_MS);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
plain.includes("Listing all available commands") ||
|
|
313
|
-
plain.includes("(Source: extension)") ||
|
|
314
|
-
plain.trim().startsWith("/skill:")
|
|
315
|
-
) {
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (component instanceof Spacer && !currentRef.revealed) return;
|
|
321
|
-
try {
|
|
322
|
-
origAddChild(component);
|
|
323
|
-
} catch (err) {
|
|
324
|
-
console.error("[archimedes] Error in resourcesContainer.addChild:", err);
|
|
325
|
-
}
|
|
326
|
-
} catch (err) {
|
|
327
|
-
console.error("[archimedes] Error in startup listing addChild handler:", err);
|
|
328
|
-
}
|
|
329
|
-
};
|
|
330
|
-
}
|
package/src/startup/logo.test.ts
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import * as fc from "fast-check";
|
|
3
|
-
import { stripAnsi } from "../text.js";
|
|
4
|
-
|
|
5
|
-
// ── Helpers for dynamic imports with controlled TRUECOLOR ────────────────────
|
|
6
|
-
|
|
7
|
-
async function importLogoWithTruecolor(truecolor: boolean) {
|
|
8
|
-
vi.resetModules();
|
|
9
|
-
|
|
10
|
-
// Set env before the module loads
|
|
11
|
-
const origColorterm = process.env.COLORTERM;
|
|
12
|
-
const origTerm = process.env.TERM;
|
|
13
|
-
const origTermProgram = process.env.TERM_PROGRAM;
|
|
14
|
-
const origWtSession = process.env.WT_SESSION;
|
|
15
|
-
|
|
16
|
-
if (truecolor) {
|
|
17
|
-
process.env.COLORTERM = "truecolor";
|
|
18
|
-
} else {
|
|
19
|
-
delete process.env.COLORTERM;
|
|
20
|
-
process.env.TERM = "xterm";
|
|
21
|
-
delete process.env.TERM_PROGRAM;
|
|
22
|
-
delete process.env.WT_SESSION;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const mod = await import("./logo.js");
|
|
26
|
-
|
|
27
|
-
// Restore env
|
|
28
|
-
if (origColorterm === undefined) delete process.env.COLORTERM;
|
|
29
|
-
else process.env.COLORTERM = origColorterm;
|
|
30
|
-
if (origTerm === undefined) delete process.env.TERM;
|
|
31
|
-
else process.env.TERM = origTerm;
|
|
32
|
-
if (origTermProgram === undefined) delete process.env.TERM_PROGRAM;
|
|
33
|
-
else process.env.TERM_PROGRAM = origTermProgram;
|
|
34
|
-
if (origWtSession === undefined) delete process.env.WT_SESSION;
|
|
35
|
-
else process.env.WT_SESSION = origWtSession;
|
|
36
|
-
|
|
37
|
-
return mod;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── LOGO structure (static, no env dependency) ──────────────────────────────
|
|
41
|
-
|
|
42
|
-
describe("LOGO structure", () => {
|
|
43
|
-
it("has 8 rows", async () => {
|
|
44
|
-
const mod = await importLogoWithTruecolor(false);
|
|
45
|
-
expect(mod.LOGO.length).toBe(8);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("each row has 16 characters", async () => {
|
|
49
|
-
const mod = await importLogoWithTruecolor(false);
|
|
50
|
-
for (const row of mod.LOGO) {
|
|
51
|
-
expect(row.length).toBe(16);
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("first row is 12 blocks + 4 spaces", async () => {
|
|
56
|
-
const mod = await importLogoWithTruecolor(false);
|
|
57
|
-
expect(mod.LOGO[0]).toBe("████████████ ");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("last row is 4 blocks + 6 spaces + 4 blocks", async () => {
|
|
61
|
-
const mod = await importLogoWithTruecolor(false);
|
|
62
|
-
expect(mod.LOGO[7]).toBe("████ ████");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("LOGO contains only block chars and spaces", async () => {
|
|
66
|
-
const mod = await importLogoWithTruecolor(false);
|
|
67
|
-
for (const row of mod.LOGO) {
|
|
68
|
-
for (const ch of row) {
|
|
69
|
-
expect(ch === " " || ch === "█").toBe(true);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
// ── Animation constants ──────────────────────────────────────────────────────
|
|
76
|
-
|
|
77
|
-
describe("animation constants", () => {
|
|
78
|
-
it("CHAR_FADE_FRAMES is 22", async () => {
|
|
79
|
-
const mod = await importLogoWithTruecolor(false);
|
|
80
|
-
expect(mod.CHAR_FADE_FRAMES).toBe(22);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("LOGO_SETTLE_FRAME is 90", async () => {
|
|
84
|
-
const mod = await importLogoWithTruecolor(false);
|
|
85
|
-
expect(mod.LOGO_SETTLE_FRAME).toBe(90);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("LOGO_PAD is 0", async () => {
|
|
89
|
-
const mod = await importLogoWithTruecolor(false);
|
|
90
|
-
expect(mod.LOGO_PAD).toBe(0);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("LOGO_GAP is 4", async () => {
|
|
94
|
-
const mod = await importLogoWithTruecolor(false);
|
|
95
|
-
expect(mod.LOGO_GAP).toBe(4);
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// ── TRUECOLOR detection ──────────────────────────────────────────────────────
|
|
100
|
-
|
|
101
|
-
describe("TRUECOLOR", () => {
|
|
102
|
-
it("is true when COLORTERM contains truecolor", async () => {
|
|
103
|
-
const mod = await importLogoWithTruecolor(true);
|
|
104
|
-
expect(mod.TRUECOLOR).toBe(true);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("is false when no truecolor env vars set", async () => {
|
|
108
|
-
const mod = await importLogoWithTruecolor(false);
|
|
109
|
-
expect(mod.TRUECOLOR).toBe(false);
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// ── getShinedLogo — non-truecolor ────────────────────────────────────────────
|
|
114
|
-
|
|
115
|
-
describe("getShinedLogo (non-truecolor)", () => {
|
|
116
|
-
let mod: typeof import("./logo.js");
|
|
117
|
-
|
|
118
|
-
beforeEach(async () => {
|
|
119
|
-
mod = await importLogoWithTruecolor(false);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("returns LOGO unchanged when TRUECOLOR is false", () => {
|
|
123
|
-
const result = mod.getShinedLogo(0, "wave");
|
|
124
|
-
expect(result).toBe(mod.LOGO);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("returns LOGO at any frame when TRUECOLOR is false", () => {
|
|
128
|
-
expect(mod.getShinedLogo(999, "wave")).toBe(mod.LOGO);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it("returns 8 rows regardless of frame", () => {
|
|
132
|
-
expect(mod.getShinedLogo(50, "diagonal").length).toBe(8);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
// ── getShinedLogo — truecolor ────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
describe("getShinedLogo (truecolor)", () => {
|
|
139
|
-
let mod: typeof import("./logo.js");
|
|
140
|
-
|
|
141
|
-
beforeEach(async () => {
|
|
142
|
-
mod = await importLogoWithTruecolor(true);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("returns 8 rows", () => {
|
|
146
|
-
expect(mod.getShinedLogo(0, "wave").length).toBe(8);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("returns different output than LOGO when TRUECOLOR is true", () => {
|
|
150
|
-
const result = mod.getShinedLogo(50, "wave");
|
|
151
|
-
expect(result).not.toBe(mod.LOGO);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("early frames show spaces for not-yet-revealed chars", () => {
|
|
155
|
-
const result = mod.getShinedLogo(0, "vertical");
|
|
156
|
-
// At frame 0, nothing should be revealed yet
|
|
157
|
-
for (const row of result) {
|
|
158
|
-
const stripped = stripAnsi(row);
|
|
159
|
-
expect(stripped).toMatch(/^[\s]*$/);
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("late frames show all characters revealed", () => {
|
|
164
|
-
const result = mod.getShinedLogo(200, "vertical");
|
|
165
|
-
for (let i = 0; i < result.length; i++) {
|
|
166
|
-
const stripped = stripAnsi(result[i]!);
|
|
167
|
-
const expected = stripAnsi(mod.LOGO[i]!);
|
|
168
|
-
expect(stripped).toBe(expected);
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("output contains ANSI gray escapes", () => {
|
|
173
|
-
const result = mod.getShinedLogo(50, "wave");
|
|
174
|
-
const joined = result.join("\n");
|
|
175
|
-
expect(joined).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("default style is wave", () => {
|
|
179
|
-
const result1 = mod.getShinedLogo(50);
|
|
180
|
-
const result2 = mod.getShinedLogo(50, "wave");
|
|
181
|
-
expect(result1).toEqual(result2);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
// ── All animation styles ─────────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
describe("animation styles", () => {
|
|
188
|
-
const styles = [
|
|
189
|
-
"diagonal",
|
|
190
|
-
"top-right",
|
|
191
|
-
"bottom-left",
|
|
192
|
-
"bottom-right",
|
|
193
|
-
"center-out",
|
|
194
|
-
"wave",
|
|
195
|
-
"horizontal",
|
|
196
|
-
"vertical",
|
|
197
|
-
"vertical-up",
|
|
198
|
-
] as const;
|
|
199
|
-
|
|
200
|
-
it.each(styles)("style '%s' produces valid reveal times", async (style) => {
|
|
201
|
-
const mod = await importLogoWithTruecolor(true);
|
|
202
|
-
const result = mod.getShinedLogo(100, style);
|
|
203
|
-
expect(result.length).toBe(8);
|
|
204
|
-
// Each row should be a string
|
|
205
|
-
for (const row of result) {
|
|
206
|
-
expect(typeof row).toBe("string");
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it.each(styles)("style '%s' fully reveals at high frame count", async (style) => {
|
|
211
|
-
const mod = await importLogoWithTruecolor(true);
|
|
212
|
-
const result = mod.getShinedLogo(500, style);
|
|
213
|
-
for (let i = 0; i < result.length; i++) {
|
|
214
|
-
const stripped = stripAnsi(result[i]!);
|
|
215
|
-
const expected = stripAnsi(mod.LOGO[i]!);
|
|
216
|
-
expect(stripped).toBe(expected);
|
|
217
|
-
}
|
|
218
|
-
});
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// ── Properties ───────────────────────────────────────────────────────────────
|
|
222
|
-
|
|
223
|
-
describe("properties", () => {
|
|
224
|
-
it("getShinedLogo always returns 8 rows (truecolor)", async () => {
|
|
225
|
-
const mod = await importLogoWithTruecolor(true);
|
|
226
|
-
fc.assert(
|
|
227
|
-
fc.property(fc.nat(1000), n => {
|
|
228
|
-
return mod.getShinedLogo(n, "wave").length === 8;
|
|
229
|
-
}),
|
|
230
|
-
);
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
it("each row visible width equals 16 after stripping ANSI (truecolor)", async () => {
|
|
234
|
-
const mod = await importLogoWithTruecolor(true);
|
|
235
|
-
fc.assert(
|
|
236
|
-
fc.property(fc.nat(500), n => {
|
|
237
|
-
const result = mod.getShinedLogo(n, "diagonal");
|
|
238
|
-
for (const row of result) {
|
|
239
|
-
const stripped = stripAnsi(row);
|
|
240
|
-
// stripAnsi trims, so we check the raw row length instead
|
|
241
|
-
// The actual row length (including ANSI) may vary, but the
|
|
242
|
-
// visible content should always be 16 chars
|
|
243
|
-
// Since stripAnsi trims trailing spaces, check untrimmed
|
|
244
|
-
const raw = row.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
|
|
245
|
-
if (raw.length !== 16) return false;
|
|
246
|
-
}
|
|
247
|
-
return true;
|
|
248
|
-
}),
|
|
249
|
-
);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it("getShinedLogo returns 8 rows (non-truecolor)", async () => {
|
|
253
|
-
const mod = await importLogoWithTruecolor(false);
|
|
254
|
-
fc.assert(
|
|
255
|
-
fc.property(fc.nat(1000), n => {
|
|
256
|
-
return mod.getShinedLogo(n, "wave").length === 8;
|
|
257
|
-
}),
|
|
258
|
-
);
|
|
259
|
-
});
|
|
260
|
-
});
|
package/src/startup/logo.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { gray, rgb, extractRgb, lerp } from "../color.js";
|
|
2
|
-
import type { AnimationStyle } from "../config.js";
|
|
3
|
-
|
|
4
|
-
// ── Truecolor detection ────────────────────────────────────────
|
|
5
|
-
|
|
6
|
-
export const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM ?? "")
|
|
7
|
-
|| (process.env.TERM ?? "").includes("256color")
|
|
8
|
-
|| process.env.TERM_PROGRAM === "iTerm.app"
|
|
9
|
-
|| process.env.TERM_PROGRAM === "WezTerm"
|
|
10
|
-
|| process.env.TERM_PROGRAM === "vscode"
|
|
11
|
-
|| process.env.WT_SESSION !== undefined;
|
|
12
|
-
|
|
13
|
-
// ── Logo ───────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
export const LOGO = [
|
|
16
|
-
"████████████ ",
|
|
17
|
-
"████████████ ",
|
|
18
|
-
"████ ████ ",
|
|
19
|
-
"████ ████ ",
|
|
20
|
-
"████████ ████",
|
|
21
|
-
"████████ ████",
|
|
22
|
-
"████ ████",
|
|
23
|
-
"████ ████",
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
export const CHAR_FADE_FRAMES = 22;
|
|
27
|
-
export const LOGO_SETTLE_FRAME = 90;
|
|
28
|
-
export const LOGO_PAD = 0;
|
|
29
|
-
export const LOGO_GAP = 4;
|
|
30
|
-
|
|
31
|
-
const LOGO_COLS = 14;
|
|
32
|
-
const LOGO_ROWS = 8;
|
|
33
|
-
const CENTER_X = (LOGO_COLS - 1) / 2;
|
|
34
|
-
const CENTER_Y = (LOGO_ROWS - 1) / 2;
|
|
35
|
-
|
|
36
|
-
function computeRevealAt(x: number, y: number, style: AnimationStyle): number {
|
|
37
|
-
switch (style) {
|
|
38
|
-
case "diagonal":
|
|
39
|
-
return ((x / 2) * 1.2 + (y / 2) * 3.5) * 1.4;
|
|
40
|
-
case "top-right":
|
|
41
|
-
return (((LOGO_COLS - 1 - x) / 2) * 1.2 + (y / 2) * 3.5) * 1.4;
|
|
42
|
-
case "bottom-left":
|
|
43
|
-
return ((x / 2) * 1.2 + ((LOGO_ROWS - 1 - y) / 2) * 3.5) * 1.4;
|
|
44
|
-
case "bottom-right":
|
|
45
|
-
return (((LOGO_COLS - 1 - x) / 2) * 1.2 + ((LOGO_ROWS - 1 - y) / 2) * 3.5) * 1.4;
|
|
46
|
-
case "center-out": {
|
|
47
|
-
const dist = Math.sqrt((x - CENTER_X) ** 2 + (y - CENTER_Y) ** 2);
|
|
48
|
-
return dist * 4.5;
|
|
49
|
-
}
|
|
50
|
-
case "wave": {
|
|
51
|
-
const base = ((x / 2) * 1.2 + (y / 2) * 3.5) * 1.4;
|
|
52
|
-
const wave = Math.sin((x * 0.8 + y * 0.5) * 1.2) * 8;
|
|
53
|
-
return base + wave;
|
|
54
|
-
}
|
|
55
|
-
case "horizontal":
|
|
56
|
-
return x * 3.5;
|
|
57
|
-
case "vertical":
|
|
58
|
-
return y * 5.5;
|
|
59
|
-
case "vertical-up":
|
|
60
|
-
return (LOGO_ROWS - 1 - y) * 5.5;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function getShinedLogo(frame: number, style: AnimationStyle = "wave"): string[] {
|
|
65
|
-
if (!TRUECOLOR) return LOGO;
|
|
66
|
-
|
|
67
|
-
return LOGO.map((line, y) => {
|
|
68
|
-
let result = "";
|
|
69
|
-
for (let x = 0; x < line.length; x++) {
|
|
70
|
-
const char = line[x]!;
|
|
71
|
-
if (char === " ") { result += " "; continue; }
|
|
72
|
-
|
|
73
|
-
const revealAt = computeRevealAt(x, y, style);
|
|
74
|
-
const age = frame - revealAt;
|
|
75
|
-
|
|
76
|
-
if (age <= 0) { result += " "; continue; }
|
|
77
|
-
|
|
78
|
-
const t = Math.min(1, age / CHAR_FADE_FRAMES);
|
|
79
|
-
const eased = 1 - (1 - t) * (1 - t);
|
|
80
|
-
const brightness = Math.floor(lerp(50, 255, eased));
|
|
81
|
-
result += gray(brightness, char);
|
|
82
|
-
}
|
|
83
|
-
return result;
|
|
84
|
-
});
|
|
85
|
-
}
|