@pi-archimedes/core 2.10.4 → 2.11.1

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.
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
3
+ import { join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // ── The rule ────────────────────────────────────────────────────────────────
7
+ //
8
+ // Nothing in the suite writes to the console. Two independent reasons:
9
+ //
10
+ // 1. On fullscreen screen a stray `console.*` lands inside the differential
11
+ // frame and smears the viewport until the next full redraw (on regular
12
+ // screen it scrolls away unseen, which is why the habit survived so long).
13
+ // 2. Inside a subagent child every write is silently discarded:
14
+ // `subagent/src/stream.ts` drops any non-JSON stdout line (`catch { return }`,
15
+ // a guard for pi's own startup messages), so a warning vanishes exactly when
16
+ // someone is debugging a child run.
17
+ //
18
+ // Diagnostics go to `diag()` in `log.ts` (a file, only when PI_ARCHIMEDES_LOG
19
+ // names one) and messages the user must see go to the notification surface
20
+ // (`ctx.ui.notify`). Both are safe on every screen and in a child process.
21
+ //
22
+ // The pattern demands the open parenthesis, so it is narrower than a loose
23
+ // `grep console.log`: a monkey-patch site (`console.log = (…) => {…}`,
24
+ // `let orig: typeof console.log`) is not a write and is not reported here.
25
+ //
26
+ // Every *method* is covered, not just log/warn/error. `console.info` and
27
+ // `console.debug` land on stdout/stderr exactly like `console.log`, and
28
+ // `console.trace`/`dir`/`table`/`group`/`count`/`clear` write too (and `clear`
29
+ // wipes the frame outright), so naming a subset would leave a guard that stays
30
+ // green while a future write corrupts the display.
31
+ const PATTERN = /console\.[a-zA-Z]+\(/;
32
+
33
+ interface AllowEntry {
34
+ readonly file: string;
35
+ readonly reason: string;
36
+ }
37
+
38
+ /**
39
+ * Files exempt from the rule, whole-file, each with the reason it is tolerated
40
+ * and when that goes away. A new violation in an exempt file still shows up in
41
+ * the census, so this list can only ever be argued down, not silently widened.
42
+ */
43
+ const ALLOW_LIST: readonly AllowEntry[] = [
44
+ {
45
+ file: "packages/core/src/log.ts",
46
+ reason:
47
+ "The diagnostics sink itself. Exempt by construction: it is the one module allowed to own output plumbing, and a last-resort fallback there must not trip the guard. It writes files, never the console.",
48
+ },
49
+ ];
50
+
51
+ // ── Walking the tree ────────────────────────────────────────────────────────
52
+
53
+ /** Repo root, resolved from this file (`packages/core/src` → repo root). */
54
+ const REPO_ROOT = fileURLToPath(new URL("../../../", import.meta.url));
55
+
56
+ /** Every `packages/<name>/src` directory, plus `meta/src`. */
57
+ function sourceDirs(): string[] {
58
+ const dirs: string[] = [];
59
+ for (const entry of readdirSync(join(REPO_ROOT, "packages"), { withFileTypes: true })) {
60
+ if (!entry.isDirectory()) continue;
61
+ const src = join(REPO_ROOT, "packages", entry.name, "src");
62
+ if (existsSync(src)) dirs.push(src);
63
+ }
64
+ const metaSrc = join(REPO_ROOT, "meta", "src");
65
+ if (existsSync(metaSrc)) dirs.push(metaSrc);
66
+ return dirs;
67
+ }
68
+
69
+ /** Source files under those trees; tests are excluded (they spy on the console). */
70
+ function collectSourceFiles(): string[] {
71
+ const out: string[] = [];
72
+ const walk = (dir: string): void => {
73
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
74
+ const full = join(dir, entry.name);
75
+ if (entry.isDirectory()) {
76
+ if (entry.name !== "node_modules") walk(full);
77
+ } else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts")) {
78
+ out.push(relative(REPO_ROOT, full).split(/[\\/]/).join("/"));
79
+ }
80
+ }
81
+ };
82
+ for (const dir of sourceDirs()) walk(dir);
83
+ return out.sort();
84
+ }
85
+
86
+ interface Hit {
87
+ file: string;
88
+ line: number;
89
+ text: string;
90
+ }
91
+
92
+ function findViolations(files: readonly string[]): Hit[] {
93
+ const hits: Hit[] = [];
94
+ for (const file of files) {
95
+ const lines = readFileSync(join(REPO_ROOT, file), "utf8").split("\n");
96
+ lines.forEach((text, i) => {
97
+ if (PATTERN.test(text)) hits.push({ file, line: i + 1, text: text.trim() });
98
+ });
99
+ }
100
+ return hits;
101
+ }
102
+
103
+ const FILES = collectSourceFiles();
104
+ const HITS = findViolations(FILES);
105
+ const ALLOWED = new Set(ALLOW_LIST.map((e) => e.file));
106
+ const VIOLATIONS = HITS.filter((h) => !ALLOWED.has(h.file));
107
+
108
+ // ── Tests ───────────────────────────────────────────────────────────────────
109
+
110
+ describe("console discipline: no console writes from render or event paths", () => {
111
+ it("walks the whole suite, not an empty file set", () => {
112
+ expect(existsSync(join(REPO_ROOT, "pnpm-workspace.yaml"))).toBe(true);
113
+ expect(FILES.length).toBeGreaterThan(50);
114
+ // The trees the guard covers, and the shape of what it skips.
115
+ for (const expected of [
116
+ "packages/core/src/log.ts",
117
+ "packages/ui/src/startup/index.ts",
118
+ "meta/src/index.ts",
119
+ ]) {
120
+ expect(FILES).toContain(expected);
121
+ }
122
+ expect(FILES.filter((f) => f.endsWith(".test.ts"))).toEqual([]);
123
+ });
124
+
125
+ it("reports no console writes outside the allow-list", () => {
126
+ expect(VIOLATIONS.map((v) => `${v.file}:${v.line} ${v.text}`)).toEqual([]);
127
+ });
128
+
129
+ it("matches any console write and not a patch site (the guard is not a loose grep)", () => {
130
+ const offenders = [
131
+ 'console.log("x");',
132
+ ' console.warn(`y`);',
133
+ "console.error(err);",
134
+ "if (bad) console.error('nope');",
135
+ // The rest of the console writes to the terminal just as hard. `info` and
136
+ // `debug` used to be *pinned* as allowed here, which encoded the hole in
137
+ // the guard rather than the rule; the suite has none today, so widening
138
+ // costs nothing and closes it.
139
+ 'console.info("x");',
140
+ 'console.debug("x");',
141
+ "console.trace('here');",
142
+ 'console.dir(obj);',
143
+ 'console.table(rows);',
144
+ 'console.group("g");',
145
+ "console.count();",
146
+ "console.clear();",
147
+ ];
148
+ for (const line of offenders) {
149
+ expect(PATTERN.test(line), `expected the guard to reject: ${line}`).toBe(true);
150
+ }
151
+
152
+ // Assignments, type references and prose about the console are not writes.
153
+ // This is exactly the difference between the guard and the census grep, and
154
+ // it is why the startup console patch this file used to exempt needed one
155
+ // entry for its single real write while its patch plumbing did not count.
156
+ const fine = [
157
+ "console.log = (...args) => {};",
158
+ "let _origLog: typeof console.log | undefined;",
159
+ "_origLog = console.log;",
160
+ "console.log = _origLog;",
161
+ "// Patch console.log for model scope capture",
162
+ "const orig = Object.getOwnPropertyDescriptor(console, 'log');",
163
+ ];
164
+ for (const line of fine) {
165
+ expect(PATTERN.test(line), `expected no rule to reject: ${line}`).toBe(false);
166
+ }
167
+ });
168
+
169
+ it("keeps the allow-list as small as the work that removes it", () => {
170
+ const sink = "packages/core/src/log.ts";
171
+ for (const entry of ALLOW_LIST) {
172
+ expect(FILES, `allow-listed file is no longer scanned: ${entry.file}`).toContain(entry.file);
173
+ expect(entry.reason.length).toBeGreaterThan(0);
174
+ // Anything exempted beyond the sink itself must name the task that
175
+ // removes it — otherwise the exemption is permanent by accident.
176
+ if (entry.file !== sink) expect(entry.reason).toMatch(/Task \d+/);
177
+ }
178
+
179
+ // Nothing outside the sink has a console write to excuse: the one file that
180
+ // did (the startup `console.log` scrape) is gone, so the sink entry is
181
+ // exempt by construction alone. Pin that, or the list grows a member nobody
182
+ // remembers granting.
183
+ const exercised = new Set(HITS.map((h) => h.file));
184
+ expect([...exercised]).toEqual([]);
185
+ expect(exercised.has(sink)).toBe(false);
186
+ });
187
+ });
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ import { diag } from "./log.js";
8
+
9
+ // A scratch directory per test file; `diag` is pointed at files inside it.
10
+ const SANDBOX = mkdtempSync(join(tmpdir(), `archimedes-log-${randomUUID()}-`));
11
+
12
+ const ENV = "PI_ARCHIMEDES_LOG";
13
+ const previous = process.env[ENV];
14
+
15
+ afterEach(() => {
16
+ if (previous === undefined) delete process.env[ENV];
17
+ else process.env[ENV] = previous;
18
+ });
19
+
20
+ describe("diag — the diagnostics sink", () => {
21
+ it("writes nothing at all when the env var is unset", () => {
22
+ // Unset is the default state of every real session, so this is the
23
+ // "logging costs nothing and touches no disk" guarantee. A file from an
24
+ // earlier enabled run proves the sink had somewhere to go; it must not
25
+ // gain a line now.
26
+ const target = join(SANDBOX, "unset.log");
27
+ process.env[ENV] = target;
28
+ diag("core", "enabled");
29
+
30
+ delete process.env[ENV];
31
+ expect(() => diag("core", "a message")).not.toThrow();
32
+
33
+ expect(readFileSync(target, "utf8").trimEnd().split("\n")).toEqual([
34
+ expect.stringContaining("[core] enabled"),
35
+ ]);
36
+ });
37
+
38
+ it("treats an empty value as off, not as the file named ''", () => {
39
+ // `PI_ARCHIMEDES_LOG=` is how an operator switches logging back off in a
40
+ // shell, and appendFileSync("") would resolve against the cwd — so the
41
+ // empty string must short-circuit rather than write somewhere surprising.
42
+ const target = join(SANDBOX, "blank.log");
43
+ process.env[ENV] = target;
44
+ diag("core", "before");
45
+
46
+ process.env[ENV] = "";
47
+ expect(() => diag("core", "after")).not.toThrow();
48
+
49
+ expect(readFileSync(target, "utf8").trimEnd().split("\n")).toEqual([
50
+ expect.stringContaining("[core] before"),
51
+ ]);
52
+ });
53
+
54
+ it("appends one line carrying an ISO-8601 timestamp, the scope and the message", () => {
55
+ const target = join(SANDBOX, "single.log");
56
+ process.env[ENV] = target;
57
+
58
+ const before = new Date();
59
+ diag("session-name", "title request failed");
60
+ const after = new Date();
61
+
62
+ const text = readFileSync(target, "utf8");
63
+ expect(text.endsWith("\n")).toBe(true);
64
+ const lines = text.trimEnd().split("\n");
65
+ expect(lines).toHaveLength(1);
66
+
67
+ const match = /^\[(.+?)\] \[(.+?)\] (.*)$/.exec(lines[0]!);
68
+ expect(match, `unexpected line shape: ${lines[0]}`).not.toBeNull();
69
+ const [, stamp, scope, message] = match!;
70
+ expect(scope).toBe("session-name");
71
+ expect(message).toBe("title request failed");
72
+ // ISO-8601 with an offset — parseable, and inside the call window.
73
+ const at = new Date(stamp!);
74
+ expect(Number.isNaN(at.getTime())).toBe(false);
75
+ expect(at.getTime()).toBeGreaterThanOrEqual(before.getTime() - 1000);
76
+ expect(at.getTime()).toBeLessThanOrEqual(after.getTime() + 1000);
77
+ expect(stamp!).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/);
78
+ });
79
+
80
+ it("appends across calls instead of truncating the file", () => {
81
+ const target = join(SANDBOX, "append.log");
82
+ process.env[ENV] = target;
83
+
84
+ diag("bus", "first");
85
+ diag("footer", "second");
86
+
87
+ const lines = readFileSync(target, "utf8").trimEnd().split("\n");
88
+ expect(lines).toHaveLength(2);
89
+ expect(lines[0]).toContain("[bus] first");
90
+ expect(lines[1]).toContain("[footer] second");
91
+ });
92
+
93
+ it("re-reads the env var on every call (a later export takes effect)", () => {
94
+ // pi sets nothing itself: the operator exports PI_ARCHIMEDES_LOG in a shell,
95
+ // but a test/tool that sets it mid-run must also be honoured — reading it
96
+ // once at module load would freeze the no-op path.
97
+ const target = join(SANDBOX, "late.log");
98
+ delete process.env[ENV];
99
+ diag("core", "dropped");
100
+ expect(existsSync(target)).toBe(false);
101
+
102
+ process.env[ENV] = target;
103
+ diag("core", "captured");
104
+ expect(readFileSync(target, "utf8")).toContain("[core] captured");
105
+ });
106
+
107
+ it("never throws when the target cannot be written, and creates no directories", () => {
108
+ // A path under a directory that does not exist: appendFileSync fails with
109
+ // ENOENT. Logging must swallow it — and must not "helpfully" mkdir.
110
+ const missingDir = join(SANDBOX, "missing-dir");
111
+ process.env[ENV] = join(missingDir, "log.txt");
112
+
113
+ expect(() => diag("core", "unreachable")).not.toThrow();
114
+ expect(existsSync(missingDir)).toBe(false);
115
+ });
116
+
117
+ it("never throws when the target is a directory", () => {
118
+ const dir = join(SANDBOX, "a-directory");
119
+ mkdirSync(dir, { recursive: true });
120
+ process.env[ENV] = dir;
121
+
122
+ expect(() => diag("core", "EISDIR")).not.toThrow();
123
+ });
124
+
125
+ it("tolerates a message containing newlines without throwing", () => {
126
+ // Callers format error objects; a stack would arrive here multi-line. The
127
+ // sink keeps it verbatim rather than trying to sanitise.
128
+ const target = join(SANDBOX, "multiline.log");
129
+ process.env[ENV] = target;
130
+
131
+ expect(() => diag("core", "line one\nline two")).not.toThrow();
132
+ expect(readFileSync(target, "utf8")).toContain("[core] line one\nline two");
133
+ });
134
+ });
135
+
136
+ // ── describeError — what a swallowed error looks like in the log ───────────
137
+ //
138
+ // `console.error(scope, err)` used to print the stack. Routing a swallowed
139
+ // error through a formatter that keeps only `err.message` would leave the log
140
+ // with a message and no origin, and for these call sites the log is the ONLY
141
+ // record: the write smears a fullscreen frame, and a subagent child discards
142
+ // it. So the formatter keeps the whole `Error`, stack included. (The user-facing
143
+ // surface keeps taking the message alone — see `describeError`'s doc.)
144
+
145
+ import { describeError } from "./log.js";
146
+
147
+ describe("describeError", () => {
148
+ function exploded(): Error {
149
+ // A named frame on purpose: the assertion below wants an origin, not just
150
+ // a message — that is the whole reason the stack is kept.
151
+ return new Error("listener exploded");
152
+ }
153
+
154
+ it("keeps the stack, which is the only source location a swallowed error ever gets", () => {
155
+ const text = describeError(exploded());
156
+ expect(text).toContain("listener exploded");
157
+ expect(text).toMatch(/\n\s+at\s+\S/);
158
+ expect(text).toContain("exploded");
159
+ });
160
+
161
+ it("falls back to the message when the error carries no stack", () => {
162
+ const err = new Error("no stack here");
163
+ delete (err as { stack?: string }).stack;
164
+ expect(describeError(err)).toBe("no stack here");
165
+ });
166
+
167
+ it("stringifies anything that is not an Error", () => {
168
+ expect(describeError("a string")).toBe("a string");
169
+ expect(describeError({ code: 42 })).toBe("[object Object]");
170
+ expect(describeError(undefined)).toBe("undefined");
171
+ });
172
+ });
package/src/log.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ // ── Diagnostics sink ────────────────────────────────────────────────────────
4
+ //
5
+ // The suite does not write to the console. Two independent reasons:
6
+ //
7
+ // 1. On fullscreen screen a stray write lands inside the differential frame
8
+ // and smears the viewport until the next full redraw. On regular screen it
9
+ // scrolled away unseen, which is why the habit was cheap to keep.
10
+ // 2. In a subagent child every write is discarded anyway:
11
+ // `subagent/src/stream.ts` drops any non-JSON stdout line (a guard for pi's
12
+ // own startup messages), so a warning vanishes exactly when someone is
13
+ // debugging a child run.
14
+ //
15
+ // So developer diagnostics go here, and a message the user must see goes to the
16
+ // notification surface (`ctx.ui.notify`) — never to stdout/stderr.
17
+ //
18
+ // Logging is opt-in and dumb on purpose: one env var naming a file, one
19
+ // appended line per call, and a `try/catch` that swallows every failure. A
20
+ // diagnostic that can throw, block, or fill a disk is a new bug class, and the
21
+ // whole point of the sink is to be trustworthy while another bug is in flight.
22
+ // Nothing else in this module touches the filesystem — it does not create
23
+ // directories, rotate, buffer, or truncate.
24
+
25
+ /**
26
+ * Append one diagnostic line to `$PI_ARCHIMEDES_LOG`.
27
+ *
28
+ * No-op when the variable is unset or empty, so a default install never
29
+ * touches disk. When set, appends `[ISO-8601] [scope] message`. Never throws:
30
+ * an unwritable path (missing directory, EISDIR, EACCES, a full volume) is
31
+ * silently ignored, because losing a log line is always cheaper than failing a
32
+ * render, an event handler, or a session.
33
+ *
34
+ * One *append* per call; the message is kept verbatim, so an entry built by
35
+ * `describeError` spans the lines of its stack — which is the point of it.
36
+ *
37
+ * @param scope the package the line came from (e.g. `"session-name"`)
38
+ * @param message the diagnostic text; multi-line text is kept verbatim
39
+ */
40
+ export function diag(scope: string, message: string): void {
41
+ const file = process.env.PI_ARCHIMEDES_LOG;
42
+ if (!file) return;
43
+ try {
44
+ appendFileSync(file, `[${new Date().toISOString()}] [${scope}] ${message}\n`);
45
+ } catch {
46
+ // Logging must never be the reason something failed.
47
+ }
48
+ }
49
+
50
+ /**
51
+ * An error, formatted for `diag` — stack included.
52
+ *
53
+ * Task 6 moved errors off the console, and handing an error object to the
54
+ * console used to print its stack on the way out. Formatting them down to
55
+ * `err.message` would trade a visible stack for an invisible one, and at the sites this exists for —
56
+ * a bus listener that threw, a footer frame that failed, a package whose
57
+ * `import()` was refused — the error is **swallowed**, so this log is the only
58
+ * place it is ever recorded and the stack is the only part naming its origin.
59
+ *
60
+ * Use the message alone (`err.message`) where someone on screen is about to
61
+ * read the text — `ctx.ui.notify`, a tool result, a widget. A stack belongs in a
62
+ * log file, never in a notification.
63
+ *
64
+ * @param err whatever a `catch` caught
65
+ */
66
+ export function describeError(err: unknown): string {
67
+ if (err instanceof Error) return err.stack ?? err.message;
68
+ return String(err);
69
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import { CURSOR_MARKER, stripTerminalSequences, visibleWidth as tuiVisibleWidth } from "@earendil-works/pi-tui";
2
3
  import {
3
4
  visibleWidth,
4
5
  padEnd,
@@ -52,9 +53,14 @@ describe("padEnd", () => {
52
53
  expect(padEnd("hi", 5)).toBe("hi ");
53
54
  });
54
55
 
55
- it("returns input unchanged when visible width >= target", () => {
56
+ it("leaves a line that already fits untouched", () => {
56
57
  expect(padEnd("hello", 5)).toBe("hello");
57
- expect(padEnd("toolong", 5)).toBe("toolong");
58
+ });
59
+
60
+ it("clips a line wider than the target instead of letting it overflow", () => {
61
+ // The frame is a hard limit: a padded row must never measure past it, or
62
+ // the closing border lands off-screen and the terminal hard-wraps the row.
63
+ expect(visibleWidth(padEnd("toolong", 5))).toBe(5);
58
64
  });
59
65
 
60
66
  it("returns '' for width <= 0", () => {
@@ -67,11 +73,11 @@ describe("padEnd", () => {
67
73
 
68
74
  describe("wrapText", () => {
69
75
  it("wraps a long line to width", () => {
70
- expect(wrapText("aaa bbb ccc ddd", 8)).toEqual(["aaa bbb ", "ccc ddd"]);
76
+ expect(wrapText("aaa bbb ccc ddd", 8)).toEqual(["aaa bbb", "ccc ddd"]);
71
77
  });
72
78
 
73
79
  it("wraps long words to width", () => {
74
- expect(wrapText("hello world", 5)).toEqual(["hello", " ", "world"]);
80
+ expect(wrapText("hello world", 5)).toEqual(["hello", "world"]);
75
81
  });
76
82
 
77
83
  it("preserves empty paragraphs as blank lines", () => {
@@ -91,7 +97,10 @@ describe("hardTruncate", () => {
91
97
  });
92
98
 
93
99
  it("truncates at the visible-width boundary", () => {
94
- expect(hardTruncate("hello world", 5)).toBe("hello");
100
+ // pi-tui closes any styling it cut into, so the visible text — not the raw
101
+ // bytes — is the contract.
102
+ expect(stripTerminalSequences(hardTruncate("hello world", 5))).toBe("hello");
103
+ expect(visibleWidth(hardTruncate("hello world", 5))).toBe(5);
95
104
  });
96
105
 
97
106
  it("truncates an ANSI-colored string and appends a reset", () => {
@@ -101,6 +110,80 @@ describe("hardTruncate", () => {
101
110
  });
102
111
  });
103
112
 
113
+ // ── Terminal-hostile content ────────────────────────────────────────────────
114
+ //
115
+ // Every measurement in this module used to be `.length` after stripping only
116
+ // `\x1b[…m`. That is wrong for the three things the real overlays actually
117
+ // print: pi's cursor marker (an APC sequence, zero columns wide), OSC 8
118
+ // hyperlinks (a URL is invisible but enormous), and east-asian text (two
119
+ // columns per character). Each case below is measured with pi-tui's own
120
+ // `visibleWidth`, i.e. what the terminal draws.
121
+
122
+ describe("width maths on terminal-hostile content", () => {
123
+ it("measures a line carrying the real CURSOR_MARKER at its visible width", () => {
124
+ const line = `Search: plan${CURSOR_MARKER}`;
125
+ expect(visibleWidth(line)).toBe("Search: plan".length);
126
+ // The row this feeds must still land on exactly the target column: the
127
+ // marker occupies no cells, so the padding has to make up all of them.
128
+ expect(visibleWidth(padEnd(line, 20))).toBe(20);
129
+ });
130
+
131
+ it("returns an OSC 8 hyperlink that already fits byte-for-byte", () => {
132
+ const url = `https://example.com/${"a".repeat(40)}`;
133
+ const line = `\x1b]8;;${url}\x07docs\x1b]8;;\x07`;
134
+ // 4 visible columns at a limit of 4: the no-cut path. pi-tui hands the input
135
+ // straight back, which is what makes a fitting row render identically to the
136
+ // old chrome. This is NOT the cutting case — see the next test.
137
+ expect(visibleWidth(line)).toBe(4);
138
+ expect(hardTruncate(line, 4)).toBe(line);
139
+ });
140
+
141
+ it("cuts inside an OSC 8 hyperlink without leaving a half-written sequence", () => {
142
+ const url = `https://example.com/${"a".repeat(40)}`;
143
+ const line = `\x1b]8;;${url}\x07documentation\x1b]8;;\x07`; // 13 visible columns
144
+ expect(visibleWidth(line)).toBe(13);
145
+ const cut = hardTruncate(line, 4);
146
+ // The cut really happened: four columns of the linked text survive.
147
+ expect(tuiVisibleWidth(cut)).toBe(4);
148
+ expect(stripTerminalSequences(cut)).toBe("docu");
149
+ // The URL stays inside its escape sequence and every OSC 8 opener is
150
+ // terminated — a half-written OSC swallows the rest of the row, which is how
151
+ // the old byte-walking truncator failed here (it produced the 3-column stub
152
+ // "\x1b]8;" for this exact input, with no BEL and no closing sequence).
153
+ const opens = cut.match(/\x1b\]8;;/g) ?? [];
154
+ const terminated = cut.match(/\x1b\]8;;[^\x07]*\x07/g) ?? [];
155
+ expect(opens.length).toBeGreaterThan(0);
156
+ expect(terminated.length).toBe(opens.length);
157
+ // And the cut emits an explicit hyperlink close plus a reset, so the styling
158
+ // and the link target cannot bleed into the padding and the closing border.
159
+ expect(cut).toContain("\x1b]8;;\x07");
160
+ expect(cut.endsWith("\x1b[0m")).toBe(true);
161
+ });
162
+
163
+ it("wraps a CJK line at the right column", () => {
164
+ const lines = wrapText("日本語のテキストです", 6);
165
+ expect(lines).toEqual(["日本語", "のテキ", "ストで", "す"]);
166
+ for (const line of lines) {
167
+ expect(tuiVisibleWidth(line)).toBeLessThanOrEqual(6);
168
+ }
169
+ });
170
+
171
+ it("keeps an SGR colour across a wrap", () => {
172
+ const lines = wrapText("\x1b[31mred word and more words\x1b[0m", 8);
173
+ expect(lines.map((l) => stripTerminalSequences(l))).toEqual([
174
+ "red word",
175
+ "and more",
176
+ "words",
177
+ ]);
178
+ // Each continuation line re-opens the colour, so the wrap does not spill
179
+ // red onto whatever the surface prints next.
180
+ for (const line of lines) {
181
+ expect(line).toContain("\x1b[31m");
182
+ expect(tuiVisibleWidth(line)).toBeLessThanOrEqual(8);
183
+ }
184
+ });
185
+ });
186
+
104
187
  // ── renderHeader / renderFooter ──────────────────────────────────────────────
105
188
 
106
189
  describe("renderHeader", () => {
@@ -164,7 +247,10 @@ describe("wrapWithBorder", () => {
164
247
  for (const line of out) {
165
248
  expect(visibleWidth(line)).toBe(W);
166
249
  }
167
- expect(out[1]).toBe("│ " + "z".repeat(W - 4) + " │");
250
+ // Asserted on visible text, not bytes: closing the truncation appends a
251
+ // zero-width reset, which is what keeps the style from bleeding into the
252
+ // padding and the closing border.
253
+ expect(stripTerminalSequences(out[1]!)).toBe("│ " + "z".repeat(W - 4) + " │");
168
254
  });
169
255
  });
170
256
 
@@ -179,3 +265,89 @@ describe("OVERLAY_CHROME", () => {
179
265
  });
180
266
  });
181
267
  });
268
+
269
+ // ── Frame-width audit ────────────────────────────────────────────────────────
270
+ //
271
+ // The automated stand-in for a `PI_TUI_WRITE_LOG` capture: render the chrome the
272
+ // way the overlay surfaces do, measure every produced line with pi-tui's own
273
+ // `visibleWidth` (what the terminal actually draws), and require it to fit the
274
+ // frame. A line wider than the frame is not a cosmetic defect — the terminal
275
+ // hard-wraps it, which shoves the closing border onto the next row and shifts
276
+ // every row below it.
277
+ //
278
+ // The same audit runs per surface where that surface lives (the agents panel in
279
+ // @pi-archimedes/subagent, the settings/onboarding overlays in meta), so the
280
+ // numbers below are the chrome's share of the measurement.
281
+
282
+ /** Hostile content, one entry per failure mode of the old `.length` maths. */
283
+ function hostileLines(theme: OverlayTheme): string[] {
284
+ const url = `https://example.com/${"segment/".repeat(12)}`;
285
+ return [
286
+ // real pi cursor marker (APC: ESC _ … BEL) — zero columns wide
287
+ `Search: wide-query${CURSOR_MARKER}`,
288
+ // OSC 8 hyperlink — long invisible payload, short visible text
289
+ `docs: \x1b]8;;${url}\x07readme\x1b]8;;\x07 tail`,
290
+ // east-asian text — two columns per code unit
291
+ "設定パネルの横幅の測定を検証するための日本語テキスト",
292
+ // SGR-coloured runs
293
+ theme.fg("accent", "accent run ") + theme.fg("dim", "dim run ") + "tail",
294
+ // all three at once, wider than any frame
295
+ theme.fg("error", "✗ " + "失敗".repeat(40)) + `\x1b]8;;${url}\x07link\x1b]8;;\x07` + CURSOR_MARKER,
296
+ ];
297
+ }
298
+
299
+ interface AuditViolation {
300
+ surface: string;
301
+ frameWidth: number;
302
+ row: number;
303
+ measured: number;
304
+ }
305
+
306
+ /**
307
+ * Measure every produced line against the frame. Returns one entry per line
308
+ * that overflows (or, for a bordered frame, fails to land on it exactly).
309
+ */
310
+ function auditFrameWidth(
311
+ surface: string,
312
+ frameWidth: number,
313
+ lines: string[],
314
+ bordered: boolean,
315
+ ): AuditViolation[] {
316
+ const violations: AuditViolation[] = [];
317
+ lines.forEach((line, row) => {
318
+ const measured = tuiVisibleWidth(line);
319
+ const bad = bordered ? measured !== frameWidth : measured > frameWidth;
320
+ if (bad) violations.push({ surface, frameWidth, row, measured });
321
+ });
322
+ return violations;
323
+ }
324
+
325
+ describe("frame-width audit (overlay chrome)", () => {
326
+ const FRAME_WIDTHS = [40, 60, 84, 100];
327
+
328
+ it("renders chrome inside the frame at every width, for hostile content", () => {
329
+ const violations: AuditViolation[] = [];
330
+ const content = hostileLines(mockTheme);
331
+
332
+ for (const frameWidth of FRAME_WIDTHS) {
333
+ const contentWidth = borderContentWidth(frameWidth);
334
+ // Composed the way the real surfaces compose it: header + padded body
335
+ // rows (some wrapped) + footer, then the border.
336
+ const body = content.flatMap((line) => wrapText(line, contentWidth));
337
+ const lines = [
338
+ renderHeader(" Settings ", contentWidth, mockTheme),
339
+ ...body.map((line) => padEnd(line, contentWidth)),
340
+ renderFooter(" [esc] close ", contentWidth, mockTheme),
341
+ ];
342
+ violations.push(
343
+ ...auditFrameWidth("chrome", frameWidth, wrapWithBorder(lines, frameWidth, mockTheme), true),
344
+ );
345
+ }
346
+
347
+ expect(
348
+ violations,
349
+ `${violations.length} chrome line(s) overflow the frame: ` +
350
+ violations.slice(0, 8).map((v) => `w=${v.frameWidth} row=${v.row} measured=${v.measured}`).join(", "),
351
+ ).toEqual([]);
352
+ });
353
+ });