@zenodinh/pi-render 0.1.0 → 0.1.3

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 (40) hide show
  1. package/README.md +20 -6
  2. package/index.ts +29 -33
  3. package/package.json +6 -2
  4. package/src/core/paint.ts +17 -2
  5. package/src/core/types/host.ts +15 -18
  6. package/src/core/types.ts +0 -1
  7. package/src/renderers/content/artifacts/cards.ts +13 -6
  8. package/src/renderers/content/code-panel.ts +53 -31
  9. package/src/renderers/content/index.ts +70 -21
  10. package/src/renderers/content/json-panel.ts +9 -17
  11. package/src/renderers/content/link-paragraph.ts +153 -0
  12. package/src/renderers/content/table.ts +4 -1
  13. package/src/renderers/tool/runtime.ts +44 -5
  14. package/src/renderers/tool/specs/search.ts +18 -10
  15. package/src/renderers/tool/types.ts +6 -1
  16. package/src/commands/canvas.test.ts +0 -150
  17. package/src/commands/canvas.ts +0 -57
  18. package/src/core/code-theme.test.ts +0 -266
  19. package/src/core/log.test.ts +0 -127
  20. package/src/core/paint.test.ts +0 -322
  21. package/src/core/registry.test.ts +0 -307
  22. package/src/core/settings.test.ts +0 -183
  23. package/src/renderers/content/artifacts/artifacts.test.ts +0 -199
  24. package/src/renderers/content/artifacts/cards.test.ts +0 -216
  25. package/src/renderers/content/artifacts/engines-extra.test.ts +0 -556
  26. package/src/renderers/content/artifacts/local-binary.test.ts +0 -207
  27. package/src/renderers/content/image-card.test.ts +0 -170
  28. package/src/renderers/content/panels.test.ts +0 -188
  29. package/src/renderers/content/table.test.ts +0 -209
  30. package/src/renderers/content/transformer.test.ts +0 -254
  31. package/src/renderers/tool/resolver.test.ts +0 -257
  32. package/src/renderers/tool/runtime.test.ts +0 -313
  33. package/src/renderers/tool/specs/bash.test.ts +0 -110
  34. package/src/renderers/tool/specs/codemode.test.ts +0 -212
  35. package/src/renderers/tool/specs/edit.test.ts +0 -260
  36. package/src/renderers/tool/specs/ls.test.ts +0 -173
  37. package/src/renderers/tool/specs/read.test.ts +0 -340
  38. package/src/renderers/tool/specs/search.test.ts +0 -197
  39. package/src/renderers/tool/specs/write.test.ts +0 -145
  40. package/themes/themes.test.ts +0 -251
