@pi-archimedes/core 2.0.1 → 2.1.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 ADDED
@@ -0,0 +1,51 @@
1
+ # @pi-archimedes/core
2
+
3
+ The visual foundation and shared infrastructure for pi-archimedes.
4
+
5
+ Core is what you see first — the animated splash screen, the framed editor, and styled thinking blocks. It is also the invisible glue: an event bus that lets packages talk to each other, shared text and color utilities, and a settings system. Install it standalone for polished chrome, or let the meta package include it automatically.
6
+
7
+ ## What you get
8
+
9
+ - **Animated splash screen** — configurable reveal animations (9 styles) that set the tone when Pi starts
10
+ - **Framed editor** — custom editor component with double-press quit guard
11
+ - **Styled thinking blocks** — configurable label text, color, and muted theme option; optional code block unindenting
12
+ - **Event bus** — shared pub/sub channel that lets packages communicate (subagent costs → footer, subagent questions → ask, etc.)
13
+ - **Shared utilities** — text truncation/width calculation, color helpers, config loading, settings I/O, and startup profiling
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pi install npm:@pi-archimedes/core
19
+ ```
20
+
21
+ Or install full meta package:
22
+
23
+ ```bash
24
+ pi install npm:pi-archimedes
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ Core works automatically when Pi starts. It sets the session header, editor frame, and thinking block renderer automatically. There are no manual commands or tools to call.
30
+
31
+ For configuration:
32
+ - When using the meta package, run `/archimedes` to access the settings panel.
33
+ - For standalone installs, edit `archimedes.core` in `~/.pi/agent/settings.json`.
34
+
35
+ ## Settings
36
+
37
+ Core reads configuration from the `archimedes.core` namespace in `~/.pi/agent/settings.json`.
38
+
39
+ | Setting | Type | Default | Description |
40
+ |---------|------|---------|-------------|
41
+ | `mutedTheme` | bool | `false` | Use subdued colors for thinking blocks |
42
+ | `codeUnindent` | bool | `true` | Remove common indentation from code blocks inside thinking sections |
43
+ | `labelText` | string | `Thinking...` | Custom prefix shown before thinking blocks |
44
+ | `labelColor` | string | `255,215,0` | RGB color for the thinking label |
45
+ | `animationStyle` | string | `vertical-up` | Splash animation style (9 options) |
46
+
47
+ ## Integration
48
+
49
+ Core is auto-included by the `pi-archimedes` meta package. All other archimedes packages depend on `@pi-archimedes/core` for the event bus, chrome elements, and utilities. Standalone installation provides the visual chrome and bus without the other archimedes features.
50
+
51
+ ← Back to [pi-archimedes](../../README.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("./settings-io.js", () => ({
4
+ loadConfig: vi.fn(),
5
+ saveConfig: vi.fn(),
6
+ }));
7
+
8
+ const { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES } =
9
+ await import("./config.js");
10
+ const { loadConfig, saveConfig } = await import("./settings-io.js");
11
+
12
+ describe("loadCoreConfig", () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ it("uses correct namespace", () => {
18
+ vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
19
+ loadCoreConfig();
20
+ expect(loadConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
21
+ });
22
+
23
+ it("returns default config when no settings exist", () => {
24
+ vi.mocked(loadConfig).mockReturnValue(DEFAULT_CORE_CONFIG);
25
+ const result = loadCoreConfig();
26
+ expect(result).toEqual({
27
+ mutedTheme: false,
28
+ codeUnindent: true,
29
+ labelText: "Thinking...",
30
+ labelColor: "255,215,0",
31
+ animationStyle: "vertical-up",
32
+ });
33
+ });
34
+
35
+ it("passes through merged config from settings-io", () => {
36
+ const merged = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
37
+ vi.mocked(loadConfig).mockReturnValue(merged);
38
+ const result = loadCoreConfig();
39
+ expect(result).toEqual(merged);
40
+ });
41
+ });
42
+
43
+ describe("saveCoreConfig", () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ });
47
+
48
+ it("saves with correct namespace", () => {
49
+ saveCoreConfig(DEFAULT_CORE_CONFIG);
50
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.core", DEFAULT_CORE_CONFIG);
51
+ });
52
+
53
+ it("passes config through unchanged", () => {
54
+ const config = { ...DEFAULT_CORE_CONFIG, mutedTheme: true };
55
+ saveCoreConfig(config);
56
+ expect(saveConfig).toHaveBeenCalledWith("archimedes.core", config);
57
+ });
58
+ });
59
+
60
+ describe("DEFAULT_CORE_CONFIG", () => {
61
+ it("has the expected shape", () => {
62
+ expect(DEFAULT_CORE_CONFIG).toEqual({
63
+ mutedTheme: false,
64
+ codeUnindent: true,
65
+ labelText: "Thinking...",
66
+ labelColor: "255,215,0",
67
+ animationStyle: "vertical-up",
68
+ });
69
+ });
70
+ });
71
+
72
+ describe("ANIMATION_STYLES", () => {
73
+ it("contains all expected styles", () => {
74
+ expect(ANIMATION_STYLES).toEqual([
75
+ "diagonal",
76
+ "top-right",
77
+ "bottom-left",
78
+ "bottom-right",
79
+ "center-out",
80
+ "wave",
81
+ "horizontal",
82
+ "vertical",
83
+ "vertical-up",
84
+ ]);
85
+ });
86
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // ── hoisted: must use require() since vi.hoisted runs before imports ────────
4
+
5
+ const { tempDir, fs, join, tmpdir, randomUUID } = vi.hoisted(() => {
6
+ const fs = require("node:fs");
7
+ const { join } = require("node:path");
8
+ const { tmpdir } = require("node:os");
9
+ const { randomUUID } = require("node:crypto");
10
+ const dir = join(tmpdir(), `settings-io-test-${randomUUID()}`);
11
+ fs.mkdirSync(dir, { recursive: true });
12
+ return { tempDir: dir, fs, join, tmpdir, randomUUID };
13
+ });
14
+
15
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
16
+ getAgentDir: () => tempDir,
17
+ }));
18
+
19
+ // Import after mocks are set up
20
+ const { loadConfig, saveConfig } = await import("./settings-io.js");
21
+
22
+ describe("loadConfig", () => {
23
+ beforeEach(() => {
24
+ // Clean up settings file before each test
25
+ const settingsPath = join(tempDir, "settings.json");
26
+ if (fs.existsSync(settingsPath)) {
27
+ fs.unlinkSync(settingsPath);
28
+ }
29
+ });
30
+
31
+ afterEach(() => {
32
+ // Clean up temp dir artifacts
33
+ const settingsPath = join(tempDir, "settings.json");
34
+ const tmpPath = settingsPath + ".tmp";
35
+ try { fs.unlinkSync(settingsPath); } catch { /* ignore */ }
36
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
37
+ });
38
+
39
+ it("returns defaults when settings missing", () => {
40
+ const result = loadConfig("test.ns", { foo: "bar", count: 42 });
41
+ expect(result).toEqual({ foo: "bar", count: 42 });
42
+ });
43
+
44
+ it("merges settings over defaults", () => {
45
+ const settingsPath = join(tempDir, "settings.json");
46
+ fs.writeFileSync(
47
+ settingsPath,
48
+ JSON.stringify({ "test.ns": { foo: "overridden" } }),
49
+ "utf-8",
50
+ );
51
+ const result = loadConfig("test.ns", { foo: "bar", count: 42 });
52
+ expect(result).toEqual({ foo: "overridden", count: 42 });
53
+ });
54
+
55
+ it("returns defaults on corrupt JSON", () => {
56
+ const settingsPath = join(tempDir, "settings.json");
57
+ fs.writeFileSync(settingsPath, "{ invalid json }", "utf-8");
58
+ const result = loadConfig("test.ns", { foo: "bar", count: 42 });
59
+ expect(result).toEqual({ foo: "bar", count: 42 });
60
+ });
61
+ });
62
+
63
+ describe("saveConfig", () => {
64
+ beforeEach(() => {
65
+ const settingsPath = join(tempDir, "settings.json");
66
+ if (fs.existsSync(settingsPath)) {
67
+ fs.unlinkSync(settingsPath);
68
+ }
69
+ });
70
+
71
+ afterEach(() => {
72
+ const settingsPath = join(tempDir, "settings.json");
73
+ const tmpPath = settingsPath + ".tmp";
74
+ try { fs.unlinkSync(settingsPath); } catch { /* ignore */ }
75
+ try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
76
+ });
77
+
78
+ it("writes atomically (tmp + rename)", () => {
79
+ saveConfig("test.ns", { foo: "bar" });
80
+ const settingsPath = join(tempDir, "settings.json");
81
+ expect(fs.existsSync(settingsPath)).toBe(true);
82
+ const tmpPath = settingsPath + ".tmp";
83
+ expect(fs.existsSync(tmpPath)).toBe(false);
84
+ const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
85
+ expect(data["test.ns"]).toEqual({ foo: "bar" });
86
+ });
87
+
88
+ it("persists data for subsequent loads", () => {
89
+ saveConfig("test.ns", { foo: "bar", count: 42 });
90
+ const result = loadConfig("test.ns", { foo: "default", count: 0 });
91
+ expect(result).toEqual({ foo: "bar", count: 42 });
92
+ });
93
+
94
+ it("writes data correctly on success path", () => {
95
+ saveConfig("test.ns", { foo: "bar" });
96
+ const settingsPath = join(tempDir, "settings.json");
97
+ expect(fs.existsSync(settingsPath)).toBe(true);
98
+ const data = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
99
+ expect(data["test.ns"]).toEqual({ foo: "bar" });
100
+ // No .tmp file should remain after successful rename
101
+ expect(fs.existsSync(settingsPath + ".tmp")).toBe(false);
102
+ });
103
+ });
@@ -0,0 +1,260 @@
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
+ });