@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,113 @@
1
+ /**
2
+ * index.ts — the master resolver: the tool-name table, the captured host base, and one registry check
3
+ * per invocation — the pluggable toggle for every tool row.
4
+ *
5
+ * Boundary: the host consults this ONCE per row component construction, keyed on tool name alone, and
6
+ * `next()` exists only at that moment; the base renderers it hands back are captured here and reused for
7
+ * the component's lifetime. Both the table and the collaborators arrive as parameters (T-28 composes
8
+ * them), so a unit test drives the whole toggle with fake specs, a fake registry and a fake base.
9
+ *
10
+ * Replaces pi-pretty-tui's three attachment mechanisms (shadow registration, Proxy shim, orphan
11
+ * factories) with one resolver — none of them ported.
12
+ *
13
+ * shape: closure returning a resolver function — trigger #4: the factory builds and captures one runtime
14
+ * per spec, and the resolver it returns keeps no per-row state of its own.
15
+ */
16
+
17
+ import type {
18
+ HostTheme,
19
+ Logger,
20
+ Registry,
21
+ RenderContext,
22
+ RenderResultOptions,
23
+ RowPaintFactory,
24
+ ToolRendererResolver,
25
+ ToolRenderers,
26
+ ToolResult,
27
+ UiComponent,
28
+ } from "../../core/types.ts";
29
+ import { createRowRenderers } from "./runtime.ts";
30
+ import type { RowSpec } from "./types.ts";
31
+
32
+ /** The host's own shell frame: chrome belongs to pi-zentui, so a row never asks for a custom one. */
33
+ const SHELL = "default";
34
+ const SCOPE = "tool.resolve";
35
+
36
+ /** The resolver's injected collaborators — the row paint factory, plus the keyed diagnostic sink. */
37
+ export interface MasterResolverDeps {
38
+ /** Region-1 row paint, resolved per render call so a theme switch repaints. Required. */
39
+ paint: RowPaintFactory;
40
+ /** Diagnostics for a spec whose runtime cannot draw. Required. */
41
+ log: Logger;
42
+ }
43
+
44
+ /** One table entry: the spec plus its registry key (search's four names share one key — T-20 ruling). */
45
+ export interface SpecEntry {
46
+ /** The spec the runtime renders when enabled. Required. */
47
+ spec: RowSpec;
48
+ /** Registry key governing this name — explicit, because keys are not always "row." + name. Required. */
49
+ key: string;
50
+ }
51
+
52
+ /** The row runtime's built renderers: both slots, always (runtime.ts returns them unconditionally). */
53
+ type RowRuntime = Required<Pick<ToolRenderers, "renderCall" | "renderResult">>;
54
+
55
+ /**
56
+ * Base renderers pattern-match the component they are handed and clear it (host grep.js:65-67), so a
57
+ * delegated call gets a blank slot: a foreign component there is a type mismatch, hence a throw.
58
+ */
59
+ function withoutComponent(ctx: RenderContext): RenderContext {
60
+ // Copy, never mutate: the host reuses its ctx object for its own bookkeeping.
61
+ return { ...ctx, lastComponent: undefined };
62
+ }
63
+
64
+ // shape: closure returning a resolver function — trigger #4: one runtime binding per spec is captured by
65
+ // the factory; the resolver allocates only what a single row construction needs.
66
+ export function createMasterResolver(
67
+ registry: Registry,
68
+ specs: Record<string, SpecEntry>,
69
+ deps: MasterResolverDeps,
70
+ ): ToolRendererResolver {
71
+ // Built ONCE per spec, never per row or per frame: the memo, args gate and decorate protocol belong to
72
+ // the spec. A Map, not the injected object — a bare `{}` lookup would answer for "constructor" and
73
+ // style a row the table never declared.
74
+ const runtimes = new Map<string, { renderers: RowRuntime; key: string }>();
75
+ for (const [name, { spec, key }] of Object.entries(specs)) {
76
+ const { renderCall, renderResult } = createRowRenderers(spec, deps);
77
+ // The host type marks the slots optional because a resolver may omit one to delegate; the runtime
78
+ // supplies both. A slotless runtime cannot draw a row, so that name stays with the host chain.
79
+ if (renderCall === undefined || renderResult === undefined) {
80
+ deps.log.logOnce(`runtime:${name}`, SCOPE, `row runtime for ${name} returned no renderers`);
81
+ continue;
82
+ }
83
+ runtimes.set(name, { renderers: { renderCall, renderResult }, key });
84
+ }
85
+
86
+ return (toolName, next) => {
87
+ const entry = runtimes.get(toolName);
88
+ if (entry === undefined) return next(); // not our table: the host chain answers, verbatim
89
+ const base = next(); // captured here and only here — next() is gone once this resolver returns
90
+ const key = entry.key; // explicit: search's four names share "row.search" (T-20 ruling)
91
+ const runtime = entry.renderers;
92
+
93
+ return {
94
+ renderShell: SHELL,
95
+ renderCall(args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent {
96
+ const baseCall = base?.renderCall;
97
+ if (registry.isEnabled(key) || baseCall === undefined) return runtime.renderCall(args, theme, ctx);
98
+ return baseCall(args, theme, withoutComponent(ctx));
99
+ },
100
+ renderResult(
101
+ result: ToolResult,
102
+ options: RenderResultOptions,
103
+ theme: HostTheme,
104
+ ctx: RenderContext,
105
+ ): UiComponent {
106
+ const baseResult = base?.renderResult;
107
+ if (registry.isEnabled(key) || baseResult === undefined)
108
+ return runtime.renderResult(result, options, theme, ctx);
109
+ return baseResult(result, options, theme, withoutComponent(ctx));
110
+ },
111
+ };
112
+ };
113
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * resolver.test.ts — the master resolver at its seam: createMasterResolver(registry, specs, deps),
3
+ * driven with fake specs, a fake registry and a fake base (the captured next()).
4
+ *
5
+ * Each scenario resolves through the factory and then invokes the returned wrappers the way the host
6
+ * does — through the component's own render(width) — toggling the registry between invocations of the
7
+ * SAME resolved row. Nothing below the seam is imported.
8
+ *
9
+ * invented: the fake registry / base / specs stand in for core/registry, the host chain and T-14..T-20;
10
+ * they satisfy the structural contracts and record every call. The theme is invented because
11
+ * markerPaint ignores it — the host still passes one per render call.
12
+ */
13
+
14
+ import { describe, expect, it } from "vitest";
15
+ import type { TestResultInvocation } from "../../../test/fakes/index.ts";
16
+ import { markerPaint, testRenderContext } from "../../../test/fakes/index.ts";
17
+ import { createLogger } from "../../core/log.ts";
18
+ import type { HostTheme, Registry, RenderContext, ToolRenderers, ToolResult, UiComponent } from "../../core/types.ts";
19
+ import { createMasterResolver } from "./index.ts";
20
+ import type { RowSpec } from "./types.ts";
21
+
22
+ const PAINT = markerPaint();
23
+ const THEME: HostTheme = { fg: (_key, text) => text, bold: (text) => text };
24
+ const DEPS = { paint: markerPaint, log: createLogger() };
25
+
26
+ /** Reads a returned component the way the host does: through its own render(width). */
27
+ function show(component: UiComponent): string {
28
+ return component.render(80).join("\n");
29
+ }
30
+
31
+ function requireRenderers(renderers: ToolRenderers | undefined): ToolRenderers {
32
+ if (renderers === undefined) throw new Error("resolver returned no renderers for a known name");
33
+ return renderers;
34
+ }
35
+
36
+ function showResult(renderers: ToolRenderers, inv: TestResultInvocation): string {
37
+ const renderResult = renderers.renderResult;
38
+ if (renderResult === undefined) throw new Error("resolver returned no renderResult");
39
+ return show(renderResult(inv.result, inv.options, THEME, inv.ctx));
40
+ }
41
+
42
+ function showCall(renderers: ToolRenderers, args: Record<string, unknown>, ctx: RenderContext): string {
43
+ const renderCall = renderers.renderCall;
44
+ if (renderCall === undefined) throw new Error("resolver returned no renderCall");
45
+ return show(renderCall(args, THEME, ctx));
46
+ }
47
+
48
+ /** The registry double: the resolver reads only isEnabled, and a toggle lands on the next read. */
49
+ function fakeRegistry(): { registry: Registry; setEnabled(key: string, enabled: boolean): void } {
50
+ const enabledKeys = new Set<string>();
51
+ return {
52
+ registry: {
53
+ defineModule: () => {},
54
+ isEnabled: (key) => enabledKeys.has(key),
55
+ getSettings: () => ({}),
56
+ setSettings: () => {},
57
+ },
58
+ setEnabled(key, enabled) {
59
+ if (enabled) enabledKeys.add(key);
60
+ else enabledKeys.delete(key);
61
+ },
62
+ };
63
+ }
64
+
65
+ /** A component the way a host renderer returns one: lines in, no text protocol. */
66
+ function plain(text: string): UiComponent {
67
+ return {
68
+ render: () => [text],
69
+ invalidate: () => {},
70
+ };
71
+ }
72
+
73
+ /** What the captured next() returns, plus the ctx each slot invocation was handed. */
74
+ interface FakeBase {
75
+ renderers: ToolRenderers;
76
+ calls: { call: RenderContext[]; result: RenderContext[] };
77
+ }
78
+
79
+ /**
80
+ * invented: the base throws on a foreign component the way the host's own renderers do — they clear the
81
+ * component they are handed (host grep.js:65-67), so a non-blank slot is a type mismatch, not a redraw.
82
+ */
83
+ function fakeBase(): FakeBase {
84
+ const call: RenderContext[] = [];
85
+ const result: RenderContext[] = [];
86
+ const rejectForeign = (ctx: RenderContext): void => {
87
+ if (ctx.lastComponent !== undefined) throw new Error("base received a foreign component");
88
+ };
89
+ return {
90
+ calls: { call, result },
91
+ renderers: {
92
+ renderCall(_args, _theme, ctx) {
93
+ rejectForeign(ctx);
94
+ call.push(ctx);
95
+ return plain("base-call");
96
+ },
97
+ renderResult(_result, _options, _theme, ctx) {
98
+ rejectForeign(ctx);
99
+ result.push(ctx);
100
+ return plain("base-result");
101
+ },
102
+ },
103
+ };
104
+ }
105
+
106
+ interface FakeModel {
107
+ text: string;
108
+ }
109
+
110
+ /** A spec labelled so the rendered output names the spec the resolver actually picked. */
111
+ function fakeSpec(label: string): RowSpec<FakeModel> {
112
+ return {
113
+ buildCall: () => ({ title: `call:${label}` }),
114
+ build: (result) => ({ text: engineText(result) }),
115
+ project: (m, _view, paint) => paint.output(`${label}(${m.text})`),
116
+ };
117
+ }
118
+
119
+ function engineText(result: ToolResult): string {
120
+ return (result.content ?? [])
121
+ .filter((block) => block.type === "text")
122
+ .map((block) => block.text ?? "")
123
+ .join(" ");
124
+ }
125
+
126
+ const CONTENT = [{ type: "text", text: "engine" }];
127
+
128
+ describe("createMasterResolver — names outside the table", () => {
129
+ it("AC-1: an unknown name returns exactly what next() returned, with next() called once", () => {
130
+ const { registry } = fakeRegistry();
131
+ const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
132
+ const base = fakeBase().renderers;
133
+ let nextCalls = 0;
134
+
135
+ const resolved = resolver("not-a-row", () => {
136
+ nextCalls += 1;
137
+ return base;
138
+ });
139
+
140
+ // fails_when: an unknown name gets our wrappers (or undefined) instead of the host chain
141
+ expect(resolved).toBe(base);
142
+ expect(nextCalls).toBe(1);
143
+ });
144
+
145
+ it("AC-1: an exhausted chain stays undefined", () => {
146
+ const { registry } = fakeRegistry();
147
+ const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
148
+
149
+ expect(resolver("not-a-row", () => undefined)).toBeUndefined();
150
+ });
151
+ });
152
+
153
+ describe("createMasterResolver — known names", () => {
154
+ it("a shared registry key governs several names (search's four, T-20 ruling)", () => {
155
+ const { registry, setEnabled } = fakeRegistry();
156
+ const resolver = createMasterResolver(
157
+ registry,
158
+ {
159
+ find: { spec: fakeSpec("find"), key: "row.search" },
160
+ grep: { spec: fakeSpec("grep"), key: "row.search" },
161
+ },
162
+ DEPS,
163
+ );
164
+ const base = fakeBase();
165
+ setEnabled("row.search", true);
166
+ const grepWrapper = requireRenderers(resolver("grep", () => base.renderers));
167
+ const row = testRenderContext();
168
+ expect(showResult(grepWrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("grep(engine)"));
169
+
170
+ // disabling the SHARED key flips every name governed by it — not just the one resolved first
171
+ setEnabled("row.search", false);
172
+ const findWrapper = requireRenderers(resolver("find", () => base.renderers));
173
+ // fails_when: a per-name derived key ("row.find") would read false and the row would stay styled
174
+ expect(showResult(findWrapper, row.result({ content: CONTENT }))).toBe("base-result");
175
+ });
176
+
177
+ it("AC-2: an enabled module renders the row through its own spec, over one captured base", () => {
178
+ const { registry, setEnabled } = fakeRegistry();
179
+ setEnabled("row.ls", true);
180
+ const resolver = createMasterResolver(
181
+ registry,
182
+ { bash: { spec: fakeSpec("bash"), key: "row.bash" }, ls: { spec: fakeSpec("ls"), key: "row.ls" } },
183
+ DEPS,
184
+ );
185
+ const base = fakeBase();
186
+ let nextCalls = 0;
187
+ const wrapper = requireRenderers(
188
+ resolver("ls", () => {
189
+ nextCalls += 1;
190
+ return base.renderers;
191
+ }),
192
+ );
193
+ const row = testRenderContext();
194
+
195
+ // fails_when: the enabled row bypasses its spec (falls through to the host base, or to another name's spec)
196
+ expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("ls(engine)"));
197
+ expect(showCall(wrapper, { command: "ls" }, row.derive())).toBe(PAINT.title("call:ls"));
198
+ expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("ls(engine)"));
199
+ // fails_when: the host chain is consulted per invocation instead of captured once at resolution
200
+ expect(nextCalls).toBe(1);
201
+ expect(wrapper.renderShell).toBe("default");
202
+ });
203
+
204
+ it("AC-3: disabling the module hands the very next invocation of an existing row to the base", () => {
205
+ const { registry, setEnabled } = fakeRegistry();
206
+ setEnabled("row.bash", true);
207
+ const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
208
+ const base = fakeBase();
209
+ const wrapper = requireRenderers(resolver("bash", () => base.renderers));
210
+ const row = testRenderContext();
211
+
212
+ expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("bash(engine)"));
213
+
214
+ // registry.ts: a write lands on the next read and pushes no redraw, so the toggle shows up here
215
+ setEnabled("row.bash", false);
216
+
217
+ // fails_when: a disabled module keeps styling the row (the choice was frozen at construction)
218
+ expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe("base-result");
219
+ expect(showCall(wrapper, { command: "bash" }, row.derive())).toBe("base-call");
220
+ });
221
+
222
+ it("AC-4: a delegated call receives a blank component slot, never our component", () => {
223
+ const { registry } = fakeRegistry();
224
+ const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
225
+ const base = fakeBase();
226
+ const wrapper = requireRenderers(resolver("bash", () => base.renderers));
227
+ const row = testRenderContext();
228
+ const hostComponent = plain("host row");
229
+ row.recordLastComponent("call", hostComponent);
230
+ row.recordLastComponent("result", hostComponent);
231
+ const inv = row.result({ content: CONTENT });
232
+ expect(inv.ctx.lastComponent).toBe(hostComponent);
233
+
234
+ // fails_when: the base clears our component, throws, and the row degrades for the row's lifetime
235
+ expect(showResult(wrapper, inv)).toBe("base-result");
236
+ expect(showCall(wrapper, { command: "bash" }, row.derive())).toBe("base-call");
237
+ expect(base.calls.result[0]?.lastComponent).toBeUndefined();
238
+ expect(base.calls.call[0]?.lastComponent).toBeUndefined();
239
+ // the host's own ctx object is left intact for its bookkeeping
240
+ expect(inv.ctx.lastComponent).toBe(hostComponent);
241
+ });
242
+
243
+ it("AC-5: the registry is read per invocation, so a row resolved while disabled can come back", () => {
244
+ const { registry, setEnabled } = fakeRegistry();
245
+ const resolver = createMasterResolver(registry, { bash: { spec: fakeSpec("bash"), key: "row.bash" } }, DEPS);
246
+ const base = fakeBase();
247
+ const wrapper = requireRenderers(resolver("bash", () => base.renderers));
248
+ const row = testRenderContext();
249
+
250
+ expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe("base-result");
251
+
252
+ setEnabled("row.bash", true);
253
+
254
+ // fails_when: the enabled flag is read once at resolution and the row never returns
255
+ expect(showResult(wrapper, row.result({ content: CONTENT }))).toBe(PAINT.output("bash(engine)"));
256
+ });
257
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * runtime.test.ts — the row runtime at its seam: createRowRenderers + a counting spec + the fake row.
3
+ *
4
+ * Every test drives the public seam only (T-12); the spec is the caller-side double, and rendered
5
+ * output is read back through the returned component's own render(width), exactly as the host does.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import type { TestResultInvocation } from "../../../test/fakes/index.ts";
10
+ import { markerPaint, testRenderContext } from "../../../test/fakes/index.ts";
11
+ import { createLogger } from "../../core/log.ts";
12
+ import { createRowPaint } from "../../core/paint.ts";
13
+ import type { HostTheme } from "../../core/types/host.ts";
14
+ import type { RenderContext, ToolRenderers, ToolResult, UiComponent } from "../../core/types.ts";
15
+ import { createRowRenderers } from "./runtime.ts";
16
+ import type { RowSpec } from "./types.ts";
17
+
18
+ /** The parsed model the test spec derives — the runtime never inspects M, only the spec does. */
19
+ interface TestModel {
20
+ text: string;
21
+ link?: string;
22
+ }
23
+
24
+ // Fixture provenance — invented: a token-naming theme stand-in, so a painted output is asserted as a
25
+ // literal ("toolOutput(engine)") rather than an opaque escape. createRowPaint reads fg live, so two
26
+ // differently-prefixed themes prove a theme switch repainted.
27
+ const THEME_A: HostTheme = { fg: (key, text) => `A:${key}(${text})`, bold: (text) => text };
28
+ const THEME_B: HostTheme = { fg: (key, text) => `B:${key}(${text})`, bold: (text) => text };
29
+
30
+ function textOf(result: ToolResult): string {
31
+ const content = result.content;
32
+ if (!Array.isArray(content)) return "";
33
+ return content.map((block) => (typeof block.text === "string" ? block.text : "")).join(" ");
34
+ }
35
+
36
+ /** Reads a returned component the way the host does: through its own render(width). */
37
+ function show(component: UiComponent): string {
38
+ return component.render(80).join("\n");
39
+ }
40
+
41
+ function showResult(renderers: ToolRenderers, inv: TestResultInvocation, theme: HostTheme): string {
42
+ const renderResult = renderers.renderResult;
43
+ if (renderResult === undefined) throw new Error("runtime returned no renderResult");
44
+ return show(renderResult(inv.result, inv.options, theme, inv.ctx));
45
+ }
46
+
47
+ function showCall(
48
+ renderers: ToolRenderers,
49
+ args: Record<string, unknown>,
50
+ ctx: RenderContext,
51
+ theme: HostTheme,
52
+ ): string {
53
+ const renderCall = renderers.renderCall;
54
+ if (renderCall === undefined) throw new Error("runtime returned no renderCall");
55
+ return show(renderCall(args, theme, ctx));
56
+ }
57
+
58
+ /** A spec that counts build/buildCall invocations and projects "text|expanded" through the paint. */
59
+ function countingSpec(): { spec: RowSpec<TestModel>; counts: { builds: number; calls: number } } {
60
+ const counts = { builds: 0, calls: 0 };
61
+ const spec: RowSpec<TestModel> = {
62
+ buildCall(args) {
63
+ counts.calls += 1;
64
+ return { title: typeof args.command === "string" ? `bash: ${args.command}` : "bash" };
65
+ },
66
+ build(result) {
67
+ counts.builds += 1;
68
+ return { text: textOf(result) };
69
+ },
70
+ project(m, view, paint) {
71
+ return paint.output(`${m.text}|${view.expanded ? "expanded" : "collapsed"}`);
72
+ },
73
+ };
74
+ return { spec, counts };
75
+ }
76
+
77
+ describe("createRowRenderers — result", () => {
78
+ it("AC-1: builds once and re-projects for every expanded/collapsed invocation", () => {
79
+ const { spec, counts } = countingSpec();
80
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
81
+ const row = testRenderContext();
82
+ const content = [{ type: "text", text: "engine" }];
83
+ const outputs: string[] = [];
84
+ for (let i = 0; i < 10; i += 1) {
85
+ const expanded = i % 2 === 0;
86
+ outputs.push(showResult(renderers, row.result({ content, expanded }), THEME_A));
87
+ }
88
+ // fails_when: build count > 1 per content reference (the #21 class returns)
89
+ expect(counts.builds).toBe(1);
90
+ expect(outputs).toEqual([
91
+ "A:toolOutput(engine|expanded)",
92
+ "A:toolOutput(engine|collapsed)",
93
+ "A:toolOutput(engine|expanded)",
94
+ "A:toolOutput(engine|collapsed)",
95
+ "A:toolOutput(engine|expanded)",
96
+ "A:toolOutput(engine|collapsed)",
97
+ "A:toolOutput(engine|expanded)",
98
+ "A:toolOutput(engine|collapsed)",
99
+ "A:toolOutput(engine|expanded)",
100
+ "A:toolOutput(engine|collapsed)",
101
+ ]);
102
+ });
103
+
104
+ it("AC-2: rebuilds for each streaming delta whose content reference changed", () => {
105
+ const { spec, counts } = countingSpec();
106
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
107
+ const row = testRenderContext();
108
+ const outputs = ["one", "two", "three"].map((delta) =>
109
+ showResult(renderers, row.result({ content: [{ type: "text", text: delta }] }), THEME_A),
110
+ );
111
+ // fails_when: a stale partial output serves a later delta
112
+ expect(counts.builds).toBe(3);
113
+ expect(outputs).toEqual([
114
+ "A:toolOutput(one|collapsed)",
115
+ "A:toolOutput(two|collapsed)",
116
+ "A:toolOutput(three|collapsed)",
117
+ ]);
118
+ });
119
+
120
+ it("AC-3: a theme switch repaints from the memoized model", () => {
121
+ const { spec, counts } = countingSpec();
122
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
123
+ const row = testRenderContext();
124
+ const content = [{ type: "text", text: "engine" }];
125
+ const underA = showResult(renderers, row.result({ content }), THEME_A);
126
+ const underB = showResult(renderers, row.result({ content }), THEME_B);
127
+ // fails_when: the memoized frame keeps theme A's colors after the theme changed
128
+ expect([underA, underB]).toEqual(["A:toolOutput(engine|collapsed)", "B:toolOutput(engine|collapsed)"]);
129
+ expect(counts.builds).toBe(1);
130
+ });
131
+
132
+ it("AC-4: a malformed result and a throwing paint degrade to one line and one log", () => {
133
+ const spec: RowSpec<TestModel> = {
134
+ buildCall: () => ({ title: "bash" }),
135
+ build(result) {
136
+ if (result.details !== undefined) throw new Error("bad details shape");
137
+ return { text: textOf(result) };
138
+ },
139
+ project(m, _view, paint) {
140
+ return paint.output(m.text);
141
+ },
142
+ };
143
+ const log = createLogger();
144
+ const paint = () => ({
145
+ ...markerPaint(),
146
+ output: () => {
147
+ throw new Error("paint boom");
148
+ },
149
+ });
150
+ const renderers = createRowRenderers(spec, { paint, log });
151
+ const row = testRenderContext();
152
+
153
+ const malformed = showResult(renderers, row.result({ details: "wrong" }), THEME_A);
154
+ const paintThrows = showResult(
155
+ renderers,
156
+ row.result({ content: [{ type: "text", text: "engine text" }] }),
157
+ THEME_A,
158
+ );
159
+
160
+ // fails_when: an exception reaches the host (the row would permanently degrade)
161
+ expect(malformed).toBe("");
162
+ expect(paintThrows).toBe("engine text");
163
+ expect(log.drain()).toHaveLength(1);
164
+ });
165
+
166
+ it("AC-5: an unrecognized result renders the engine text verbatim, without logging", () => {
167
+ const spec: RowSpec<TestModel> = {
168
+ buildCall: () => ({ title: "bash" }),
169
+ build: () => "unknown",
170
+ project: () => {
171
+ throw new Error("project must not run for an unrecognized result");
172
+ },
173
+ };
174
+ const log = createLogger();
175
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log });
176
+ const row = testRenderContext();
177
+ const out = showResult(renderers, row.result({ content: [{ type: "text", text: "no matches found" }] }), THEME_A);
178
+ // fails_when: a blank row or a crash (either would leave the text unstyled or drop a log line)
179
+ expect(out).toBe("A:muted(no matches found)");
180
+ expect(log.drain()).toHaveLength(0);
181
+ });
182
+
183
+ it("reuses the component the host recorded for the same slot", () => {
184
+ const { spec } = countingSpec();
185
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
186
+ const renderResult = renderers.renderResult;
187
+ if (renderResult === undefined) throw new Error("runtime returned no renderResult");
188
+ const row = testRenderContext();
189
+ const content = [{ type: "text", text: "engine" }];
190
+
191
+ const first = row.result({ content });
192
+ const firstComponent = renderResult(first.result, first.options, THEME_A, first.ctx);
193
+ row.recordLastComponent("result", firstComponent);
194
+ const second = row.result({ content, expanded: true });
195
+ const secondComponent = renderResult(second.result, second.options, THEME_A, second.ctx);
196
+
197
+ // fails_when: a fresh component is allocated every frame instead of mutating the host's
198
+ expect(secondComponent).toBe(firstComponent);
199
+ expect(show(secondComponent)).toBe("A:toolOutput(engine|expanded)");
200
+ });
201
+ });
202
+
203
+ describe("createRowRenderers — call", () => {
204
+ it("AC-6: shows the name only until the host reports argsComplete", () => {
205
+ const { spec } = countingSpec();
206
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
207
+ const row = testRenderContext();
208
+
209
+ const empty = showCall(renderers, {}, row.derive({ argsComplete: false, args: {} }), THEME_A);
210
+ const partial = showCall(
211
+ renderers,
212
+ { command: "rm -rf /" },
213
+ row.derive({ argsComplete: false, args: { command: "rm -rf /" } }),
214
+ THEME_A,
215
+ );
216
+ const complete = showCall(
217
+ renderers,
218
+ { command: "rm -rf /" },
219
+ row.derive({ argsComplete: true, args: { command: "rm -rf /" } }),
220
+ THEME_A,
221
+ );
222
+
223
+ expect(empty).toBe("A:toolTitle(bash)");
224
+ // fails_when: a streamed argument value appears before argsComplete
225
+ expect(partial).toBe("A:toolTitle(bash)");
226
+ expect(complete).toBe("A:toolTitle(bash: rm -rf /)");
227
+ });
228
+ });
229
+
230
+ describe("createRowRenderers — decorate", () => {
231
+ it("applies the resolved partial through ctx.invalidate without rebuilding", async () => {
232
+ let builds = 0;
233
+ const resolvers: Array<(partial: Partial<TestModel>) => void> = [];
234
+ const spec: RowSpec<TestModel> = {
235
+ buildCall: () => ({ title: "bash" }),
236
+ build() {
237
+ builds += 1;
238
+ return { text: "engine", link: "pending" };
239
+ },
240
+ project: (m, _view, paint) => paint.output(m.link ?? "none"),
241
+ decorate: () =>
242
+ new Promise<Partial<TestModel>>((resolve) => {
243
+ resolvers.push(resolve);
244
+ }),
245
+ };
246
+ let invalidations = 0;
247
+ const row = testRenderContext({
248
+ invalidate: () => {
249
+ invalidations += 1;
250
+ },
251
+ });
252
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
253
+ const content = [{ type: "text", text: "engine" }];
254
+
255
+ expect(showResult(renderers, row.result({ content }), THEME_A)).toBe("A:toolOutput(pending)");
256
+ resolvers[0]?.({ link: "resolved" });
257
+ await new Promise((resolve) => setTimeout(resolve, 0));
258
+
259
+ expect(invalidations).toBe(1);
260
+ // fails_when: the decorated model is dropped (the memo was not updated)
261
+ expect(showResult(renderers, row.result({ content }), THEME_A)).toBe("A:toolOutput(resolved)");
262
+ expect(builds).toBe(1);
263
+ });
264
+
265
+ it("drops a decorate that resolves after the content changed", async () => {
266
+ const resolvers: Array<(partial: Partial<TestModel>) => void> = [];
267
+ const spec: RowSpec<TestModel> = {
268
+ buildCall: () => ({ title: "bash" }),
269
+ build: () => ({ text: "engine", link: "pending" }),
270
+ project: (m, _view, paint) => paint.output(m.link ?? "none"),
271
+ decorate: () =>
272
+ new Promise<Partial<TestModel>>((resolve) => {
273
+ resolvers.push(resolve);
274
+ }),
275
+ };
276
+ let invalidations = 0;
277
+ const row = testRenderContext({
278
+ invalidate: () => {
279
+ invalidations += 1;
280
+ },
281
+ });
282
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
283
+ const first = [{ type: "text", text: "one" }];
284
+ const second = [{ type: "text", text: "two" }];
285
+
286
+ showResult(renderers, row.result({ content: first }), THEME_A);
287
+ resolvers[0]?.({ link: "late" });
288
+ showResult(renderers, row.result({ content: second }), THEME_A);
289
+ await new Promise((resolve) => setTimeout(resolve, 0));
290
+
291
+ // fails_when: a stale decorate enriches the new model or redraws the row
292
+ expect(invalidations).toBe(0);
293
+ expect(showResult(renderers, row.result({ content: second }), THEME_A)).toBe("A:toolOutput(pending)");
294
+ });
295
+ });
296
+
297
+ describe("createRowRenderers — model cache bound", () => {
298
+ it("evicts the least-recently-used parsed model past the entry cap", () => {
299
+ const { spec, counts } = countingSpec();
300
+ const renderers = createRowRenderers(spec, { paint: createRowPaint, log: createLogger() });
301
+ const row = testRenderContext();
302
+ const first = [{ type: "text", text: "delta 0" }];
303
+ const rest = Array.from({ length: 512 }, (_, i) => [{ type: "text", text: `delta ${i + 1}` }]);
304
+ for (const content of [first, ...rest]) {
305
+ showResult(renderers, row.result({ content }), THEME_A);
306
+ }
307
+ expect(counts.builds).toBe(513);
308
+
309
+ showResult(renderers, row.result({ content: first }), THEME_A);
310
+ // fails_when: the evicted model is still cached, so the rebuild is skipped (builds stays 513)
311
+ expect(counts.builds).toBe(514);
312
+ });
313
+ });