@pi-archimedes/core 2.1.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/core",
3
- "version": "2.1.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,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
+ }