@pi-archimedes/core 2.0.1 → 2.2.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 +51 -0
- package/package.json +2 -1
- package/src/config.test.ts +86 -0
- package/src/overlay.test.ts +181 -0
- package/src/overlay.ts +115 -0
- package/src/settings-io.test.ts +103 -0
- package/src/startup/logo.test.ts +260 -0
- package/src/startup/sections.test.ts +314 -0
- package/src/startup/version.test.ts +98 -0
- package/src/thinking/patch.test.ts +227 -0
- package/src/thinking/theme.test.ts +216 -0
- package/src/thinking/transform.test.ts +89 -0
- package/src/thinking/unindent.test.ts +74 -0
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
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"./chrome": "./src/chrome.ts",
|
|
17
17
|
"./text": "./src/text.ts",
|
|
18
18
|
"./color": "./src/color.ts",
|
|
19
|
+
"./overlay": "./src/overlay.ts",
|
|
19
20
|
"./config": "./src/config.ts",
|
|
20
21
|
"./settings-io": "./src/settings-io.ts",
|
|
21
22
|
"./profiler": "./src/profiler.ts"
|
|
@@ -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,181 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
visibleWidth,
|
|
4
|
+
padEnd,
|
|
5
|
+
wrapText,
|
|
6
|
+
hardTruncate,
|
|
7
|
+
renderHeader,
|
|
8
|
+
renderFooter,
|
|
9
|
+
wrapWithBorder,
|
|
10
|
+
borderContentWidth,
|
|
11
|
+
OVERLAY_CHROME,
|
|
12
|
+
type OverlayTheme,
|
|
13
|
+
} from "./overlay.js";
|
|
14
|
+
|
|
15
|
+
// Mock theme: fg returns its text argument untouched.
|
|
16
|
+
const mockTheme: OverlayTheme = {
|
|
17
|
+
fg: (_color: string, text: string) => text,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Mock theme that records the color token passed to fg.
|
|
21
|
+
function recordingTheme() {
|
|
22
|
+
let lastColor: string | null = null;
|
|
23
|
+
const theme: OverlayTheme = {
|
|
24
|
+
fg: (color: string, text: string) => {
|
|
25
|
+
lastColor = color;
|
|
26
|
+
return text;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
return { theme, lastColor: () => lastColor };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── visibleWidth ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe("visibleWidth", () => {
|
|
35
|
+
it("counts a plain string", () => {
|
|
36
|
+
expect(visibleWidth("hello")).toBe(5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("ignores ANSI SGR codes", () => {
|
|
40
|
+
expect(visibleWidth("\x1b[31mred\x1b[0m")).toBe(3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns 0 for empty string", () => {
|
|
44
|
+
expect(visibleWidth("")).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── padEnd ───────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("padEnd", () => {
|
|
51
|
+
it("pads to width", () => {
|
|
52
|
+
expect(padEnd("hi", 5)).toBe("hi ");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns input unchanged when visible width >= target", () => {
|
|
56
|
+
expect(padEnd("hello", 5)).toBe("hello");
|
|
57
|
+
expect(padEnd("toolong", 5)).toBe("toolong");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns '' for width <= 0", () => {
|
|
61
|
+
expect(padEnd("text", 0)).toBe("");
|
|
62
|
+
expect(padEnd("text", -3)).toBe("");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── wrapText ─────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
describe("wrapText", () => {
|
|
69
|
+
it("wraps a long line to width", () => {
|
|
70
|
+
expect(wrapText("aaa bbb ccc ddd", 8)).toEqual(["aaa bbb ", "ccc ddd"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("wraps long words to width", () => {
|
|
74
|
+
expect(wrapText("hello world", 5)).toEqual(["hello", " ", "world"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("preserves empty paragraphs as blank lines", () => {
|
|
78
|
+
expect(wrapText("one\n\ntwo", 10)).toEqual(["one", "", "two"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns [] for width <= 0", () => {
|
|
82
|
+
expect(wrapText("anything", 0)).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── hardTruncate ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
describe("hardTruncate", () => {
|
|
89
|
+
it("leaves short strings alone", () => {
|
|
90
|
+
expect(hardTruncate("short", 10)).toBe("short");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("truncates at the visible-width boundary", () => {
|
|
94
|
+
expect(hardTruncate("hello world", 5)).toBe("hello");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("truncates an ANSI-colored string and appends a reset", () => {
|
|
98
|
+
const result = hardTruncate("\x1b[31mhello world\x1b[0m", 5);
|
|
99
|
+
expect(result).toBe("\x1b[31mhello\x1b[0m");
|
|
100
|
+
expect(result).toContain("\x1b[0m");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── renderHeader / renderFooter ──────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe("renderHeader", () => {
|
|
107
|
+
it("pads to width and renders with the accent token", () => {
|
|
108
|
+
const { theme, lastColor } = recordingTheme();
|
|
109
|
+
const out = renderHeader(" Title ", 10, theme);
|
|
110
|
+
expect(out).toBe(" Title ");
|
|
111
|
+
expect(lastColor()).toBe("accent");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("renderFooter", () => {
|
|
116
|
+
it("pads to width and renders with the dim token", () => {
|
|
117
|
+
const { theme, lastColor } = recordingTheme();
|
|
118
|
+
const out = renderFooter(" hint ", 10, theme);
|
|
119
|
+
expect(out).toBe(" hint ");
|
|
120
|
+
expect(lastColor()).toBe("dim");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── wrapWithBorder / borderContentWidth ──────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe("borderContentWidth", () => {
|
|
127
|
+
it("derives content width from outer width", () => {
|
|
128
|
+
expect(borderContentWidth(84)).toBe(80);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("floors at 1 for tiny widths", () => {
|
|
132
|
+
expect(borderContentWidth(0)).toBe(1);
|
|
133
|
+
expect(borderContentWidth(2)).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("wrapWithBorder", () => {
|
|
138
|
+
const W = 84;
|
|
139
|
+
|
|
140
|
+
it("emits border rows with the correct shape", () => {
|
|
141
|
+
const out = wrapWithBorder(["a", "b"], W, mockTheme);
|
|
142
|
+
expect(out[0]).toBe("┌" + "─".repeat(W - 2) + "┐");
|
|
143
|
+
expect(out[out.length - 1]).toBe("└" + "─".repeat(W - 2) + "┘");
|
|
144
|
+
expect(out[1]).toBe("│ a" + " ".repeat(W - 4) + "│");
|
|
145
|
+
expect(out[2]).toBe("│ b" + " ".repeat(W - 4) + "│");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("emits exactly lines.length + 2 rows", () => {
|
|
149
|
+
const out = wrapWithBorder(["a", "b", "c"], W, mockTheme);
|
|
150
|
+
expect(out.length).toBe(5);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("pads every row to exactly the outer width", () => {
|
|
154
|
+
const out = wrapWithBorder(["x", "shorter", "y"], W, mockTheme);
|
|
155
|
+
for (const line of out) {
|
|
156
|
+
expect(visibleWidth(line)).toBe(W);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("hard-truncates content wider than the content width, without overflowing", () => {
|
|
161
|
+
const long = "z".repeat(W + 10); // wider than the 80-col content area
|
|
162
|
+
const out = wrapWithBorder([long], W, mockTheme);
|
|
163
|
+
expect(out.length).toBe(3);
|
|
164
|
+
for (const line of out) {
|
|
165
|
+
expect(visibleWidth(line)).toBe(W);
|
|
166
|
+
}
|
|
167
|
+
expect(out[1]).toBe("│ " + "z".repeat(W - 4) + " │");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ── OVERLAY_CHROME ───────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
describe("OVERLAY_CHROME", () => {
|
|
174
|
+
it("matches the /agents overlay options", () => {
|
|
175
|
+
expect(OVERLAY_CHROME).toEqual({
|
|
176
|
+
anchor: "center",
|
|
177
|
+
width: 84,
|
|
178
|
+
maxHeight: "80%",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared overlay chrome — border wrapping, text width math, header/footer
|
|
3
|
+
* rendering. Used by the /agents manager (subagent) and the /archimedes
|
|
4
|
+
* settings overlay (meta) so both screens look identical.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Minimal structural theme — anything with `fg` satisfies it (the real pi-coding-agent Theme, agent-manager's local interface, or a test mock). */
|
|
8
|
+
export interface OverlayTheme {
|
|
9
|
+
fg(token: string, text: string): string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function wrapText(text: string, width: number): string[] {
|
|
13
|
+
if (width <= 0) return [];
|
|
14
|
+
const lines: string[] = [];
|
|
15
|
+
const paragraphs = text.split("\n");
|
|
16
|
+
for (const para of paragraphs) {
|
|
17
|
+
if (para.length === 0) {
|
|
18
|
+
lines.push("");
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const words = para.split(/(\s+)/).filter(Boolean);
|
|
22
|
+
let current = "";
|
|
23
|
+
for (const word of words) {
|
|
24
|
+
const test = current === "" ? word : current + word;
|
|
25
|
+
if (test.length > width && current.length > 0) {
|
|
26
|
+
lines.push(current);
|
|
27
|
+
current = word;
|
|
28
|
+
} else {
|
|
29
|
+
current = test;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (current) lines.push(current);
|
|
33
|
+
}
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function padEnd(text: string, width: number): string {
|
|
38
|
+
if (width <= 0) return "";
|
|
39
|
+
const vw = visibleWidth(text);
|
|
40
|
+
if (vw >= width) return text;
|
|
41
|
+
return text + " ".repeat(width - vw);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function visibleWidth(text: string): number {
|
|
45
|
+
// Strip ANSI escape sequences for width calculation
|
|
46
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderHeader(text: string, width: number, theme: OverlayTheme): string {
|
|
50
|
+
return theme.fg("accent", padEnd(text, width));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderFooter(text: string, width: number, theme: OverlayTheme): string {
|
|
54
|
+
return theme.fg("dim", padEnd(text, width));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Content width available inside wrapWithBorder at a given outer width.
|
|
58
|
+
* inner = max(1, width - 2); content = max(1, inner - 2). */
|
|
59
|
+
export function borderContentWidth(width: number): number {
|
|
60
|
+
const innerWidth = Math.max(1, width - 2);
|
|
61
|
+
return Math.max(1, innerWidth - 2);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Shared overlay options so /agents and /archimedes never drift. */
|
|
65
|
+
export const OVERLAY_CHROME = { anchor: "center", width: 84, maxHeight: "80%" } as const;
|
|
66
|
+
|
|
67
|
+
// ── Border wrapper ────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/** Hard-truncate by visible width — no "..." suffix. Strips ANSI, truncates, rebuilds. */
|
|
70
|
+
export function hardTruncate(text: string, maxVisible: number): string {
|
|
71
|
+
if (visibleWidth(text) <= maxVisible) return text;
|
|
72
|
+
// Walk the string, skipping ANSI escape sequences, and stop after
|
|
73
|
+
// maxVisible visible characters; copy SGR codes through so styling survives,
|
|
74
|
+
// and append a reset at the end so styling doesn't bleed.
|
|
75
|
+
let result = "";
|
|
76
|
+
let plainPos = 0;
|
|
77
|
+
let i = 0;
|
|
78
|
+
let copiedSgr = false;
|
|
79
|
+
while (i < text.length && plainPos < maxVisible) {
|
|
80
|
+
if (text[i] === "\x1b" && text[i + 1] === "[") {
|
|
81
|
+
// Copy the escape sequence
|
|
82
|
+
let j = i;
|
|
83
|
+
while (j < text.length && text[j] !== "m") j++;
|
|
84
|
+
result += text.slice(i, j + 1);
|
|
85
|
+
copiedSgr = true;
|
|
86
|
+
i = j + 1;
|
|
87
|
+
} else {
|
|
88
|
+
result += text[i];
|
|
89
|
+
plainPos++;
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Ensure styling doesn't bleed: append reset if we copied SGR and result doesn't end with one
|
|
94
|
+
if (copiedSgr && !/\x1b\[0?m$/.test(result)) {
|
|
95
|
+
result += "\x1b[0m";
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function wrapWithBorder(lines: string[], width: number, theme: OverlayTheme): string[] {
|
|
101
|
+
const innerWidth = Math.max(1, width - 2);
|
|
102
|
+
const contentWidth = Math.max(1, innerWidth - 2); // minus 1 space padding each side
|
|
103
|
+
const left = theme.fg("dim", "│");
|
|
104
|
+
const right = theme.fg("dim", "│");
|
|
105
|
+
const top = theme.fg("dim", `┌${"─".repeat(innerWidth)}┐`);
|
|
106
|
+
const bottom = theme.fg("dim", `└${"─".repeat(innerWidth)}┘`);
|
|
107
|
+
const result: string[] = [top];
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const clamped = hardTruncate(line, contentWidth);
|
|
110
|
+
const padded = " " + padEnd(clamped, contentWidth) + " ";
|
|
111
|
+
result.push(left + padded + right);
|
|
112
|
+
}
|
|
113
|
+
result.push(bottom);
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
@@ -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
|
+
});
|