@@ -1,145 +0,0 @@
1
- // spec: specs/write.ts RowSpec — buildCall(args, ctx) -> { title }; build(result, ctx) -> model;
2
- // project(model, view, paint) -> the row string. The collapsed summary derives from the args
3
- // (path + content size); the host result carries only a one-line confirmation, so it is not trusted
4
- // for counts. Cases: written row, empty content, trailing newline, error row, args gate, paint-only.
5
-
6
- import { readFileSync } from "node:fs";
7
- import { describe, expect, test } from "vitest";
8
- import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
9
- import type { CallModel, RenderContext, RowPaint, RowView, ToolResult } from "../../../core/types.ts";
10
- import { writeRow } from "./write.ts";
11
-
12
- const { spec } = writeRow;
13
-
14
- // recorded-from: pi host write tool_result, 2026-10-06 (T-18 host facts, pi 1.0.4) — details are
15
- // undefined and content is the one-line confirmation. It carries no counts.
16
- function writeResult(path: string): ToolResult {
17
- return { content: [{ type: "text", text: `Successfully wrote to ${path}` }] };
18
- }
19
-
20
- // invented: host write error text; the predecessor recorded no error capture for this row.
21
- const ERROR_RESULT: ToolResult = {
22
- content: [{ type: "text", text: "ENOENT: no such file or directory, open '/repo/src/app.ts'" }],
23
- };
24
-
25
- const PATH = "/repo/src/app.ts";
26
- // invented: arithmetic fixture — "line one"(8) + "\n"(1) + "line two"(8) = 17 bytes over 2 lines, so
27
- // the pinned counts are literals the code under test cannot recompute to agree with itself.
28
- const CONTENT = "line one\nline two";
29
-
30
- // invented: identity paint so AC-1/AC-2 goldens are readable plain text; AC-5 drives the marker fake.
31
- const PLAIN: RowPaint = {
32
- title: (text) => text,
33
- output: (text) => text,
34
- muted: (text) => text,
35
- accent: (text) => text,
36
- error: (text) => text,
37
- warning: (text) => text,
38
- gutter: (text) => text,
39
- match: (text) => text,
40
- diffAdded: (text) => text,
41
- diffRemoved: (text) => text,
42
- diffContext: (text) => text,
43
- };
44
-
45
- const COLLAPSED: RowView = { expanded: false, isError: false, isPartial: false };
46
- const EXPANDED: RowView = { expanded: true, isError: false, isPartial: false };
47
-
48
- function row(args: Record<string, unknown>, overrides: Partial<RenderContext> = {}): RenderContext {
49
- return testRenderContext({ cwd: "/repo", args }).derive(overrides);
50
- }
51
-
52
- /** Narrows build()'s "unknown" away so a spec regression surfaces as a named failure, not a type error. */
53
- function written(args: Record<string, unknown>, overrides: Partial<RenderContext> = {}) {
54
- const model = spec.build(writeResult(PATH), row(args, overrides));
55
- if (model === "unknown") throw new Error("write build returned unknown for a written row");
56
- return model;
57
- }
58
-
59
- describe("write spec — args gate (AC-4)", () => {
60
- test("incomplete args show the tool name only", () => {
61
- const ctx = row({}, { argsComplete: false });
62
- const call: CallModel = spec.buildCall({}, ctx);
63
- expect(call.title).toBe("write");
64
- expect(call.title).not.toContain("src/");
65
- });
66
-
67
- test("a path streamed before argsComplete is not guessed onto the call line", () => {
68
- const call = spec.buildCall({ path: PATH }, row({ path: PATH }, { argsComplete: false }));
69
- expect(call.title).toBe("write");
70
- });
71
-
72
- test("complete args put the shortened path on the call line", () => {
73
- const call = spec.buildCall({ path: PATH }, row({ path: PATH }));
74
- expect(call.title).toBe("write: src/app.ts");
75
- });
76
- });
77
-
78
- describe("write spec — golden row (AC-1)", () => {
79
- test("collapsed line shows path and args-derived counts", () => {
80
- const out = spec.project(written({ path: PATH, content: CONTENT }), COLLAPSED, PLAIN);
81
- expect(out).toBe("✎ src/app.ts (2 lines · 17 bytes)");
82
- expect(out.split("\n")).toHaveLength(1);
83
- });
84
-
85
- test("expanded body shows the written content", () => {
86
- const out = spec.project(written({ path: PATH, content: CONTENT }), EXPANDED, PLAIN);
87
- expect(out).toBe("line one\nline two");
88
- });
89
-
90
- test("empty content is 0 lines · 0 bytes", () => {
91
- const out = spec.project(written({ path: "/repo/src/empty.ts", content: "" }), COLLAPSED, PLAIN);
92
- expect(out).toBe("✎ src/empty.ts (0 lines · 0 bytes)");
93
- });
94
-
95
- test("a trailing newline is not an extra line", () => {
96
- const out = spec.project(written({ path: "/repo/src/one.ts", content: "a\n" }), COLLAPSED, PLAIN);
97
- expect(out).toBe("✎ src/one.ts (1 lines · 2 bytes)");
98
- });
99
- });
100
-
101
- describe("write spec — error row (AC-2)", () => {
102
- test("collapsed line carries the error summary", () => {
103
- const model = spec.build(ERROR_RESULT, row({ path: PATH }, { isError: true }));
104
- if (model === "unknown") throw new Error("write build returned unknown for an error row");
105
- const out = spec.project(model, COLLAPSED, PLAIN);
106
- expect(out).toBe("✎ src/app.ts ✗ ENOENT: no such file or directory, open '/repo/src/app.ts'");
107
- });
108
-
109
- test("expanded body carries the error text", () => {
110
- const model = spec.build(ERROR_RESULT, row({ path: PATH }, { isError: true }));
111
- if (model === "unknown") throw new Error("write build returned unknown for an error row");
112
- const out = spec.project(model, EXPANDED, PLAIN);
113
- expect(out).toBe("ENOENT: no such file or directory, open '/repo/src/app.ts'");
114
- });
115
- });
116
-
117
- describe("write spec — paint-only color (AC-5)", () => {
118
- test("collapsed and expanded emit only marker escapes", () => {
119
- const paint = markerPaint();
120
- const model = written({ path: PATH, content: CONTENT });
121
- const collapsed = spec.project(model, COLLAPSED, paint);
122
- const expanded = spec.project(model, EXPANDED, paint);
123
- expect(() => assertNoRealEscapes(collapsed)).not.toThrow();
124
- expect(() => assertNoRealEscapes(expanded)).not.toThrow();
125
- // Role use is pinned: a bare interpolation (no paint call) would satisfy the escape check alone.
126
- expect(collapsed).toContain(paint.title("src/app.ts"));
127
- expect(collapsed).toContain(paint.muted("(2 lines · 17 bytes)"));
128
- expect(expanded).toContain(paint.output("line one"));
129
- });
130
- });
131
-
132
- describe("write spec — no execution ported (AC-3)", () => {
133
- test("the source contains no execute, tool-registration or model-facing text", () => {
134
- const source = readFileSync(new URL("./write.ts", import.meta.url), "utf8");
135
- for (const pattern of [/\bexecute\b/, /registerTool/, /as unknown as/, /\bprompt\b/, /\bToolDefinition\b/]) {
136
- expect(source).not.toMatch(pattern);
137
- }
138
- expect(writeRow.descriptor).toEqual({
139
- key: "row.write",
140
- name: "Write row",
141
- defaultEnabled: true,
142
- settings: [],
143
- });
144
- });
145
- });
@@ -1,251 +0,0 @@
1
- // Fixture provenance — schema: recorded-from @earendil-works/pi-coding-agent@1.0.4
2
- // (dist/modes/interactive/theme/theme-schema.json, located with findPackageJSON), read 2026-10-06.
3
- // Fixture provenance — palettes: recorded-from @shikijs/themes 4.4.3 (dracula-soft, one-dark-pro), read 2026-10-06.
4
-
5
- // shape: type predicates + plain functions — trigger #7 (fixed-shape record) narrowed from `unknown`;
6
- // a class, enum, namespace, or registry would be ceremony over two data files.
7
- import { readFileSync } from "node:fs";
8
- import { findPackageJSON } from "node:module";
9
- import { dirname, join } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { describe, expect, test } from "vitest";
12
-
13
- const HOST_PACKAGE = "@earendil-works/pi-coding-agent";
14
- const SCHEMA_ENTRY = "dist/modes/interactive/theme/theme-schema.json";
15
- const THEME_FILE_NAMES = ["dracula-soft.json", "one-dark.json"] as const;
16
- const HEX_COLOR = /^#[0-9a-f]{6}$/;
17
- const COLOR_FUNCTION = /^ok(lch|hsl)\(/i;
18
- const EXPORT_TOKENS = ["pageBg", "cardBg", "infoBg"];
19
- const THINKING_TOKENS = [
20
- "thinkingOff",
21
- "thinkingMinimal",
22
- "thinkingLow",
23
- "thinkingMedium",
24
- "thinkingHigh",
25
- "thinkingXhigh",
26
- "thinkingMax",
27
- ];
28
- const TEXT_BACKGROUND_TOKENS = [
29
- "selectedBg",
30
- "userMessageBg",
31
- "customMessageBg",
32
- "toolPendingBg",
33
- "toolSuccessBg",
34
- "toolErrorBg",
35
- "searchMatchBg",
36
- ];
37
-
38
- function readJson(path: string): unknown {
39
- const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
40
- return parsed;
41
- }
42
-
43
- function isRecord(value: unknown): value is Record<string, unknown> {
44
- return typeof value === "object" && value !== null && !Array.isArray(value);
45
- }
46
-
47
- function asRecord(value: unknown, label: string): Record<string, unknown> {
48
- if (!isRecord(value)) throw new Error(`${label}: expected an object, got ${typeof value}`);
49
- return value;
50
- }
51
-
52
- function asStringArray(value: unknown, label: string): string[] {
53
- if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
54
- throw new Error(`${label}: expected a string array`);
55
- }
56
- return value;
57
- }
58
-
59
- function asString(value: unknown, label: string): string {
60
- if (typeof value !== "string") throw new Error(`${label}: expected a string, got ${typeof value}`);
61
- return value;
62
- }
63
-
64
- const hostPackageJson = findPackageJSON(HOST_PACKAGE, import.meta.url);
65
- if (hostPackageJson === undefined) {
66
- throw new Error(`${HOST_PACKAGE} is not resolvable — the pinned host devDependency supplies theme-schema.json`);
67
- }
68
- const schema = asRecord(readJson(join(dirname(hostPackageJson), SCHEMA_ENTRY)), "theme-schema.json");
69
- const schemaProperties = asRecord(schema.properties, "theme-schema.properties");
70
- const colorValueSpec = asRecord(
71
- asRecord(schema.$defs, "theme-schema.$defs").colorValue,
72
- "theme-schema.$defs.colorValue",
73
- );
74
- const colorTokens = Object.keys(
75
- asRecord(asRecord(schemaProperties.colors, "theme-schema.properties.colors").properties, "colors.properties"),
76
- );
77
- const exportTokens = Object.keys(
78
- asRecord(asRecord(schemaProperties.export, "theme-schema.properties.export").properties, "export.properties"),
79
- );
80
- const requiredTopLevel = asStringArray(schema.required, "theme-schema.required");
81
- const appearances = asStringArray(
82
- asRecord(schemaProperties.appearance, "theme-schema.properties.appearance").enum,
83
- "theme-schema.properties.appearance.enum",
84
- );
85
- const colorValueBranches = (Array.isArray(colorValueSpec.oneOf) ? colorValueSpec.oneOf : []).map((branch, index) =>
86
- asRecord(branch, `theme-schema.$defs.colorValue.oneOf[${index}]`),
87
- );
88
-
89
- /** Spec: theme-schema.json $defs.colorValue — a string (#RGB/#RRGGBB, OKLCH, OKHSL, var name, ""), or a 0-255 index. */
90
- function isColorValue(value: unknown): boolean {
91
- return colorValueBranches.some((branch) => {
92
- if (branch.type === "string") return typeof value === "string";
93
- if (branch.type !== "integer" || typeof value !== "number" || !Number.isInteger(value)) return false;
94
- const minimum = typeof branch.minimum === "number" ? branch.minimum : Number.NEGATIVE_INFINITY;
95
- const maximum = typeof branch.maximum === "number" ? branch.maximum : Number.POSITIVE_INFINITY;
96
- return value >= minimum && value <= maximum;
97
- });
98
- }
99
-
100
- interface Theme {
101
- readonly label: string;
102
- readonly doc: Record<string, unknown>;
103
- readonly colors: Record<string, unknown>;
104
- readonly vars: Record<string, unknown>;
105
- }
106
-
107
- function readTheme(file: string): Theme {
108
- const doc = asRecord(readJson(fileURLToPath(new URL(file, import.meta.url))), file);
109
- return {
110
- label: file,
111
- doc,
112
- colors: asRecord(doc.colors, `${file}.colors`),
113
- vars: doc.vars === undefined ? {} : asRecord(doc.vars, `${file}.vars`),
114
- };
115
- }
116
-
117
- /** Host literal grammar (theme.js resolveVarRefs): "#…", "", a palette index, okhsl()/oklch() — anything else names a var. */
118
- function resolveColor(theme: Theme, value: unknown): unknown {
119
- if (typeof value === "string" && value !== "" && !value.startsWith("#") && !COLOR_FUNCTION.test(value)) {
120
- return theme.vars[value];
121
- }
122
- return value;
123
- }
124
-
125
- function hexOf(theme: Theme, value: unknown, label: string): string {
126
- const resolved = resolveColor(theme, value);
127
- if (typeof resolved !== "string" || !HEX_COLOR.test(resolved)) {
128
- throw new Error(`${theme.label}.${label}: expected a #rrggbb color, got ${JSON.stringify(resolved)}`);
129
- }
130
- return resolved;
131
- }
132
-
133
- function exportColor(theme: Theme, key: string): unknown {
134
- return asRecord(theme.doc.export, `${theme.label}.export`)[key];
135
- }
136
-
137
- function relativeLuminance(hex: string): number {
138
- const rgb = Number.parseInt(hex.slice(1), 16);
139
- const channel = (byte: number): number => {
140
- const ratio = byte / 255;
141
- return ratio <= 0.04045 ? ratio / 12.92 : ((ratio + 0.055) / 1.055) ** 2.4;
142
- };
143
- return 0.2126 * channel((rgb >> 16) & 0xff) + 0.7152 * channel((rgb >> 8) & 0xff) + 0.0722 * channel(rgb & 0xff);
144
- }
145
-
146
- function contrastRatio(a: string, b: string): number {
147
- const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
148
- const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
149
- return (lighter + 0.05) / (darker + 0.05);
150
- }
151
-
152
- describe("host theme definitions", () => {
153
- for (const theme of THEME_FILE_NAMES.map(readTheme)) {
154
- test(`${theme.label} validates against the host theme schema`, () => {
155
- // spec: schema requires {name, colors}; name matches ^[^/]+$; appearance ∈ {dark, light}; no key outside
156
- // properties; colors carries the schema's own 56 token names, each a $defs.colorValue.
157
- // fails_when: the host would reject the file — a missing/extra/mistyped token, an unknown key, a bad name or appearance.
158
- for (const key of requiredTopLevel) {
159
- expect(Object.hasOwn(theme.doc, key), `missing top-level "${key}"`).toBe(true);
160
- }
161
- const schemaTopLevel = new Set(Object.keys(schemaProperties));
162
- expect(Object.keys(theme.doc).filter((key) => !schemaTopLevel.has(key))).toEqual([]);
163
- expect(asString(theme.doc.name, "name")).not.toContain("/");
164
- expect(appearances).toContain(theme.doc.appearance);
165
-
166
- expect(colorTokens).toHaveLength(56);
167
- expect(Object.keys(theme.colors).sort()).toEqual([...colorTokens].sort());
168
- for (const token of colorTokens) {
169
- expect(isColorValue(theme.colors[token]), `${theme.label}.colors.${token}`).toBe(true);
170
- }
171
- for (const [name, value] of Object.entries(theme.vars)) {
172
- expect(isColorValue(value), `${theme.label}.vars.${name}`).toBe(true);
173
- }
174
- const exportSection = Object.entries(asRecord(theme.doc.export, `${theme.label}.export`));
175
- expect(exportSection.map(([key]) => key).filter((key) => !exportTokens.includes(key))).toEqual([]);
176
- for (const [key, value] of exportSection) {
177
- expect(isColorValue(value), `${theme.label}.export.${key}`).toBe(true);
178
- }
179
- });
180
-
181
- test(`${theme.label} resolves every color through its vars`, () => {
182
- // spec: each value is a form the host parses (#rrggbb / "" / 0-255 / okhsl|oklch) or the name of a declared var;
183
- // the host throws "Variable reference not found" for an undeclared name.
184
- // fails_when: a token or export color names a var that does not exist.
185
- for (const token of colorTokens) {
186
- hexOf(theme, theme.colors[token], token);
187
- }
188
- for (const token of EXPORT_TOKENS) {
189
- hexOf(theme, exportColor(theme, token), `export.${token}`);
190
- }
191
- const referenced = new Set<string>();
192
- const collect = (value: unknown): void => {
193
- if (typeof value === "string" && value !== "" && !value.startsWith("#") && !COLOR_FUNCTION.test(value)) {
194
- referenced.add(value);
195
- expect(Object.hasOwn(theme.vars, value), `undeclared var "${value}"`).toBe(true);
196
- }
197
- };
198
- for (const token of colorTokens) collect(theme.colors[token]);
199
- for (const token of EXPORT_TOKENS) collect(exportColor(theme, token));
200
- expect(Object.keys(theme.vars).filter((name) => !referenced.has(name))).toEqual([]);
201
- });
202
-
203
- test(`${theme.label} is dark with readable text on every background`, () => {
204
- // spec: export.pageBg is the page the theme assumes; text > muted > dim in contrast against it, and text stays
205
- // legible on every *Bg token the renderer draws content over.
206
- // fails_when: a light page background, or a foreground that vanishes against the page or a message/tool background.
207
- const page = hexOf(theme, exportColor(theme, "pageBg"), "export.pageBg");
208
- expect(relativeLuminance(page)).toBeLessThan(0.1);
209
-
210
- const text = hexOf(theme, theme.colors.text, "text");
211
- const onPage = contrastRatio(text, page);
212
- const onMuted = contrastRatio(hexOf(theme, theme.colors.muted, "muted"), page);
213
- const onDim = contrastRatio(hexOf(theme, theme.colors.dim, "dim"), page);
214
- expect(onPage).toBeGreaterThanOrEqual(4.5);
215
- expect(onMuted).toBeGreaterThanOrEqual(3);
216
- expect(onDim).toBeGreaterThanOrEqual(1.5);
217
- expect(onPage).toBeGreaterThan(onMuted);
218
- expect(onMuted).toBeGreaterThan(onDim);
219
-
220
- for (const token of TEXT_BACKGROUND_TOKENS) {
221
- const background = hexOf(theme, theme.colors[token], token);
222
- expect(contrastRatio(text, background), `text on ${token}`).toBeGreaterThanOrEqual(3);
223
- }
224
- });
225
-
226
- test(`${theme.label} steps its thinking tiers monotonically`, () => {
227
- // spec: the renderer maps thinking level to a border color; off → max must read brighter tier by tier.
228
- // fails_when: a lower tier renders brighter than a higher one, two tiers collapse to one color, or the ladder
229
- // fails to span the range.
230
- const tierColors = THINKING_TOKENS.map((token) => hexOf(theme, theme.colors[token], token));
231
- expect(new Set(tierColors).size).toBe(THINKING_TOKENS.length);
232
- const luminances = tierColors.map(relativeLuminance);
233
- const steps = luminances.slice(1).map((luminance, index) => luminance - (luminances[index] ?? 0));
234
- expect(steps.every((step) => step > 0)).toBe(true);
235
- expect(Math.max(...luminances) - Math.min(...luminances)).toBeGreaterThan(0.2);
236
- });
237
- }
238
-
239
- test("the two themes are distinct dark palettes", () => {
240
- // spec: dracula-soft and one-dark are independent community palettes — no shared token value, different page background.
241
- // fails_when: one file is a copy of the other, so the theme list ships the same look twice.
242
- const first = readTheme("dracula-soft.json");
243
- const second = readTheme("one-dark.json");
244
- const shared = colorTokens.filter(
245
- (token) => hexOf(first, first.colors[token], token) === hexOf(second, second.colors[token], token),
246
- );
247
- expect(shared).toEqual([]);
248
- expect(first.doc.appearance).toBe(second.doc.appearance);
249
- expect(exportColor(first, "pageBg")).not.toBe(exportColor(second, "pageBg"));
250
- });
251
- });