@zenodinh/pi-render 0.0.0-stage → 0.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/index.ts +279 -0
  4. package/package.json +66 -5
  5. package/src/commands/canvas.test.ts +150 -0
  6. package/src/commands/canvas.ts +57 -0
  7. package/src/core/code-theme.test.ts +266 -0
  8. package/src/core/code-theme.ts +275 -0
  9. package/src/core/log.test.ts +127 -0
  10. package/src/core/log.ts +57 -0
  11. package/src/core/paint.test.ts +322 -0
  12. package/src/core/paint.ts +64 -0
  13. package/src/core/registry.test.ts +307 -0
  14. package/src/core/registry.ts +135 -0
  15. package/src/core/settings.test.ts +183 -0
  16. package/src/core/settings.ts +119 -0
  17. package/src/core/types/code-theme.ts +24 -0
  18. package/src/core/types/host.ts +160 -0
  19. package/src/core/types/log.ts +28 -0
  20. package/src/core/types/paint.ts +54 -0
  21. package/src/core/types/registry.ts +44 -0
  22. package/src/core/types/settings.ts +31 -0
  23. package/src/core/types.ts +34 -0
  24. package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
  25. package/src/renderers/content/artifacts/cache.ts +234 -0
  26. package/src/renderers/content/artifacts/cards.test.ts +216 -0
  27. package/src/renderers/content/artifacts/cards.ts +136 -0
  28. package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
  29. package/src/renderers/content/artifacts/engines.ts +396 -0
  30. package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
  31. package/src/renderers/content/artifacts/local-binary.ts +80 -0
  32. package/src/renderers/content/artifacts/prereqs.ts +128 -0
  33. package/src/renderers/content/artifacts/server.ts +181 -0
  34. package/src/renderers/content/code-panel.ts +161 -0
  35. package/src/renderers/content/image-card.test.ts +170 -0
  36. package/src/renderers/content/image-card.ts +252 -0
  37. package/src/renderers/content/index.ts +79 -0
  38. package/src/renderers/content/json-panel.ts +116 -0
  39. package/src/renderers/content/panels.test.ts +188 -0
  40. package/src/renderers/content/table.test.ts +209 -0
  41. package/src/renderers/content/table.ts +174 -0
  42. package/src/renderers/content/transformer.test.ts +254 -0
  43. package/src/renderers/content/types.ts +20 -0
  44. package/src/renderers/tool/index.ts +113 -0
  45. package/src/renderers/tool/resolver.test.ts +257 -0
  46. package/src/renderers/tool/runtime.test.ts +313 -0
  47. package/src/renderers/tool/runtime.ts +267 -0
  48. package/src/renderers/tool/specs/bash.test.ts +110 -0
  49. package/src/renderers/tool/specs/bash.ts +168 -0
  50. package/src/renderers/tool/specs/codemode.test.ts +212 -0
  51. package/src/renderers/tool/specs/codemode.ts +248 -0
  52. package/src/renderers/tool/specs/edit.test.ts +260 -0
  53. package/src/renderers/tool/specs/edit.ts +213 -0
  54. package/src/renderers/tool/specs/ls.test.ts +173 -0
  55. package/src/renderers/tool/specs/ls.ts +136 -0
  56. package/src/renderers/tool/specs/read.test.ts +340 -0
  57. package/src/renderers/tool/specs/read.ts +296 -0
  58. package/src/renderers/tool/specs/search.test.ts +197 -0
  59. package/src/renderers/tool/specs/search.ts +325 -0
  60. package/src/renderers/tool/specs/write.test.ts +145 -0
  61. package/src/renderers/tool/specs/write.ts +142 -0
  62. package/src/renderers/tool/types.ts +45 -0
  63. package/themes/dracula-soft.json +81 -0
  64. package/themes/one-dark.json +80 -0
  65. package/themes/themes.test.ts +251 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * log.test.ts — createLogger's behavior through its seam: logLine / logOnce in, drain() out (T-06).
