@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
@@ -15,16 +15,16 @@
15
15
  */
16
16
 
17
17
  import type { CodeTheme, ContentPaint, Logger } from "../../core/types.ts";
18
- import { mapFencedBlocks, panelWidth, renderPanel, toMarkdownRows } from "./code-panel.ts";
18
+ import { blockWidth, mapFencedBlocks, renderPanel, toMarkdownRows } from "./code-panel.ts";
19
+ import { hrefExtension, isLocalRef, standaloneLinkHref } from "./link-paragraph.ts";
19
20
  import type { Surface } from "./types.ts";
20
21
 
21
22
  /** JSON's content is what the reader wants up to here; past it the file stays one click away (P transformer.ts:265). */
22
23
  const MAX_PANE_LINES = 40;
23
24
  /** A fence spelling that still highlights as JSON (P transformer.ts:264). */
24
25
  const JSON_LANGS = new Set(["json", "jsonc", "jsonl"]);
25
- /** A paragraph that is nothing but a link, captured as its href (P transformer.ts:270-275). */
26
- const LINK_ONLY = /^\[[^\]]*\]\(([^)\s]+)\)$/;
27
- const JSON_EXTENSION = /\.(jsonc?|jsonl)$/i;
26
+ /** The extensions the pane claims; `hrefExtension` tests one at a time, so the table spells them out. */
27
+ const JSON_EXTENSIONS = ["json", "jsonc", "jsonl"] as const;
28
28
 
29
29
  /** The collaborators the pane reads through: the file reader and the optional diagnostics sink. */