3
+ *
4
+ * No module internals appear here; every assertion reads the entries a test or the future /render
5
+ * panel would read back.
6
+ *
7
+ * shape: none — test module, no runtime unit for DSG-1's trigger table to select a shape for.
8
+ */
9
+ // invented fixtures: the keys, scopes, and messages below are plain strings with no upstream payload to
10
+ // record; "tool:call-42" is the key shape named in src/core/types/log.ts.
11
+ import { describe, expect, test, vi } from "vitest";
12
+ import { createLogger } from "./log.ts";
13
+
14
+ describe("createLogger", () => {
15
+ // spec: logOnce(key, …) appends one entry for a key; each distinct key appends its own.
16
+ // fails_when: a repeated key adds a duplicate entry, or a second key collides onto the first.
17
+ test("AC-1 logOnce keeps one entry per key, distinct keys do not collide", () => {
18
+ const logger = createLogger();
19
+
20
+ logger.logOnce("tool:call-42", "tool.bash", "first");
21
+ logger.logOnce("tool:call-42", "tool.bash", "repeat of the same key");
22
+ logger.logOnce("tool:call-43", "tool.bash", "a different key");
23
+
24
+ expect(logger.drain().map((entry) => entry.message)).toEqual(["first", "a different key"]);
25
+ });
26
+
27
+ // spec: any sequence of logging calls returns without throwing, including drain() on a logger with
28
+ // no entries; the render path calls these, so a throw is a permanently broken row.
29
+ // fails_when: a logging call throws — the render path can be broken by its own diagnostics.
30
+ test("AC-2 no logging call throws, even on hostile text", () => {
31
+ const logger = createLogger();
32
+ const longText = "x".repeat(200_000);
33
+
34
+ expect(logger.drain()).toEqual([]);
35
+
36
+ expect(() => {
37
+ logger.logLine("", "");
38
+ logger.logLine("tool.bash", "line\nwith newlines\tand a 🖼 surrogate pair");
39
+ logger.logLine("tool.bash", longText);
40
+ logger.logOnce("tool:call-42", "tool.bash", longText);
41
+ logger.logOnce("tool:call-42", "tool.bash", "repeat");
42
+ logger.drain();
43
+ }).not.toThrow();
44
+
45
+ expect(logger.drain()).toEqual([]);
46
+ });
47
+
48
+ // spec: a logging call writes nothing to process.stdout or process.stderr — pi owns the terminal
49
+ // for the TUI, so a write lands inside the rendered frame.
50
+ // fails_when: a diagnostic leaks to the terminal.
51
+ test("AC-3 logging writes neither stdout nor stderr", () => {
52
+ const logger = createLogger();
53
+ const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
54
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
55
+
56
+ try {
57
+ logger.logLine("tool.bash", "line");
58
+ logger.logOnce("tool:call-42", "tool.bash", "once");
59
+ logger.logOnce("tool:call-42", "tool.bash", "once");
60
+ expect(logger.drain()).toHaveLength(2);
61
+ // Asserted before mockRestore(): restoring also discards the recorded calls.
62
+ expect(stdout).not.toHaveBeenCalled();
63
+ expect(stderr).not.toHaveBeenCalled();
64
+ } finally {
65
+ stdout.mockRestore();
66
+ stderr.mockRestore();
67
+ }
68
+ });
69
+
70
+ // spec: logLine(scope, message) leaves the entry attributable — that scope, that message, and a
71
+ // capture time inside the call window.
72
+ // fails_when: entries cannot be attributed to the module that logged them.
73
+ test("AC-4 each entry keeps its scope, message, and capture time", () => {
74
+ const logger = createLogger();
75
+ const before = Date.now();
76
+
77
+ logger.logLine("find", "no matches");
78
+ logger.logOnce("tool:call-42", "tool.bash", "degraded row");
79
+
80
+ const after = Date.now();
81
+ const entries = logger.drain();
82
+
83
+ expect(entries.map((entry) => entry.scope)).toEqual(["find", "tool.bash"]);
84
+ expect(entries.map((entry) => entry.message)).toEqual(["no matches", "degraded row"]);
85
+ for (const entry of entries) {
86
+ expect(entry.time).toBeGreaterThanOrEqual(before);
87
+ expect(entry.time).toBeLessThanOrEqual(after);
88
+ }
89
+ });
90
+
91
+ // spec: past 256 entries the newest 256 survive in order and the oldest are dropped.
92
+ // fails_when: the buffer grows unbounded, or it drops the newest instead of the oldest.
93
+ test("AC-5 more than 256 entries keeps the newest 256 in order", () => {
94
+ const logger = createLogger();
95
+ for (let i = 0; i < 300; i += 1) logger.logLine("loop", `line-${i}`);
96
+
97
+ const messages = logger.drain().map((entry) => entry.message);
98
+
99
+ expect(messages).toHaveLength(256);
100
+ expect(messages.at(0)).toBe("line-44");
101
+ expect(messages.at(-1)).toBe("line-299");
102
+ });
103
+
104
+ // spec: drain() returns every captured entry in log order, and the buffer is empty afterwards.
105
+ // fails_when: a drained buffer still holds entries, so a panel re-shows the same lines.
106
+ test("drain() returns every captured entry and then empties the buffer", () => {
107
+ const logger = createLogger();
108
+ logger.logLine("tool.bash", "one");
109
+ logger.logLine("find", "two");
110
+
111
+ expect(logger.drain().map((entry) => entry.scope)).toEqual(["tool.bash", "find"]);
112
+ expect(logger.drain()).toEqual([]);
113
+ });
114
+
115
+ // spec: "at most one entry per key" is a property of the logger, not of the current buffer — a
116
+ // panel that drains every frame must not re-emit a key's line.
117
+ // fails_when: drain() clears the dedup marks, so the same key logs again after a readout.
118
+ test("logOnce keeps a key spent across drain()", () => {
119
+ const logger = createLogger();
120
+ logger.logOnce("tool:call-42", "tool.bash", "once");
121
+ logger.drain();
122
+
123
+ logger.logOnce("tool:call-42", "tool.bash", "again");
124
+
125
+ expect(logger.drain()).toEqual([]);
126
+ });
127
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * log.ts — keyed in-memory diagnostics: logLine for scoped lines, logOnce for once-per-key lines.
3
+ *
4
+ * Memory, not a file. The predecessor resolved an agent directory and appended to disk on every line —
5
+ * the owner-confirmed cause of its slow start. A bounded ring costs no boot work, no fs call, and no
6
+ * permission that can fail; drain() is the readout tests and the future /render panel consume.
7
+ *
8
+ * Boundary: called from the render path, so nothing here throws or writes stdout/stderr — pi owns the
9
+ * terminal. Both hold structurally: there is no fallible operation here to catch.
10
+ *
11
+ * shape: closure returning an object literal — trigger #4, one ring buffer plus dedup marks per logger,
12
+ * no subclassing or instanceof.
13
+ */
14
+ import type { LogEntry, Logger } from "./types/log.ts";
15
+
16
+ /** A session logs thousands; the panel reads a window, so the buffer stays bounded and the rest drops. */
17
+ const LOG_CAP = 256;
18
+
19
+ export function createLogger(): Logger {
20
+ // Empty until the first entry: nothing is preallocated at import or construction, and pushing below
21
+ // the cap keeps the index math out of the common case.
22
+ let ring: Array<LogEntry | undefined> = [];
23
+ let head = 0;
24
+ // Marks, not entries: a panel may drain repeatedly, and a drained key must still count as logged.
25
+ const seen = new Set<string>();
26
+
27
+ const record = (entry: LogEntry): void => {
28
+ if (ring.length < LOG_CAP) {
29
+ ring.push(entry);
30
+ return;
31
+ }
32
+ ring[head] = entry;
33
+ head = (head + 1) % LOG_CAP;
34
+ };
35
+
36
+ return {
37
+ logLine(scope: string, message: string): void {
38
+ record({ scope, message, time: Date.now() });
39
+ },
40
+ logOnce(key: string, scope: string, message: string): void {
41
+ if (seen.has(key)) return;
42
+ seen.add(key);
43
+ record({ scope, message, time: Date.now() });
44
+ },
45
+ drain(): LogEntry[] {
46
+ const out: LogEntry[] = [];
47
+ // Once full, head is the oldest slot, so read from it and wrap.
48
+ for (let i = 0; i < ring.length; i += 1) {
49
+ const entry = ring[(head + i) % ring.length];
50
+ if (entry !== undefined) out.push(entry);
51
+ }
52
+ ring = [];
53
+ head = 0;
54
+ return out;
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * paint.test.ts — the two painting seams: role → host token, and the degrade-to-plain contract.
3
+ *
4
+ * Tests enter at createRowPaint / createContentPaint only, and every theme is a plain object, so no
5
+ * host package is imported (AC-4).
6
+ */
7
+
8
+ // shape: table-driven data + closures — trigger #4, two host-shape stand-ins parameterized by an
9
+ // escape table; a class or a fixture framework would be ceremony over one fg lookup.
10
+ import { describe, expect, it } from "vitest";
11
+ import { createContentPaint, createRowPaint } from "./paint.ts";
12
+ import type { HostTheme, MarkdownTheme } from "./types/host.ts";
13
+
14
+ // Fixture provenance — host theme shape: recorded-from @earendil-works/pi-coding-agent@1.0.4,
15
+ // dist/modes/interactive/theme/theme.js:201-216 (read 2026-10-06): fg() returns `<open>text\x1b[39m`,
16
+ // and throws `Unknown theme color: <token>` when the active theme lacks the token.
17
+ // Fixture provenance — dark palette: recorded-from themes/dracula-soft.json (2026-10-06), vars resolved
18
+ // to truecolor RGB — toolTitle #f6f6f4, toolOutput/muted/searchMatchText/mdHr/mdQuote/mdQuoteBorder/
19
+ // mdCodeBlockBorder #7b7f8b, accent #bf9eee, error #ee6666, warning #ffb86c, mdCode #62e884.
20
+ // Fixture provenance — light + system: invented — the indexed and terminal-default escape forms the
21
+ // host emits in those modes, each token distinct so a leaked escape names itself; pi-render reads no
22
+ // palette, so only the escape form is contract-relevant.
23
+
24
+ const TEXT = "bash";
25
+ const FG_CLOSE = "\x1b[39m";
26
+
27
+ /** One escape per host token; typed so a fixture cannot silently omit a token paint reads. */
28
+ type EscapeTable = {
29
+ toolTitle: string;
30
+ toolOutput: string;
31
+ muted: string;
32
+ accent: string;
33
+ error: string;
34
+ warning: string;
35
+ searchMatchText: string;
36
+ hr: string;
37
+ quote: string;
38
+ quoteBorder: string;
39
+ code: string;
40
+ codeBlockBorder: string;
41
+ };
42
+
43
+ const DARK_OPEN: EscapeTable = {
44
+ toolTitle: "\x1b[38;2;246;246;244m",
45
+ toolOutput: "\x1b[38;2;123;127;139m",
46
+ muted: "\x1b[38;2;123;127;139m",
47
+ accent: "\x1b[38;2;191;158;238m",
48
+ error: "\x1b[38;2;238;102;102m",
49
+ warning: "\x1b[38;2;255;184;108m",
50
+ searchMatchText: "\x1b[38;2;123;127;139m",
51
+ hr: "\x1b[38;2;123;127;139m",
52
+ quote: "\x1b[38;2;123;127;139m",
53
+ quoteBorder: "\x1b[38;2;123;127;139m",
54
+ code: "\x1b[38;2;98;232;132m",
55
+ codeBlockBorder: "\x1b[38;2;123;127;139m",
56
+ };
57
+
58
+ const LIGHT_OPEN: EscapeTable = {
59
+ toolTitle: "\x1b[38;5;61m",
60
+ toolOutput: "\x1b[38;5;240m",
61
+ muted: "\x1b[38;5;240m",
62
+ accent: "\x1b[38;5;134m",
63
+ error: "\x1b[38;5;167m",
64
+ warning: "\x1b[38;5;179m",
65
+ searchMatchText: "\x1b[38;5;135m",
66
+ hr: "\x1b[38;5;240m",
67
+ quote: "\x1b[38;5;242m",
68
+ quoteBorder: "\x1b[38;5;241m",
69
+ code: "\x1b[38;5;71m",
70
+ codeBlockBorder: "\x1b[38;5;239m",
71
+ };
72
+
73
+ /** An empty theme token renders the terminal default — the host uses `\x1b[39m` for every such token. */
74
+ const DEFAULT_OPEN: EscapeTable = Object.fromEntries(
75
+ Object.keys(DARK_OPEN).map((token) => [token, "\x1b[39m"]),
76
+ ) as EscapeTable;
77
+
78
+ const FORMS: ReadonlyArray<[string, EscapeTable]> = [
79
+ ["truecolor (dark)", DARK_OPEN],
80
+ ["indexed (light)", LIGHT_OPEN],
81
+ ["terminal default (system)", DEFAULT_OPEN],
82
+ ];
83
+
84
+ /** Mirrors Theme.fg: `<open>text\x1b[39m`, throwing on a token the fixture theme does not carry. */
85
+ function hostFg(open: Record<string, string>): (key: string, text: string) => string {
86
+ return (key, text) => {
87
+ const sequence = open[key];
88
+ if (sequence === undefined) throw new Error(`Unknown theme color: ${key}`);
89
+ return `${sequence}${text}${FG_CLOSE}`;
90
+ };
91
+ }
92
+
93
+ function themeWith(open: Record<string, string>): HostTheme {
94
+ return { fg: hostFg(open), bold: (text) => text };
95
+ }
96
+
97
+ /** Mirrors getMarkdownTheme(): each member closes over the live theme's fg for its md* token. */
98
+ function markdownThemeWith(open: Record<string, string>): MarkdownTheme {
99
+ const fg = hostFg(open);
100
+ return {
101
+ hr: (text) => fg("hr", text),
102
+ quote: (text) => fg("quote", text),
103
+ quoteBorder: (text) => fg("quoteBorder", text),
104
+ code: (text) => fg("code", text),
105
+ codeBlockBorder: (text) => fg("codeBlockBorder", text),
106
+ };
107
+ }
108
+
109
+ /** Marker stand-in: no escapes at all, so the recorded token list is the only signal paint can leak. */
110
+ function recorder(): { fg: (key: string, text: string) => string; tokens: string[] } {
111
+ const tokens: string[] = [];
112
+ return {
113
+ tokens,
114
+ fg: (key, text) => {
115
+ tokens.push(key);
116
+ return `«${key}»${text}«/${key}»`;
117
+ },
118
+ };
119
+ }
120
+
121
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: the ESC introducer is exactly what we scan for
122
+ const ESCAPE_SEQUENCE = /\x1b\[[0-9;]*m/g;
123
+ const escapesIn = (text: string): string[] => text.match(ESCAPE_SEQUENCE) ?? [];
124
+
125
+ describe("createRowPaint — region 1", () => {
126
+ it("AC-1: every row role wraps through its own host token", () => {
127
+ const marker = recorder();
128
+ const paint = createRowPaint({ fg: marker.fg, bold: (text) => text });
129
+ const wrapped = [
130
+ paint.title(TEXT),
131
+ paint.output(TEXT),
132
+ paint.muted(TEXT),
133
+ paint.accent(TEXT),
134
+ paint.error(TEXT),
135
+ paint.warning(TEXT),
136
+ paint.gutter(TEXT),
137
+ paint.match(TEXT),
138
+ ];
139
+ // fails_when: a role reads the wrong token, or returns its text unwrapped
140
+ expect(marker.tokens).toEqual([
141
+ "toolTitle",
142
+ "toolOutput",
143
+ "muted",
144
+ "accent",
145
+ "error",
146
+ "warning",
147
+ "muted",
148
+ "searchMatchText",
149
+ ]);
150
+ expect(wrapped).toEqual([
151
+ "«toolTitle»bash«/toolTitle»",
152
+ "«toolOutput»bash«/toolOutput»",
153
+ "«muted»bash«/muted»",
154
+ "«accent»bash«/accent»",
155
+ "«error»bash«/error»",
156
+ "«warning»bash«/warning»",
157
+ "«muted»bash«/muted»",
158
+ "«searchMatchText»bash«/searchMatchText»",
159
+ ]);
160
+ });
161
+
162
+ it.each(FORMS)("AC-1: %s escapes reach every role byte-exact", (_form, open) => {
163
+ const paint = createRowPaint(themeWith(open));
164
+ const outputs = [
165
+ paint.title(TEXT),
166
+ paint.output(TEXT),
167
+ paint.muted(TEXT),
168
+ paint.accent(TEXT),
169
+ paint.error(TEXT),
170
+ paint.warning(TEXT),
171
+ paint.gutter(TEXT),
172
+ paint.match(TEXT),
173
+ ];
174
+ // fails_when: paint rewrites, drops, or double-wraps the theme's own escape
175
+ expect(outputs).toEqual([
176
+ `${open.toolTitle}${TEXT}${FG_CLOSE}`,
177
+ `${open.toolOutput}${TEXT}${FG_CLOSE}`,
178
+ `${open.muted}${TEXT}${FG_CLOSE}`,
179
+ `${open.accent}${TEXT}${FG_CLOSE}`,
180
+ `${open.error}${TEXT}${FG_CLOSE}`,
181
+ `${open.warning}${TEXT}${FG_CLOSE}`,
182
+ `${open.muted}${TEXT}${FG_CLOSE}`,
183
+ `${open.searchMatchText}${TEXT}${FG_CLOSE}`,
184
+ ]);
185
+ });
186
+
187
+ it("AC-1: a token the active theme lacks degrades that role, not the frame", () => {
188
+ const paint = createRowPaint(themeWith({ toolTitle: "\x1b[38;2;246;246;244m" }));
189
+ // fails_when: an exotic theme crashes paint (host fg throws `Unknown theme color`)
190
+ expect(paint.title(TEXT)).toBe("\x1b[38;2;246;246;244mbash\x1b[39m");
191
+ expect([paint.output(TEXT), paint.muted(TEXT), paint.error(TEXT), paint.match(TEXT)]).toEqual([
192
+ "bash",
193
+ "bash",
194
+ "bash",
195
+ "bash",
196
+ ]);
197
+ });
198
+
199
+ it("AC-4: a plain two-member object satisfies the seam with no host package", () => {
200
+ const bolded: string[] = [];
201
+ const theme = {
202
+ fg: (key: string, text: string): string => `«${key}»${text}«/${key}»`,
203
+ bold: (text: string): string => {
204
+ bolded.push(text);
205
+ return `«bold»${text}«/bold»`;
206
+ },
207
+ };
208
+ const paint = createRowPaint(theme);
209
+ // fails_when: paint requires the host Theme instance rather than the structural shape
210
+ expect(paint.title(TEXT)).toBe("«toolTitle»bash«/toolTitle»");
211
+ expect(bolded).toEqual([]);
212
+ });
213
+
214
+ it("reads the theme live on every call — no cache to invalidate", () => {
215
+ const theme = themeWith({ toolTitle: "\x1b[38;2;1;2;3m" });
216
+ const paint = createRowPaint(theme);
217
+ expect(paint.title(TEXT)).toBe("\x1b[38;2;1;2;3mbash\x1b[39m");
218
+ theme.fg = hostFg({ toolTitle: "\x1b[38;2;4;5;6m" }); // a theme switch swaps the host proxy in place
219
+ // fails_when: the factory memoizes the escape, so the next frame paints the old theme
220
+ expect(paint.title(TEXT)).toBe("\x1b[38;2;4;5;6mbash\x1b[39m");
221
+ });
222
+ });
223
+
224
+ describe("createContentPaint — region 2", () => {
225
+ it("AC-2: rule uses hr when the theme carries it", () => {
226
+ const paint = createContentPaint(markdownThemeWith(LIGHT_OPEN));
227
+ // fails_when: rule prefers the quoteBorder fallback while hr is available
228
+ expect(paint.rule(TEXT)).toBe("\x1b[38;5;240mbash\x1b[39m");
229
+ });
230
+
231
+ it("AC-2: rule falls back to quoteBorder when the theme lacks hr", () => {
232
+ const paint = createContentPaint({ quoteBorder: (text) => `«quoteBorder»${text}«/quoteBorder»` });
233
+ // fails_when: a missing hr throws or returns the text unstyled instead of using the fallback
234
+ expect(paint.rule(TEXT)).toBe("«quoteBorder»bash«/quoteBorder»");
235
+ });
236
+
237
+ it("AC-2: rule degrades to plain text with zero escapes when both tokens are missing", () => {
238
+ const rule = createContentPaint({}).rule(TEXT);
239
+ // fails_when: a missing token throws, or paint substitutes a literal fallback color
240
+ expect(rule).toBe("bash");
241
+ expect(rule).not.toContain("\x1b");
242
+ });
243
+
244
+ it("AC-2: a throwing hr falls through to quoteBorder", () => {
245
+ const paint = createContentPaint({
246
+ hr: () => {
247
+ throw new Error("Unknown theme color: mdHr");
248
+ },
249
+ quoteBorder: (text) => `«quoteBorder»${text}«/quoteBorder»`,
250
+ });
251
+ // fails_when: a live-proxy throw during a theme switch takes the frame down
252
+ expect(paint.rule(TEXT)).toBe("«quoteBorder»bash«/quoteBorder»");
253
+ });
254
+
255
+ it("AC-1: quote, code and codeBlockBorder use their own token, and degrade to plain when absent", () => {
256
+ const styled = createContentPaint(markdownThemeWith(LIGHT_OPEN));
257
+ expect([styled.quote(TEXT), styled.code(TEXT), styled.codeBlockBorder(TEXT)]).toEqual([
258
+ "\x1b[38;5;242mbash\x1b[39m",
259
+ "\x1b[38;5;71mbash\x1b[39m",
260
+ "\x1b[38;5;239mbash\x1b[39m",
261
+ ]);
262
+ const bare = createContentPaint({});
263
+ // fails_when: a missing md token throws or paints a literal color
264
+ expect([bare.rule(TEXT), bare.quote(TEXT), bare.code(TEXT), bare.codeBlockBorder(TEXT)]).toEqual([
265
+ "bash",
266
+ "bash",
267
+ "bash",
268
+ "bash",
269
+ ]);
270
+ });
271
+
272
+ it("AC-1: a markdown theme whose members all throw degrades every role to plain", () => {
273
+ const boom = (): string => {
274
+ throw new Error("Unknown theme color: mdHr");
275
+ };
276
+ const paint = createContentPaint({ hr: boom, quote: boom, quoteBorder: boom, code: boom, codeBlockBorder: boom });
277
+ // fails_when: an exotic markdown theme crashes paint
278
+ expect([paint.rule(TEXT), paint.quote(TEXT), paint.code(TEXT), paint.codeBlockBorder(TEXT)]).toEqual([
279
+ "bash",
280
+ "bash",
281
+ "bash",
282
+ "bash",
283
+ ]);
284
+ });
285
+
286
+ it("reads the markdown closures live on every call — no cache to invalidate", () => {
287
+ const mdTheme: MarkdownTheme = { hr: (text) => `«hr:1»${text}` };
288
+ const paint = createContentPaint(mdTheme);
289
+ expect(paint.rule(TEXT)).toBe("«hr:1»bash");
290
+ mdTheme.hr = (text) => `«hr:2»${text}`;
291
+ // fails_when: the factory captures the closure once, so a theme switch does not repaint
292
+ expect(paint.rule(TEXT)).toBe("«hr:2»bash");
293
+ });
294
+ });
295
+
296
+ describe("createRowPaint / createContentPaint — the escape boundary", () => {
297
+ it("AC-3: every escape in the output is one the theme itself emitted", () => {
298
+ const rows = createRowPaint(themeWith(LIGHT_OPEN));
299
+ const content = createContentPaint(markdownThemeWith(LIGHT_OPEN));
300
+ const outputs = [
301
+ rows.title(TEXT),
302
+ rows.output(TEXT),
303
+ rows.muted(TEXT),
304
+ rows.accent(TEXT),
305
+ rows.error(TEXT),
306
+ rows.warning(TEXT),
307
+ rows.gutter(TEXT),
308
+ rows.match(TEXT),
309
+ content.rule(TEXT),
310
+ content.quote(TEXT),
311
+ content.code(TEXT),
312
+ content.codeBlockBorder(TEXT),
313
+ ];
314
+ const allowed = new Set([...Object.values(LIGHT_OPEN), FG_CLOSE]);
315
+ const emitted = outputs.flatMap(escapesIn);
316
+ // 12 roles × (token open + fg close): keeps the leak scan below from passing on an empty set
317
+ expect(emitted).toHaveLength(24);
318
+ const leaks = emitted.filter((sequence) => !allowed.has(sequence));
319
+ // fails_when: paint adds an escape of its own — a hardcoded color or a reset
320
+ expect(leaks).toEqual([]);
321
+ });
322
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * paint.ts — the only color source: our role vocabulary mapped onto host theme tokens.
3
+ *
4
+ * Boundary: renderers call role methods and never emit an escape (AGENTS: every escape originates
5
+ * here); both theme inputs are structural, so a plain-object fixture drives either factory with no
6
+ * host import.
7
+ *
8
+ * shape: none — dispatch object does not apply: no discriminator, each role is one token lookup. The
9
+ * two closure factories below declare their own shape.
10
+ *
11
+ * ported from pi-pretty-tui/src/config.ts:71-100 — survives because: "read the theme token, degrade to
12
+ * default when it is absent" is what resolveBaseBackground did; its dead links (`toolBg`,
13
+ * `background`) are dropped — neither token exists at host 1.0.4.
14
+ */
15
+
16
+ import type { HostTheme, MarkdownTheme } from "./types/host.ts";
17
+ import type { ContentPaint, RowPaint } from "./types/paint.ts";
18
+
19
+ /** Host `fg` throws on a token the active theme lacks (`Theme.tokenAnsi`); a row degrades, never dies. */
20
+ function fgOrPlain(theme: HostTheme, key: string, text: string): string {
21
+ try {
22
+ return theme.fg(key, text);
23
+ } catch {
24
+ return text;
25
+ }
26
+ }
27
+
28
+ /** Region-2 closures read the live theme proxy; a missing or throwing token falls to the next link. */
29
+ function tryStyle(fn: ((text: string) => string) | undefined, text: string): string | undefined {
30
+ try {
31
+ return fn?.(text);
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ /** Region 1 — the tool-row factory. Reads `theme` live, so the next call paints the current theme. */
38
+ // shape: closure returning an object literal — trigger #4, eight stateless row roles over the captured theme.
39
+ export function createRowPaint(theme: HostTheme): RowPaint {
40
+ return {
41
+ title: (text) => fgOrPlain(theme, "toolTitle", text),
42
+ output: (text) => fgOrPlain(theme, "toolOutput", text),
43
+ muted: (text) => fgOrPlain(theme, "muted", text),
44
+ accent: (text) => fgOrPlain(theme, "accent", text),
45
+ error: (text) => fgOrPlain(theme, "error", text),
46
+ warning: (text) => fgOrPlain(theme, "warning", text),
47
+ gutter: (text) => fgOrPlain(theme, "muted", text),
48
+ match: (text) => fgOrPlain(theme, "searchMatchText", text),
49
+ diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
50
+ diffRemoved: (text) => fgOrPlain(theme, "toolDiffRemoved", text),
51
+ diffContext: (text) => fgOrPlain(theme, "toolDiffContext", text),
52
+ };
53
+ }
54
+
55
+ /** Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain. */
56
+ // shape: closure returning an object literal — trigger #4, four stateless content roles over the captured markdown theme.
57
+ export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
58
+ return {
59
+ rule: (text) => tryStyle(mdTheme.hr, text) ?? tryStyle(mdTheme.quoteBorder, text) ?? text,
60
+ quote: (text) => tryStyle(mdTheme.quote, text) ?? text,
61
+ code: (text) => tryStyle(mdTheme.code, text) ?? text,
62
+ codeBlockBorder: (text) => tryStyle(mdTheme.codeBlockBorder, text) ?? text,
63
+ };
64
+ }