30
30
  export interface JsonPanelDeps {
@@ -34,12 +34,6 @@ export interface JsonPanelDeps {
34
34
  log?: Pick<Logger, "logOnce">;
35
35
  }
36
36
 
37
- /** A scheme-bearing, protocol-relative or fragment href is not a local file (P transformer.ts:110-112). */
38
- // shape: none — one classification, no branch on a discriminator.
39
- function isLocalRef(href: string): boolean {
40
- return !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith("//") && !href.startsWith("#");
41
- }
42
-
43
37
  /** The pane markdown for one readable JSON file, or undefined when the reader could not produce content. */
44
38
  // shape: none — one guarded call plus a footer choice; no discriminator.
45
39
  function jsonFilePane(
@@ -82,13 +76,11 @@ function mapJsonFileLinks(
82
76
  let changed = false;
83
77
  for (let i = 0; i < lines.length; i += 1) {
84
78
  const line = lines[i] ?? "";
85
- const href = LINK_ONLY.exec(line.trim())?.[1];
86
- const alone =
87
- line.trim() !== "" &&
88
- (i === 0 || (lines[i - 1] ?? "").trim() === "") &&
89
- (i === lines.length - 1 || (lines[i + 1] ?? "").trim() === "");
79
+ // A standalone link paragraph, recognised once in link-paragraph.ts: the same rule the artifact
80
+ // pass applies to a local `.html` link.
81
+ const href = standaloneLinkHref(lines, i);
90
82
  const pane =
91
- href !== undefined && alone && isLocalRef(href) && JSON_EXTENSION.test(href.replace(/[?#].*$/, ""))
83
+ href !== undefined && isLocalRef(href) && JSON_EXTENSIONS.some((extension) => hrefExtension(href, extension))
92
84
  ? jsonFilePane(href, width, paint, codeTheme, deps)
93
85
  : undefined;
94
86
  if (pane === undefined) {
@@ -105,7 +97,7 @@ function mapJsonFileLinks(
105
97
  export function createJsonPanel(codeTheme: CodeTheme, deps: JsonPanelDeps): Surface {
106
98
  return {
107
99
  rewrite(markdown, ctx, paint) {
108
- const width = panelWidth(ctx.availableWidth);
100
+ const width = blockWidth(ctx.availableWidth);
109
101
  const fences = mapFencedBlocks(markdown, (lang, code) =>
110
102
  JSON_LANGS.has(lang) ? toMarkdownRows(renderPanel(code, "json", width, paint, codeTheme)) : undefined,
111
103
  );
@@ -0,0 +1,153 @@
1
+ // ported from pi-pretty-tui/src/features/canvas/transformer.ts:110-112 (isLocalRef) and :270-275
2
+ // (LINK_ONLY) — survives because: "a paragraph that is nothing but a link to a local file names that
3
+ // file" is the recognition the JSON pane and the artifact link pass both need, and it is what lets a
4
+ // large .html page be carded without its bytes ever entering the message. Promoted out of json-panel.ts
5
+ // when the second consumer arrived; carries no registration claim and reads no file.
6
+
7
+ /**
8
+ * link-paragraph.ts — what a link paragraph is, and the pass that cards a local `.html` one.
9
+ *
10
+ * Boundary: markdown is untrusted, so it is walked line by line and never parsed; this module reads no
11
+ * file — `fileExists` is one injected existence check per candidate, and the card itself is drawn by
12
+ * the caller. A missing file keeps the raw link and reports one keyed line through the injected sink; a
13
+ * remote href, a bare fragment, or another extension is left byte-identical. Nothing here throws and
14
+ * nothing here writes stdout or stderr.
15
+ *
16
+ * shape: none — pure recognition helpers plus one line walk over injected collaborators; there is no
17
+ * discriminator to dispatch on and no resource to hold.
18
+ */
19
+
20
+ import { existsSync } from "node:fs";
21
+ import { isAbsolute, resolve } from "node:path";
22
+ import type { Logger } from "../../core/types/log.ts";
23
+
24
+ /** Diagnostics scope for the pass; the artifacts surface owns its keyed lines. */
25
+ const SCOPE = "content.artifacts";
26
+
27
+ /** The one extension this pass cards (owner decision 2026-10-07); `.svg`/`.dot`/`.puml` by path are a follow-up. */
28
+ const HTML_EXTENSION = "html";
29
+
30
+ /** A paragraph that is nothing but a link, captured as its href (P transformer.ts:270-275). */
31
+ const LINK_ONLY = /^\[[^\]]*\]\(([^)\s]+)\)$/;
32
+
33
+ /**
34
+ * The href of a paragraph that is nothing but a markdown link, or `undefined` when the line is
35
+ * anything else.
36
+ *
37
+ * A line qualifies only when the whole line — ignoring leading and trailing whitespace — is one link;
38
+ * a link that shares its line with prose is not a link paragraph.
39
+ */
40
+ // shape: none — one anchored match, no branch on a discriminator.
41
+ export function linkOnlyHref(line: string): string | undefined {
42
+ return LINK_ONLY.exec(line.trim())?.[1];
43
+ }
44
+
45
+ /**
46
+ * True when the href names a local file: it carries no URI scheme (`https:`, `data:`, `mailto:`), is
47
+ * not protocol-relative (`//host/path`), and is not a bare fragment (`#section`) (P transformer.ts:110-112).
48
+ */
49
+ // shape: none — one classification, no branch on a discriminator.
50
+ export function isLocalRef(href: string): boolean {
51
+ return !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith("//") && !href.startsWith("#");
52
+ }
53
+
54
+ /** The part of an href that names a file: a query and a fragment address a location inside that file. */
55
+ // shape: none — one string cut, no state.
56
+ function withoutQuery(href: string): string {
57
+ return href.replace(/[?#].*$/, "");
58
+ }
59
+
60
+ /**
61
+ * True when the file part of the href ends in the named extension. The comparison is case-insensitive
62
+ * and ignores any query or fragment, so `hrefExtension("Guide.HTML#page=3", "html")` is true while
63
+ * `hrefExtension("guide.htm", "html")` is false.
64
+ *
65
+ * `extension` is written without its leading dot — `"html"`, `"jsonl"`.
66
+ */
67
+ // shape: none — one string test, no branch on a discriminator.
68
+ export function hrefExtension(href: string, extension: string): boolean {
69
+ return withoutQuery(href).toLowerCase().endsWith(`.${extension.toLowerCase()}`);
70
+ }
71
+
72
+ /**
73
+ * The href of a standalone link paragraph — a link-only line that its blank neighbours set off — or
74
+ * `undefined` when the line is blank, prose, or part of a paragraph.
75
+ *
76
+ * `lines` is the message split on "\n" and `index` is the line being tested; the first and last lines of
77
+ * a message are standalone when their only existing neighbour is blank.
78
+ */
79
+ // shape: none — one link match plus one neighbour test; no discriminator.
80
+ export function standaloneLinkHref(lines: readonly string[], index: number): string | undefined {
81
+ if ((lines[index] ?? "").trim() === "") return undefined;
82
+ if ((lines[index - 1] ?? "").trim() !== "") return undefined;
83
+ if ((lines[index + 1] ?? "").trim() !== "") return undefined;
84
+ return linkOnlyHref(lines[index] ?? "");
85
+ }
86
+
87
+ /** One standalone link the pass decided to card. */
88
+ export interface HtmlLinkMatch {
89
+ /** The href exactly as the message wrote it — the value a diagnostic names. */
90
+ readonly href: string;
91
+ /** The file the href resolves to, absolute; a relative href resolves against the project directory. */
92
+ readonly path: string;
93
+ }
94
+
95
+ /** Draws one match into transcript markdown; the composition root passes `renderArtifactCard`. */
96
+ export type HtmlLinkCard = (match: HtmlLinkMatch) => string;
97
+
98
+ /** What the pass reads through: where relative hrefs start, how existence is checked, how to report. */
99
+ export interface HtmlLinkPassDeps {
100
+ /** Base for a relative href — the launch directory, the same rule the JSON pane's reader uses. */
101
+ projectDir: string;
102
+ /** Builds the card for one existing file; called once per match, never for a miss. */
103
+ card: HtmlLinkCard;
104
+ /** Existence gate, called at most once per candidate. Defaults to node's `existsSync`. */
105
+ fileExists?: (absPath: string) => boolean;
106
+ /** Keyed diagnostics for a link that names a missing file. Optional; absent is silent. */
107
+ log?: Pick<Logger, "logOnce">;
108
+ }
109
+
110
+ /** The pass's outcome: the rewritten markdown, and whether anything changed. */
111
+ export interface HtmlLinkPass {
112
+ /** The message with every carded link replaced; the message itself when `changed` is false. */
113
+ readonly text: string;
114
+ /** True when at least one line became a card. */
115
+ readonly changed: boolean;
116
+ }
117
+
118
+ /**
119
+ * Rewrites every standalone link paragraph that names an existing local `.html` file into a card, and
120
+ * returns the message itself when none matched.
121
+ *
122
+ * A local `.html` link whose file is missing keeps its raw line and reports one keyed line, because a
123
+ * card whose Open link 404s is worse than the link the model wrote. The render path reads no file: per
124
+ * candidate there is one `fileExists` call, no size cap and no engine call — the server streams the
125
+ * bytes on click.
126
+ *
127
+ * shape: none — one line walk with a single injected card callback, mirroring `mapFencedBlocks`; no
128
+ * dispatch on a value.
129
+ */
130
+ export function mapHtmlLinkCards(markdown: string, deps: HtmlLinkPassDeps): HtmlLinkPass {
131
+ const exists = deps.fileExists ?? existsSync;
132
+ const lines = markdown.split("\n");
133
+ const out: string[] = [];
134
+ let changed = false;
135
+ for (let index = 0; index < lines.length; index += 1) {
136
+ const line = lines[index] ?? "";
137
+ const href = standaloneLinkHref(lines, index);
138
+ if (href === undefined || !isLocalRef(href) || !hrefExtension(href, HTML_EXTENSION)) {
139
+ out.push(line);
140
+ continue;
141
+ }
142
+ const file = withoutQuery(href);
143
+ const path = isAbsolute(file) ? file : resolve(deps.projectDir, file);
144
+ if (!exists(path)) {
145
+ deps.log?.logOnce(`artifact:html-link:${href}`, SCOPE, `could not card ${href} — no file at ${path}`);
146
+ out.push(line);
147
+ continue;
148
+ }
149
+ out.push(deps.card({ href, path }));
150
+ changed = true;
151
+ }
152
+ return { text: changed ? out.join("\n") : markdown, changed };
153
+ }
@@ -17,6 +17,7 @@
17
17
  import { Marked, type Token, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
18
18
  import type { TransformContext } from "../../core/types/host.ts";
19
19
  import type { ContentPaint } from "../../core/types/paint.ts";
20
+ import { blockWidth } from "./code-panel.ts";
20
21
  import type { Surface } from "./types.ts";
21
22
 
22
23
  type InlineToken = { type?: string; text?: string; raw?: string; tokens?: InlineToken[] };
@@ -157,7 +158,9 @@ export const table: Surface = { rewrite };
157
158
  function rewrite(markdown: string, ctx: TransformContext, paint: ContentPaint): string {
158
159
  // Every table block contains a pipe, so a block without one never pays for a lex.
159
160
  if (!markdown.includes("|")) return markdown;
160
- const width = Math.max(24, Math.floor(ctx.availableWidth));
161
+ // The shared reading column (FIX-12): the solver and every cell wrap against this one width, never
162
+ // the raw offered width, so a table's edges line up with the panels and cards above it.
163
+ const width = blockWidth(ctx.availableWidth);
161
164
  // Unchanged tokens are spliced back by their own raw text; only table blocks are rebuilt.
162
165
  const parts: string[] = [];
163
166
  let changed = false;
@@ -6,8 +6,10 @@
6
6
  * runtime.ts — a declarative RowSpec in, host tool renderers out: parse once, project per invocation.
7
7
  *
8
8
  * Boundary: one runtime per row, created by the master resolver. It owns the protocol every spec would
9
- * otherwise re-implement — memo, args gate, never-throw degradation, async decorate. Host shapes arrive
10
- * as parameters (ctx, theme) and stay structural, so a unit test needs no pi runtime.
9
+ * otherwise re-implement — memo, args gate, never-throw degradation, async decorate, and the collapsed
10
+ * summary handoff: the result slot stores its one line in ctx.state and the call slot appends it, so a
11
+ * collapsed row is one line even though the host stacks two components. Host shapes arrive as parameters
12
+ * (ctx, theme) and stay structural, so a unit test needs no pi runtime.
11
13
  *
12
14
  * shape: closure returning an object literal — trigger #4: the per-row memo/token live behind the
13
15
  * renderCall/renderResult methods, with no subclassing.
@@ -32,6 +34,8 @@ const LOG_SCOPE = "tool.row";
32
34
  const MEMO_KEY = "piRender.memo";
33
35
  const TOKEN_KEY = "piRender.token";
34
36
  const DECORATED_KEY = "piRender.decorated";
37
+ /** The collapsed result line the call slot appends, written by renderResult and read by renderCall. */
38
+ const SUMMARY_KEY = "piRender.summary";
35
39
  /** A session reaches 1,400+ rows, so cross-row parsed models are bounded by count and by size. */
36
40
  const CACHE_MAX_ENTRIES = 512;
37
41
  const CACHE_MAX_CHARS = 16_000_000;
@@ -71,6 +75,12 @@ function writeMemo(ctx: RenderContext, src: ResultContent | undefined, len: numb
71
75
  ctx.state[MEMO_KEY] = { src, len, model };
72
76
  }
73
77
 
78
+ /** The collapsed summary the result slot has settled, or undefined when it has not run yet. */
79
+ function readSummary(ctx: RenderContext): string | undefined {
80
+ const value: unknown = ctx.state[SUMMARY_KEY];
81
+ return typeof value === "string" ? value : undefined;
82
+ }
83
+
74
84
  /** The engine's text verbatim, collapsed to one line: the degradation contract's body. */
75
85
  function textOf(result: ToolResult): string {
76
86
  const content = result.content;
@@ -165,6 +175,23 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
165
175
  };
166
176
  }
167
177
 
178
+ /**
179
+ * The collapsed result slot's component: it draws no lines. The host stacks the call slot and the result
180
+ * slot inside one Box, and the Box concatenates each child's lines (pi-tui box.js render), so an empty
181
+ * array adds nothing — the call line above is the whole collapsed row. Stateless, so one shared instance
182
+ * serves every row and every frame.
183
+ *
184
+ * shape: object literal — trigger #7, a fixed two-method UiComponent with no state of its own.
185
+ */
186
+ const EMPTY_COMPONENT: UiComponent = {
187
+ render(_width: number): string[] {
188
+ return [];
189
+ },
190
+ invalidate(): void {
191
+ // The host tracks staleness; this component caches no frame.
192
+ },
193
+ };
194
+
168
195
  function nextToken(ctx: RenderContext): number {
169
196
  const current = ctx.state[TOKEN_KEY];
170
197
  const next = typeof current === "number" ? current + 1 : 1;
@@ -238,7 +265,10 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
238
265
  const paint = deps.paint(theme);
239
266
  // A partial argument set is not a title: buildCall sees {} until the host says argsComplete.
240
267
  const model = spec.buildCall(ctx.argsComplete === true ? args : {}, ctx);
241
- return textFor(ctx, paint.title(model.title));
268
+ const title = paint.title(model.title);
269
+ // Collapsed rows are one line: the summary the result slot settled rides this title line.
270
+ const summary = ctx.expanded === true ? undefined : readSummary(ctx);
271
+ return textFor(ctx, summary === undefined ? title : `${title} · ${summary}`);
242
272
  } catch (err) {
243
273
  logFailure(deps.log, ctx, err);
244
274
  return textFor(ctx, "");
@@ -254,8 +284,17 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
254
284
  try {
255
285
  const paint = deps.paint(theme);
256
286
  const model = modelFor(result, ctx);
257
- const body = model === UNKNOWN ? rawBody(result, paint) : spec.project(model, viewOf(options, ctx), paint);
258
- return textFor(ctx, body);
287
+ // A degraded row keeps today's one raw line and settles no summary for the call slot to adopt.
288
+ if (model === UNKNOWN) return textFor(ctx, rawBody(result, paint));
289
+ const body = spec.project(model, viewOf(options, ctx), paint);
290
+ if (options.expanded) return textFor(ctx, body);
291
+ // Collapsed: hand the one-line summary to the call slot through the shared state bag and draw no
292
+ // line here. Redraw only when it changed — an unchanged summary is not a new frame.
293
+ if (readSummary(ctx) !== body) {
294
+ ctx.state[SUMMARY_KEY] = body;
295
+ ctx.invalidate?.();
296
+ }
297
+ return EMPTY_COMPONENT;
259
298
  } catch (err) {
260
299
  // The paint may itself be the thrower, so the degraded row is built without it.
261
300
  logFailure(deps.log, ctx, err);
@@ -303,23 +303,31 @@ function annotation(value: string | undefined, paint: RowPaint): string {
303
303
  }
304
304
 
305
305
  function project(m: SearchModel, view: RowView, paint: RowPaint): string {
306
- const tail = m.notices.length ? `\n${paint.warning(`[${m.notices.join(". ")}]`)}` : "";
306
+ const notice = m.notices.length ? paint.warning(`[${m.notices.join(". ")}]`) : "";
307
+ // A collapsed row is exactly one line (FIX-09): the notice joins the summary inline, never its own
308
+ // line. An expanded row keeps it as a trailer under the body.
309
+ const trailer = notice === "" ? "" : `\n${notice}`;
307
310
  switch (m.kind) {
308
311
  case "empty":
309
- return `${paint.muted(`0 ${m.noun}`)}${tail}`;
312
+ return inline(`${paint.muted(`0 ${m.noun}`)}`, notice);
310
313
  case "files": {
311
314
  const count = m.files.length;
312
- const label = `${count} file${count === 1 ? "" : "s"}`;
313
- if (!view.expanded) return `${paint.muted(label)}${tail}`;
314
- return `${paint.muted(label)}\n${renderFiles(m.files, paint)}${tail}`;
315
+ const label = `${paint.muted(`${count} file${count === 1 ? "" : "s"}`)}`;
316
+ if (!view.expanded) return inline(label, notice);
317
+ return `${label}\n${renderFiles(m.files, paint)}${trailer}`;
315
318
  }
316
319
  case "matches": {
317
320
  const fileCount = m.blocks.length;
318
- const label = `${m.matchCount} match${m.matchCount === 1 ? "" : "es"} in ${fileCount} file${
319
- fileCount === 1 ? "" : "s"
320
- }`;
321
- if (!view.expanded) return `${paint.muted(label)}${tail}`;
322
- return `${paint.muted(label)}\n${renderBlocks(m.blocks, m.highlight, paint)}${tail}`;
321
+ const label = `${paint.muted(
322
+ `${m.matchCount} match${m.matchCount === 1 ? "" : "es"} in ${fileCount} file${fileCount === 1 ? "" : "s"}`,
323
+ )}`;
324
+ if (!view.expanded) return inline(label, notice);
325
+ return `${label}\n${renderBlocks(m.blocks, m.highlight, paint)}${trailer}`;
323
326
  }
324
327
  }
325
328
  }
329
+
330
+ /** Joins a notice onto a collapsed summary with a separator, so the row stays one line. */
331
+ function inline(label: string, notice: string): string {
332
+ return notice === "" ? label : `${label} · ${notice}`;
333
+ }
@@ -38,7 +38,12 @@ export interface RowSpec<M = unknown> {
38
38
  buildCall(args: Record<string, unknown>, ctx: RenderContext): CallModel;
39
39
  /** Parses the result once per result, memoized in ctx.state. Required; returns "unknown" when unrecognized. */
40
40
  build(result: ToolResult, ctx: RenderContext): M | "unknown";
41
- /** Draws the collapsed-line body; re-runs on every frame, so it stays allocation-light. Required. */
41
+ /**
42
+ * Draws the row body for the current view. While `view.expanded` is false it MUST return exactly
43
+ * one line (notices joined inline with " · ") — the runtime hands that line to the call slot so a
44
+ * collapsed invocation occupies one transcript line; expanded, it returns the summary header plus
45
+ * the body. Re-runs on every frame, so it stays allocation-light. Required.
46
+ */
42
47
  project(m: M, view: RowView, paint: RowPaint): string;
43
48
  /** Enriches M, e.g. with a resolved link target, then asks the host to redraw. Optional. */
44
49
  decorate?(m: M, ctx: RenderContext): Promise<Partial<M>>;
@@ -1,150 +0,0 @@
1
- /**
2
- * canvas through its seam — installCommands(pi, deps) on the recording host API, its handler driven
3
- * with a host-shaped context and the REAL registry (the command's only channel to the package).
4
- *
5
- * invented: the settings documents and the content.artifacts descriptor below are hand-built to the
6
- * SA §3 literal (version + modules map) keyed on SA §6's artifacts module — T-25/T-26 own the real
7
- * descriptor and T-07 the on-disk file, so there is no recorded payload to reuse; the documents are
8
- * what drives the enabled/disabled state this command reports.
9
- * invented: the context double and the view recorder stand in for the host and for the browser T-28
10
- * injects — the command's own seam is exactly these two collaborators.
11
- */
12
-
13
- import { readFileSync } from "node:fs";
14
- import { describe, expect, test } from "vitest";
15
- import type { RecordedApiCall, RecordedCommandRegistration } from "../../test/fakes/index.ts";
16
- import { recordingExtensionApi } from "../../test/fakes/index.ts";
17
- import { createRegistry } from "../core/registry.ts";
18
- import type { CommandContext, Logger, ModuleDescriptor, SettingsDoc } from "../core/types.ts";
19
- import { installCommands } from "./canvas.ts";
20
-
21
- const ARTIFACTS: ModuleDescriptor = { key: "content.artifacts", name: "Artifacts", defaultEnabled: true, settings: [] };
22
- const ENABLED: SettingsDoc = { version: 1, modules: { "content.artifacts": { enabled: true } } };
23
- const DISABLED: SettingsDoc = { version: 1, modules: { "content.artifacts": { enabled: false } } };
24
- const NON_TUI = ["rpc", "json", "print"] as const;
25
-
26
- /** The registry's own diagnostics are T-09's concern, not this seam's. */
27
- const SILENT: Logger = { logLine: () => {}, logOnce: () => {}, drain: () => [] };
28
-
29
- /** Every `from "<specifier>"` in a source file. */
30
- const IMPORT_SPECIFIER = /from\s+"([^"]+)"/g;
31
-
32
- interface Harness {
33
- /** Every registration the install made, in order. */
34
- readonly calls: readonly RecordedApiCall[];
35
- /** The command registrations, in order. */
36
- readonly commands: readonly RecordedCommandRegistration[];
37
- /** Every ctx the injected view opened with. */
38
- readonly opened: CommandContext[];
39
- /** Every document the registry saved — a /canvas run must leave this empty (SA §7). */
40
- readonly saved: SettingsDoc[];
41
- }
42
-
43
- function harness(doc: SettingsDoc): Harness {
44
- const pi = recordingExtensionApi();
45
- const opened: CommandContext[] = [];
46
- const saved: SettingsDoc[] = [];
47
- const registry = createRegistry({ load: () => structuredClone(doc), save: (next) => void saved.push(next) }, SILENT);
48
- registry.defineModule(ARTIFACTS);
49
- installCommands(pi, {
50
- registry,
51
- canvasView: async (ctx) => {
52
- // One macrotask, like the real panel open: an implementation that drops the await leaves
53
- // `opened` empty when the test asserts (ASY-1), so the assertion pins the await too.
54
- await new Promise((resolve) => setImmediate(resolve));
55
- opened.push(ctx);
56
- },
57
- });
58
- return { calls: pi.calls, commands: pi.commands, opened, saved };
59
- }
60
-
61
- interface Run {
62
- /** The context the host handed the handler. */
63
- readonly ctx: CommandContext;
64
- /** Every notify message the handler produced, in order. */
65
- readonly notices: readonly string[];
66
- }
67
-
68
- /** Enters where the host enters: the registered handler, with a host-shaped context, awaited. */
69
- async function runCanvas(h: Harness, mode: CommandContext["mode"]): Promise<Run> {
70
- const handler = h.commands[0]?.options.handler;
71
- if (handler === undefined) throw new Error("no canvas command registered");
72
- const notices: string[] = [];
73
- const ctx: CommandContext = {
74
- mode,
75
- ui: {
76
- notify: (message) => {
77
- notices.push(message);
78
- },
79
- },
80
- };
81
- await handler("", ctx);
82
- return { ctx, notices };
83
- }
84
-
85
- describe("canvas command", () => {
86
- // spec: installCommands(pi, deps) -> exactly one registerCommand call, named "canvas", handler callable.
87
- // fails_when: zero, duplicate, or extra commands (a second site, a renamed or missing command).
88
- test("AC-1 installCommands registers exactly one command named canvas", () => {
89
- const h = harness(ENABLED);
90
-
91
- expect(h.commands.map((command) => command.name)).toEqual(["canvas"]);
92
- expect(typeof h.commands[0]?.options.handler).toBe("function");
93
- // The command lane touches its own seam only — no setToolsExpanded, no resolver, no transformer.
94
- expect(h.calls.map((call) => call.method)).toEqual(["registerCommand"]);
95
- });
96
-
97
- // spec: /canvas in a non-TUI host (rpc/json/print) -> one notify carrying the status summary; the
98
- // view never opens and nothing throws.
99
- // fails_when: a non-TUI host crashes, goes silent, or opens a view it cannot draw.
100
- test("AC-2 a non-TUI host gets a notify summary and never the view", async () => {
101
- for (const mode of NON_TUI) {
102
- const h = harness(ENABLED);
103
-
104
- const run = await runCanvas(h, mode);
105
-
106
- expect(run.notices).toEqual([
107
- `canvas: content.artifacts is enabled — the artifact browser needs a TUI host (mode: ${mode})`,
108
- ]);
109
- expect(h.opened).toEqual([]);
110
- }
111
- });
112
-
113
- // spec: the module's imports -> exactly ["../core/types.ts"]; status comes from the registry, not a lane.
114
- // fails_when: the command reaches into a renderer lane for state instead of the registry (SA §1 rule 3).
115
- test("AC-3 the command reaches the package only through core", () => {
116
- const source = readFileSync(new URL("./canvas.ts", import.meta.url), "utf8");
117
-
118
- const specifiers = [...source.matchAll(IMPORT_SPECIFIER)].map((match) => match[1] ?? "");
119
-
120
- expect(specifiers).toEqual(["../core/types.ts"]);
121
- });
122
-
123
- // spec: content.artifacts disabled in the registry + a TUI host -> one notify reporting the disabled
124
- // state, the view never opens, the registry is never written.
125
- // fails_when: a disabled module's command acts anyway, or the report stays silent.
126
- test("AC-4 a disabled content.artifacts reports instead of opening the view", async () => {
127
- const h = harness(DISABLED);
128
-
129
- const run = await runCanvas(h, "tui");
130
-
131
- expect(run.notices).toEqual(["canvas: content.artifacts is disabled — enable it to open the artifact browser"]);
132
- expect(h.opened).toEqual([]);
133
- expect(h.saved).toEqual([]);
134
- });
135
-
136
- // spec: content.artifacts enabled + a TUI host -> the injected view opens once, with the host's own
137
- // ctx, no notify, and no registry write (SA §7: /canvas reads state, never writes it).
138
- // fails_when: the view never opens, opens twice, gets a rebuilt ctx, or the command writes settings —
139
- // the positive control that keeps AC-4 from passing on a command that never acts at all.
140
- test("AC-4 positive control: an enabled TUI host opens the injected view", async () => {
141
- const h = harness(ENABLED);
142
-
143
- const run = await runCanvas(h, "tui");
144
-
145
- expect(h.opened).toHaveLength(1);
146
- expect(h.opened[0]).toBe(run.ctx);
147
- expect(run.notices).toEqual([]);
148
- expect(h.saved).toEqual([]);
149
- });
150
- });
@@ -1,57 +0,0 @@
1
- // ported from pi-pretty-tui/src/features/canvas/index.ts:90-100 — survives because: the predecessor's
2
- // /canvas registerCommand shape — a thin handler that opens the artifact through an injected seam and
3
- // notifies instead of throwing when it cannot — is exactly the command contract pi-render needs. Its
4
- // shortcuts, cmux routing, warmup and cache-dir plumbing stay behind (registration-adjacent chrome).
5
-
6
- /**
7
- * canvas.ts — the package's one command: /canvas opens the artifact browser, or reports its status.
8
- *
9
- * Boundary: `ctx` arrives from the host and is read as core/types/host.ts declares it — nothing here
10
- * narrows or re-declares a host shape. The injected `deps.registry` is the command's ONLY channel to
11
- * the rest of the package (SA §7), and it is read-only: /canvas reports module state and never writes
12
- * it, so a disabled module's command cannot act anyway.
13
- *
14
- * Why `canvasView` is injected: `index.ts` (T-28) is the only file that composes lanes, so the real
15
- * browser arrives as a parameter; this module never imports a renderer and its tests drive a fake view.
16
- *
17
- * shape: none — trigger #1 dispatch object does not apply: one command, two guards (host mode, module
18
- * enabled) resolved in sequence, no per-kind handler table.
19
- */
20
-
21
- import type { CommandContext, ExtensionApi, Registry } from "../core/types.ts";
22
-
23
- /**
24
- * The content lane's artifacts module key (SA §6). A literal, not an import: `commands/` may not
25
- * reach into a renderer lane for its constant (SA §1 rule 3).
26
- */
27
- const ARTIFACTS_KEY = "content.artifacts";
28
-
29
- /** What /canvas needs from its callers: module state to report, and the view to open. */
30
- export interface CommandsDeps {
31
- /** Registry the command reads, e.g. isEnabled("content.artifacts"). Required; never written here. */
32
- readonly registry: Registry;
33
- /** Opens the artifact browser. Required; index.ts injects the real view, tests a fake one. */
34
- readonly canvasView: (ctx: CommandContext) => Promise<void>;
35
- }
36
-
37
- /** shape: none — one interpolated line; a status table would restate the same registry read. */
38
- function summarize(mode: CommandContext["mode"], enabled: boolean): string {
39
- if (!enabled) return "canvas: content.artifacts is disabled — enable it to open the artifact browser";
40
- return `canvas: content.artifacts is enabled — the artifact browser needs a TUI host (mode: ${mode})`;
41
- }
42
-
43
- /** shape: none — one guarded registration; the handler below is this module's only command. */
44
- export function installCommands(pi: ExtensionApi, deps: CommandsDeps): void {
45
- pi.registerCommand("canvas", {
46
- description: "canvas: open the artifact browser, or report its status",
47
- handler: async (_args, ctx) => {
48
- const enabled = deps.registry.isEnabled(ARTIFACTS_KEY);
49
- // A non-TUI host cannot draw the view and a disabled module has nothing to open: both report.
50
- if (ctx.mode !== "tui" || !enabled) {
51
- ctx.ui.notify(summarize(ctx.mode, enabled));
52
- return;
53
- }
54
- await deps.canvasView(ctx);
55
- },
56
- });
57
- }