@zenodinh/pi-render 0.1.2 → 0.1.4

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.
package/README.md CHANGED
@@ -4,10 +4,13 @@ A render-only presentation layer for **pi**: styled tool rows and a richer answe
4
4
  canvas, with zero tool-name registrations. pi-render draws; the host and its peers
5
5
  execute.
6
6
 
7
- It registers one row renderer resolver and one markdown transformer, draws eight
8
- built-in tool rows collapsed to a single line, and renders tables, code/JSON
9
- panels, diagram fences, and images inside assistant answers. It adds no tool
10
- schema, no prompt text, and no execution wrapper.
7
+ It registers one row renderer resolver and one markdown transformer — and no
8
+ commands at all — draws eight built-in tool rows collapsed to a single line, and
9
+ renders tables, code/JSON panels, diagram fences, and images inside assistant
10
+ answers. It adds no tool schema, no prompt text, and no execution wrapper.
11
+
12
+ It is on by default: install it and styled output appears, with nothing to
13
+ configure and nothing to invoke.
11
14
 
12
15
  Replaces [pi-pretty-tui](https://github.com/zenodinh/pi-pretty-tui), fixing
13
16
  [#21](https://github.com/zenodinh/pi-pretty-tui/issues/21) (expand freeze) and
@@ -52,8 +55,19 @@ Every tool row renders collapsed to one line, with errors summarized on that lin
52
55
  `ctrl+o` — or a click — expands the row; expanding a call again reuses the cached
53
56
  render, so there is no re-parse and no freeze.
54
57
 
55
- `/canvas` reports the canvas surfaces and the artifact cache, and opens the
56
- artifact browser in a TUI host. In a non-TUI host it prints a summary instead.
58
+ Nothing to invoke: pi-render registers no commands, so the `/canvas` status
59
+ command is gone. The artifact surfaces it reported on are unchanged — the engines
60
+ and the cache still warm at boot, and diagram fences still render as cards.
61
+
62
+ ### Turning a module off
63
+
64
+ Every module is on by default. A module can still be disabled through the one
65
+ settings document, `~/.pi/agent/pi-render/settings.json`; only that module's
66
+ surface falls back to the host's own rendering, and the others are unaffected:
67
+
68
+ ```json
69
+ { "version": 1, "modules": { "content.table": { "enabled": false } } }
70
+ ```
57
71
 
58
72
  ## What you get, and what changes
59
73
 
package/index.ts CHANGED
@@ -1,29 +1,35 @@
1
1
  /**
2
2
  * index.ts — the composition root: the package's only file that calls pi.*.
3
3
  *
4
- * Why one file: every lane ends in a plain value (a resolver, a transformer, a command), so boot is a
4
+ * Why one file: every lane ends in a plain value (a resolver, a transformer, a surface), so boot is a
5
5
  * table of contents — build the registry, compose the two tables, register once per seam, and warm the
6
6
  * engines the synchronous seams call. Each lane installs in its own try/catch: a broken lane costs
7
- * itself, never the session (SA §2). The predecessor's ordering lore does not port — with zero
8
- * registrations there is no inter-extension race left to sequence around.
7
+ * itself, never the session (SA §2). One ordering constraint survives the predecessor's ordering lore:
8
+ * both live seams register synchronously, before the first await, because the host evaluates
9
+ * getMarkdownTransformers() when it constructs a restored message and stores that list on the component
10
+ * — a transformer registered after our engine warm-up is invisible to every message restored before it
11
+ * (issue #13).
12
+ *
13
+ * The command surface is empty on purpose (Req 2): the whole host surface is one tool resolver, one
14
+ * markdown transformer and one `session_start` hook — nothing for the user to invoke.
9
15
  *
10
16
  * Boundary: the host arrives as the parameter. Nothing here parses markdown, draws a row or decides
11
- * module state; the one adapter this file owns is the artifacts fence pass, which no lane supplies.
17
+ * module state; the one adapter this file owns is the artifacts pass — the fence walk and the local
18
+ * `.html` link walk — which no lane supplies.
12
19
  *
13
20
  * shape: none — wiring only: `piRender` is the runtime unit, and every install below is straight-line.
14
21
  */
15
22
 
16
23
  import { readFileSync } from "node:fs";
17
- import { isAbsolute, resolve } from "node:path";
24
+ import { basename, isAbsolute, resolve } from "node:path";
18
25
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
19
- import { installCommands } from "./src/commands/canvas.ts";
20
26
  import { CODE_THEME_MODULE, createCodeTheme, createShikiEngine } from "./src/core/code-theme.ts";
21
27
  import { createLogger } from "./src/core/log.ts";
22
28
  import { createContentPaint, createRowPaint } from "./src/core/paint.ts";
23
29
  import { createRegistry } from "./src/core/registry.ts";
24
30
  import { createSettingsStore } from "./src/core/settings.ts";
25
31
  import type {
26
- CommandContext,
32
+ CodeTheme,
27
33
  ContentPaint,
28
34
  ExtensionApi,
29
35
  Logger,
@@ -37,7 +43,6 @@ import {
37
43
  type DiagramForm,
38
44
  type EngineStatus,
39
45
  renderDiagram,
40
- status,
41
46
  warmup,
42
47
  } from "./src/renderers/content/artifacts/engines.ts";
43
48
  import { type ArtifactServer, createArtifactServer } from "./src/renderers/content/artifacts/server.ts";
@@ -45,6 +50,7 @@ import { createCodePanel, mapFencedBlocks, panelWidth } from "./src/renderers/co
45
50
  import { createImageCardSurface } from "./src/renderers/content/image-card.ts";
46
51
  import { type ContentSurfaces, createContentTransformer } from "./src/renderers/content/index.ts";
47
52
  import { createJsonPanel } from "./src/renderers/content/json-panel.ts";
53
+ import { mapHtmlLinkCards } from "./src/renderers/content/link-paragraph.ts";
48
54
  import { table } from "./src/renderers/content/table.ts";
49
55
  import { createMasterResolver, type SpecEntry } from "./src/renderers/tool/index.ts";
50
56
  import { bashRow } from "./src/renderers/tool/specs/bash.ts";
@@ -107,7 +113,7 @@ function uniqueModules(descriptors: readonly ModuleDescriptor[]): ModuleDescript
107
113
  }
108
114
 
109
115
  // ---------------------------------------------------------------------------
110
- // The artifacts fence pass — the one adapter this file owns (SA §6)
116
+ // The artifacts pass — the one adapter this file owns (SA §6)
111
117
  // ---------------------------------------------------------------------------
112
118
 
113
119
  /** Raster width the cache key is computed from; pi-tui's own image width (P transformer.ts:68). */
@@ -122,12 +128,25 @@ function isArtifactForm(lang: string): lang is DiagramForm {
122
128
  // ported from pi-pretty-tui/src/features/canvas/transformer.ts:134-226 — survives because: fence → card
123
129
  // with a raw fence on failure is the whole artifacts surface; its settings gate and inline pixels do not.
124
130
  /**
125
- * shape: closure returning an object literal — trigger #4, one stateless Surface over the captured cache,
126
- * server and log.
131
+ * The artifacts lane's late-bound collaborators. The surface must exist when the transformer registers
132
+ * (before piRender's first await, see `piRender`), but the cache and server can only be built in the
133
+ * awaited artifacts lane, so the surface reads this holder per render: while a slot is still empty the
134
+ * whole pass is a no-op and every fence stays byte-identical.
135
+ */
136
+ interface ArtifactsHolder {
137
+ cache?: ArtifactCache;
138
+ server?: ArtifactServer;
139
+ }
140
+
141
+ /**
142
+ * shape: closure returning an object literal — trigger #4, one stateless Surface over the late-bound
143
+ * holder and the log.
127
144
  */
128
- function createArtifactsSurface(cache: ArtifactCache, server: ArtifactServer, log: Logger): Surface {
145
+ function createArtifactsSurface(holder: ArtifactsHolder, log: Logger): Surface {
129
146
  /** One fence to one card, or undefined so the raw fence stays byte-identical. */
130
147
  const card = (form: DiagramForm, source: string, width: number, paint: ContentPaint): string | undefined => {
148
+ const { cache, server } = holder;
149
+ if (cache === undefined || server === undefined) return undefined;
131
150
  try {
132
151
  const key = renderCacheKey(form, source, ARTIFACT_MODE, ARTIFACT_WIDTH_CELLS);
133
152
  const result = renderDiagram(form, source, {
@@ -152,11 +171,26 @@ function createArtifactsSurface(cache: ArtifactCache, server: ArtifactServer, lo
152
171
 
153
172
  return {
154
173
  rewrite(markdown, ctx, paint) {
174
+ const { cache, server } = holder;
175
+ // Cold holder: the artifacts lane has not built its cache or bound its server yet. Return the input
176
+ // by reference so the fence walk never runs and every fence stays byte-identical.
177
+ if (cache === undefined || server === undefined) return markdown;
155
178
  const width = panelWidth(ctx.availableWidth);
156
- const { text, changed } = mapFencedBlocks(markdown, (lang, code) =>
179
+ const fenced = mapFencedBlocks(markdown, (lang, code) =>
157
180
  isArtifactForm(lang) ? card(lang, code, width, paint) : undefined,
158
181
  );
159
- return changed ? text : markdown;
182
+ // The fence walk may have replaced a whole block, so the link walk runs on its output and the
183
+ // message itself is what it falls back to when neither pass matched.
184
+ const fencedText = fenced.changed ? fenced.text : markdown;
185
+ // A local `.html` named only by a link needs no inline bytes: the pass gates on existence and
186
+ // the server streams the file on click, so the render path never reads it.
187
+ const links = mapHtmlLinkCards(fencedText, {
188
+ projectDir: PROJECT_DIR,
189
+ log,
190
+ card: ({ path }) =>
191
+ renderArtifactCard(server, { typeLabel: "html", title: basename(path), path, width }, paint),
192
+ });
193
+ return links.changed ? links.text : fencedText;
160
194
  },
161
195
  };
162
196
  }
@@ -171,6 +205,43 @@ function readLinkedFile(href: string): string | undefined {
171
205
  }
172
206
  }
173
207
 
208
+ // ---------------------------------------------------------------------------
209
+ // The lazy code theme — the seam between the synchronous transformer and the awaited engine
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /**
213
+ * The host evaluates getMarkdownTransformers() when it constructs a restored message and stores the
214
+ * result on the component, so the transformer must register before piRender's first await — but
215
+ * createShikiEngine() is itself an await and the panel seam is synchronous. This holder bridges the
216
+ * two: the surfaces read `theme` on every render, and the content warm-up lane installs the real theme
217
+ * once shiki resolves. A cold render passes the source through unchanged, which is exactly the code
218
+ * panel's own degrade contract (bodyLines uses highlightSync's output as-is), so no async ever reaches
219
+ * the render path and nothing here throws.
220
+ */
221
+ interface LazyCodeTheme {
222
+ /** The surfaces' theme: the real CodeTheme once warm, a plain passthrough while cold. */
223
+ readonly theme: CodeTheme;
224
+ /** Installs the warmed theme; the content warm-up lane calls this exactly once. */
225
+ warm(theme: CodeTheme): void;
226
+ }
227
+
228
+ // shape: closure returning an object literal — trigger #4, one mutable engine slot behind two delegates.
229
+ function createLazyCodeTheme(): LazyCodeTheme {
230
+ let engine: CodeTheme | undefined;
231
+ return {
232
+ theme: {
233
+ // Cold: plain source lines are createCodeTheme's own no-engine fallback; this never rejects.
234
+ highlight: (code, lang) =>
235
+ engine === undefined ? Promise.resolve(code.split("\n")) : engine.highlight(code, lang),
236
+ // Cold: the exact string the panel's catch branch produces, so a cold panel reads as a plain one.
237
+ highlightSync: (code, lang) => (engine === undefined ? code : engine.highlightSync(code, lang)),
238
+ },
239
+ warm(next) {
240
+ engine = next;
241
+ },
242
+ };
243
+ }
244
+
174
245
  // ---------------------------------------------------------------------------
175
246
  // Lanes — one install per seam, each settled on its own
176
247
  // ---------------------------------------------------------------------------
@@ -181,26 +252,31 @@ function installRows(pi: ExtensionApi, registry: Registry, log: Logger): void {
181
252
  }
182
253
 
183
254
  /**
184
- * The artifacts lane: warm the engines the synchronous fence pass calls, bind the one server, then hand
185
- * the pass back. A failed bind degrades every card to a file:// link, so only a throw costs this slot.
255
+ * The artifacts lane: warm the engines the synchronous fence pass calls, bind the one server, then fill
256
+ * the holder the already-registered surface reads. A failed bind degrades every card to a file:// link,
257
+ * and a throw costs only this slot — the transformer is registered before this lane runs.
186
258
  */
187
- async function installArtifacts(log: Logger, deps: BootDeps): Promise<Surface> {
259
+ async function installArtifacts(log: Logger, deps: BootDeps, holder: ArtifactsHolder): Promise<void> {
188
260
  await (deps.warmArtifactEngines ?? warmup)(log);
189
261
  const server = (deps.createArtifactServer ?? ((logger: Logger) => createArtifactServer({ log: logger })))(log);
190
262
  await server.start();
191
- return createArtifactsSurface(createArtifactCache({ cacheDir: deps.cacheDir, log }), server, log);
263
+ holder.cache = createArtifactCache({ cacheDir: deps.cacheDir, log });
264
+ holder.server = server;
192
265
  }
193
266
 
194
- /** Region 2: code theme, five surfaces, one transformer — the only registerMarkdownTransformer call. */
195
- async function installContent(
267
+ /**
268
+ * Region 2's synchronous half: five surfaces over lazy holders, then the one registerMarkdownTransformer
269
+ * call. It runs before piRender's first await, so the host's restored-message construction sees the
270
+ * transformer (see `piRender`); the shiki theme and the artifact cache/server stay cold until the
271
+ * awaited lanes fill them.
272
+ */
273
+ function registerContentSeam(
196
274
  pi: ExtensionApi,
197
275
  registry: Registry,
198
276
  log: Logger,
199
- artifacts: Surface | undefined,
200
- ): Promise<void> {
201
- // The panel seam is synchronous while every engine start-up is not, so the highlight core is warmed
202
- // here, before this registration can be reached.
203
- const codeTheme = createCodeTheme(await createShikiEngine(log), { registry, log });
277
+ codeTheme: CodeTheme,
278
+ artifacts: ArtifactsHolder,
279
+ ): void {
204
280
  const surfaces: ContentSurfaces = {
205
281
  table,
206
282
  codePanel: createCodePanel(codeTheme),
@@ -209,7 +285,7 @@ async function installContent(
209
285
  projectDir: PROJECT_DIR,
210
286
  log: (line) => log.logLine(SCOPE, line),
211
287
  }),
212
- artifacts,
288
+ artifacts: createArtifactsSurface(artifacts, log),
213
289
  };
214
290
  // The host's markdown theme is live per call, so its closures are captured once, here.
215
291
  pi.registerMarkdownTransformer(
@@ -217,18 +293,9 @@ async function installContent(
217
293
  );
218
294
  }
219
295
 
220
- /** The /canvas view: what the artifact engines can draw this session. The browser pane is T-32's. */
221
- function canvasView(ctx: CommandContext): Promise<void> {
222
- const ready = Object.entries(status())
223
- .filter(([, on]) => on)
224
- .map(([id]) => id);
225
- ctx.ui.notify(`canvas: artifact engines ready — ${ready.join(", ") || "none"}`);
226
- return Promise.resolve();
227
- }
228
-
229
- /** Region 9: the one command, which reaches the rest of the package through the registry alone (SA §7). */
230
- function installCanvasCommand(pi: ExtensionApi, registry: Registry): void {
231
- installCommands(pi, { registry, canvasView });
296
+ /** Region 2's awaited half: fill the lazy theme once shiki resolves; renders before then already returned. */
297
+ async function warmCodeTheme(registry: Registry, log: Logger, lazy: LazyCodeTheme): Promise<void> {
298
+ lazy.warm(createCodeTheme(await createShikiEngine(log), { registry, log }));
232
299
  }
233
300
 
234
301
  /** shape: none — one guarded call; a lane reports itself and boot carries on. */
@@ -241,6 +308,18 @@ async function settle<T>(lane: string, log: Logger, install: () => T | Promise<T
241
308
  }
242
309
  }
243
310
 
311
+ /**
312
+ * The synchronous twin of {@link settle} for the two lanes that must register before piRender's first
313
+ * await: a throw is logged and contained exactly as in settle, but nothing yields the event loop.
314
+ */
315
+ function settleSync(lane: string, log: Logger, install: () => void): void {
316
+ try {
317
+ install();
318
+ } catch (error) {
319
+ log.logOnce(`boot:${lane}`, SCOPE, `${lane} lane failed: ${messageOf(error)}`);
320
+ }
321
+ }
322
+
244
323
  /** shape: none — one message extraction for a caught value of unknown type. */
245
324
  function messageOf(error: unknown): string {
246
325
  return error instanceof Error ? error.message : String(error);
@@ -268,11 +347,21 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
268
347
  const registry = createRegistry(createSettingsStore({ path: deps.settingsPath, logger: log }), log);
269
348
  for (const descriptor of MODULES) registry.defineModule(descriptor);
270
349
 
271
- // Lane by lane: the artifacts pass resolves before content, because the transformer dispatches it.
272
- await settle("rows", log, () => installRows(pi, registry, log));
273
- const artifacts = await settle("artifacts", log, () => installArtifacts(log, deps));
274
- await settle("content", log, () => installContent(pi, registry, log, artifacts));
275
- await settle("commands", log, () => installCanvasCommand(pi, registry));
350
+ // Both live seams register synchronously, before piRender's first await. The host evaluates
351
+ // getMarkdownTransformers() when it constructs a restored message (session rejoin builds
352
+ // AssistantMessageComponent while our engines are still warming) and stores that list on the
353
+ // component, so a transformer registered after an await is invisible to every message restored in
354
+ // the window — its tables render host-default for the whole session (issue #13). The rows table is
355
+ // synchronous already; the content seam takes lazy holders for the shiki theme and the artifact
356
+ // cache/server, which the awaited lanes below fill in.
357
+ const codeTheme = createLazyCodeTheme();
358
+ const artifacts: ArtifactsHolder = {};
359
+ settleSync("rows", log, () => installRows(pi, registry, log));
360
+ settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts));
361
+
362
+ // The fallible, environment-bound lanes run next and only fill the holders above.
363
+ await settle("artifacts", log, () => installArtifacts(log, deps, artifacts));
364
+ await settle("content-warm", log, () => warmCodeTheme(registry, log, codeTheme));
276
365
 
277
366
  // Collapse-first reading model (owner decision): every tool row starts on one line.
278
367
  // The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
package/package.json CHANGED
@@ -67,5 +67,5 @@
67
67
  "typescript": "^7.0.2",
68
68
  "vitest": "^5.0.3"
69
69
  },
70
- "version": "0.1.2"
70
+ "version": "0.1.4"
71
71
  }
package/src/core/paint.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * here); both theme inputs are structural, so a plain-object fixture drives either factory with no
6
6
  * host import.
7
7
  *
8
- * shape: none — dispatch object does not apply: no discriminator, each role is one token lookup. The
8
+ * shape: none — dispatch object does not apply: no discriminator, every role is a token lookup. The
9
9
  * two closure factories below declare their own shape.
10
10
  *
11
11
  * ported from pi-pretty-tui/src/config.ts:71-100 — survives because: "read the theme token, degrade to
@@ -25,6 +25,21 @@ function fgOrPlain(theme: HostTheme, key: string, text: string): string {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * The match ink: the theme's `searchMatchText`, or `accent` when that ink is the line's own body ink.
30
+ *
31
+ * The `system` theme resolves both tokens into its `neutral` family (its bundled palettes do the same),
32
+ * so a match painted with `searchMatchText` is invisible inside the line it belongs to. Styling the
33
+ * same text with both tokens compares the two inks directly; a collision — including a `searchMatchText`
34
+ * that is absent or throws, where `fgOrPlain` degrades it to plain — falls to `accent`, which every
35
+ * theme keeps distinct from body text. Never throws: each lookup already ends in plain text.
36
+ */
37
+ function matchOrAccent(theme: HostTheme, text: string): string {
38
+ const own = fgOrPlain(theme, "searchMatchText", text);
39
+ const body = fgOrPlain(theme, "toolOutput", text);
40
+ return own === body ? fgOrPlain(theme, "accent", text) : own;
41
+ }
42
+
28
43
  /** Region-2 closures read the live theme proxy; a missing or throwing token falls to the next link. */
29
44
  function tryStyle(fn: ((text: string) => string) | undefined, text: string): string | undefined {
30
45
  try {
@@ -45,7 +60,7 @@ export function createRowPaint(theme: HostTheme): RowPaint {
45
60
  error: (text) => fgOrPlain(theme, "error", text),
46
61
  warning: (text) => fgOrPlain(theme, "warning", text),
47
62
  gutter: (text) => fgOrPlain(theme, "muted", text),
48
- match: (text) => fgOrPlain(theme, "searchMatchText", text),
63
+ match: (text) => matchOrAccent(theme, text),
49
64
  diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
50
65
  diffRemoved: (text) => fgOrPlain(theme, "toolDiffRemoved", text),
51
66
  diffContext: (text) => fgOrPlain(theme, "toolDiffContext", text),
@@ -130,31 +130,19 @@ export interface TransformContext {
130
130
  availableWidth: number;
131
131
  }
132
132
 
133
- /** The command-handler context slice /canvas reads. Structural host shape — no host import. */
134
- export interface CommandContext {
135
- /** Host output mode. Required: "tui" | "rpc" | "json" | "print". */
136
- mode: "tui" | "rpc" | "json" | "print";
137
- /** Host notification seam for user-facing feedback. Required. */
138
- ui: {
139
- /** Shows one message to the user, e.g. notify("Canvas closed"). Required. */
140
- notify(message: string): void;
141
- };
142
- }
143
-
144
133
  /**
145
134
  * The host surface the extension registers through. Structural host shape: the host arrives as the
146
135
  * composition root's parameter, never as a module import, so this file stays pi-free.
136
+ *
137
+ * The command seam and its handler context are deliberately absent (Req 2): pi-render registers no
138
+ * command, and a declaration it never calls is a claim it does not make. A future settings panel
139
+ * re-declares both here, in one place (PRD Q1).
147
140
  */
148
141
  export interface ExtensionApi {
149
142
  /** Adds one resolver to the tool-render chain. Required; called exactly once, in index.ts. */
150
143
  registerToolRenderer(resolver: ToolRendererResolver): void;
151
144
  /** Adds one markdown rewrite stage. Required; called exactly once, in index.ts. */
152
145
  registerMarkdownTransformer(transformer: (markdown: string, ctx: TransformContext) => string): void;
153
- /** Registers one slash command, e.g. name "canvas". Required; one call per command. */
154
- registerCommand(
155
- name: string,
156
- options: { description?: string; handler: (args: string, ctx: CommandContext) => Promise<void> },
157
- ): void;
158
146
  /** Subscribes to a host event, e.g. "session_start". Required; the per-event ctx carries ui. */
159
147
  on(event: string, handler: (event: unknown, ctx: EventContext) => void): void;
160
148
  }
package/src/core/types.ts CHANGED
@@ -14,7 +14,6 @@ export type { Surface, SurfaceKey } from "../renderers/content/types.ts";
14
14
  export type { CallModel, RowSpec, RowView } from "../renderers/tool/types.ts";
15
15
  export type { CodeTheme, HighlightEngine } from "./types/code-theme.ts";
16
16
  export type {
17
- CommandContext,
18
17
  ExtensionApi,
19
18
  HostTheme,
20
19
  MarkdownTheme,
@@ -18,15 +18,13 @@
18
18
  import { pathToFileURL } from "node:url";
19
19
  import { calculateImageRows, getImageDimensions, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
20
  import type { ContentPaint } from "../../../core/types/paint.ts";
21
+ import { blockWidth } from "../code-panel.ts";
21
22
  import type { ArtifactServer } from "./server.ts";
22
23
 
23
24
  const OPEN_LABEL = "Open (⌘click)";
24
25
  const COPY_LABEL = "Copy path (⌥C)";
25
26
  /** The rendered link occupies brackets around the label. */
26
27
  const OPEN_WIDTH = visibleWidth(OPEN_LABEL) + 2;
27
- /** Cards read best narrow; the offered width is clamped into these bounds. */
28
- const CARD_MIN_WIDTH = 24;
29
- const CARD_MAX_WIDTH = 64;
30
28
  /** pi-tui's default image width; the reference for a raster artifact's transcript row count. */
31
29
  const DEFAULT_WIDTH_CELLS = 60;
32
30
 
@@ -70,9 +68,15 @@ export function artifactTitle(form: string, source: string): string {
70
68
  return form;
71
69
  }
72
70
 
73
- /** shape: none — one box; every line's visible width is arithmetically bounded. */
71
+ /**
72
+ * shape: none — one box; every line's DRAWN width is arithmetically bounded.
73
+ *
74
+ * Drawn, not raw: the host paints a markdown link as its label, so the pad filling the line to the
75
+ * right edge charges OPEN_LABEL's cells. `linkFits` and `titleBudget` still measure the RAW link —
76
+ * that is the width the host's wrap sees when it decides whether the token survives.
77
+ */
74
78
  function renderCard(spec: ArtifactCardSpec & { openUrl: string; pathLabel: string }, paint: ContentPaint): string {
75
- const width = Math.max(CARD_MIN_WIDTH, Math.min(Math.floor(spec.width), CARD_MAX_WIDTH));
79
+ const width = blockWidth(spec.width);
76
80
  const inner = width - 2;
77
81
  const contentWidth = Math.max(OPEN_WIDTH + 2, inner - 2);
78
82
  const label = spec.typeLabel.toLowerCase();
@@ -89,7 +93,10 @@ function renderCard(spec: ArtifactCardSpec & { openUrl: string; pathLabel: strin
89
93
  const titleBudget = contentWidth - (linkFits ? visibleWidth(link) : visibleWidth(OPEN_LABEL)) - 1;
90
94
  const titleFitted = clampWidth(title, Math.max(1, titleBudget));
91
95
  const actionText = linkFits ? link : OPEN_LABEL;
92
- const titlePad = " ".repeat(Math.max(1, contentWidth - visibleWidth(titleFitted) - visibleWidth(actionText)));
96
+ // Charge the DRAWN affordance, not the markdown: the host draws `[label](url)` as its label, so a
97
+ // pad built on the raw link width stopped (raw link − label) cells short of the frame's right edge
98
+ // — the title row was the one ragged line of the box (issue #8).
99
+ const titlePad = " ".repeat(Math.max(1, contentWidth - visibleWidth(titleFitted) - visibleWidth(OPEN_LABEL)));
93
100
  // No escape codes inside the brackets: anything there breaks the link token.
94
101
  const titleLine = `${paint.rule("│")} ${paint.quote(titleFitted)}${titlePad}${actionText} ${paint.rule("│")}`;
95
102
  const copyPad = " ".repeat(Math.max(0, contentWidth - visibleWidth(COPY_LABEL)));
@@ -1,15 +1,17 @@
1
1
  // ported from pi-pretty-tui/src/features/canvas/code-panel.ts — survives because: the bordered panel
2
- // with a language header, the content-hugging width, the ANSI-aware truncation and the sync highlight
3
- // path are the owner's reading surface for a fence; only the color source changed (paint roles plus the
4
- // injected CodeTheme instead of module-level globals and hardcoded escapes).
2
+ // with a language header, the ANSI-aware body wrap and the sync highlight path are the owner's reading
3
+ // surface for a fence (the content-hugging width does not survive: FIX-12 replaced it with the shared
4
+ // reading column); only the color source changed (paint roles plus the injected CodeTheme instead of
5
+ // module-level globals and hardcoded escapes).
5
6
 
6
7
  /**
7
8
  * code-panel.ts — a fenced block becomes a bordered, themed panel; the JSON pane reuses the same frame,
8
- * the same fence scan and the same markdown embedding.
9
+ * the same fence scan, the same block-width clamp (24–140, the owner's revised reading column) and the
10
+ * same markdown embedding.
9
11
  *
10
12
  * Boundary: markdown is untrusted and is scanned line-by-line, never handed to a parser; the CodeTheme
11
13
  * is injected, so no renderer imports shiki. Width math is pi-tui's own ANSI-aware visibleWidth /
12
- * truncateToWidth — the one host utility the panel needs, and what keeps columns aligned when truecolor
14
+ * wrapTextWithAnsi — the host utilities the panel needs, and what keeps columns aligned when truecolor
13
15
  * escapes sit inside the content (CJK included). A failing highlighter degrades to plain source; the
14
16
  * panel never throws.
15
17
  *
@@ -17,30 +19,51 @@
17
19
  * declares its own shape below.
18
20
  */
19
21
 
20
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
22
+ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
21
23
  import type { CodeTheme, ContentPaint } from "../../core/types.ts";
22
24
  import type { Surface } from "./types.ts";
23
25
 
24
- /** The panel is never narrower than this, so a one-word snippet still reads as a block (P code-panel.ts:20). */
25
- const MIN_PANEL_WIDTH = 24;
26
- /** A body row is `│ text │`, so the text budget is width-4 (P code-panel.ts:75). */
27
- const ROW_CHROME = 4;
26
+ /** The floor of the reading column, so a narrow terminal still reads a block as a block. */
27
+ const BLOCK_MIN_WIDTH = 24;
28
+ /** The ceiling of the reading column. Raised 64 → 140 by the owner (2026-10-07): 64 cells were too
29
+ * narrow for a table cell, and 140/190 ≈ 74% keeps the proportion the original reference width set
30
+ * (64/80 ≈ 80%), so a wide terminal or a two-way split still reads as one column. */
31
+ const BLOCK_MAX_WIDTH = 140;
32
+ /** A body row is `│␣␣text␣␣│` — two blank cells each side so the frame never touches the code — so the
33
+ * text budget is width-6 (P code-panel.ts:75). */
34
+ const ROW_CHROME = 6;
28
35
  /** Languages left as raw fences: mermaid is the host's own renderer, and the four diagram forms
29
36
  * belong to the artifact pass that runs AFTER surfaces (SA §6) — consuming them here would
30
37
  * regress every diagram to a plain panel. */
31
38
  const RAW_FENCES = new Set(["mermaid", "plantuml", "svg", "dot", "html"]);
32
39
 
33
- /** The offered transcript width, clamped to the panel minimum (P transformer.ts:319-320). */
34
- // shape: none — one clamp, no branch on a discriminator.
35
- export function panelWidth(availableWidth: number): number {
36
- return Math.max(MIN_PANEL_WIDTH, Math.floor(availableWidth) || 80);
40
+ /**
41
+ * The reading column: the one width every framed block shares (GLOSSARY "reading column"). The offered
42
+ * transcript width is clamped into [BLOCK_MIN_WIDTH, BLOCK_MAX_WIDTH], so at 140 columns and above a
43
+ * block holds 140 cells, and below 140 it takes the FULL offered width — a narrow terminal is never
44
+ * under-filled. Zero or a non-numeric offer falls back to 80 columns.
45
+ *
46
+ * shape: none — one clamp, no branch on a discriminator.
47
+ */
48
+ export function blockWidth(availableWidth: number): number {
49
+ const offered = Math.floor(availableWidth) || 80;
50
+ return Math.min(BLOCK_MAX_WIDTH, Math.max(BLOCK_MIN_WIDTH, offered));
37
51
  }
38
52
 
39
- /** Highlighted body lines, truncated by visible width; a throwing theme degrades to the plain source. */
40
- // shape: none — one guarded call plus a one-line fallback; no discriminator.
41
- function bodyLines(code: string, lang: string, width: number, codeTheme: CodeTheme): string[] {
53
+ /**
54
+ * The composition root's historical name for {@link blockWidth} (index.ts imports it and is outside
55
+ * FIX-12's file set). Same function, same clamp — kept so the root keeps compiling.
56
+ */
57
+ export const panelWidth = blockWidth;
58
+
59
+ /**
60
+ * Highlighted body lines, wrapped to `textWidth` visible cells; a line past the budget continues on the
61
+ * next row (ANSI-aware) rather than being cut. A throwing theme degrades to the plain source.
62
+ *
63
+ * shape: none — one guarded call plus a one-line fallback; no discriminator.
64
+ */
65
+ function bodyLines(code: string, lang: string, textWidth: number, codeTheme: CodeTheme): string[] {
42
66
  const source = code.replace(/\n+$/, "");
43
- const budget = Math.max(8, width - ROW_CHROME);
44
67
  let lines: string[];
45
68
  try {
46
69
  lines = codeTheme.highlightSync(source, lang).split("\n");
@@ -48,11 +71,15 @@ function bodyLines(code: string, lang: string, width: number, codeTheme: CodeThe
48
71
  // A cold core or an unloadable grammar: the panel degrades, the answer render never dies.
49
72
  lines = source.split("\n");
50
73
  }
51
- return lines.map((line) => truncateToWidth(line, budget));
74
+ return lines.flatMap((line) => wrapTextWithAnsi(line, textWidth));
52
75
  }
53
76
 
54
77
  /**
55
- * One framed block: header label, hugged width, padded rows. Shared by the code and JSON panels.
78
+ * One framed block at the shared reading column: header label and body rows, all `blockWidth(width)`
79
+ * cells wide. Each body row is `│␣␣text␣␣pad␣␣│`, i.e. the line begins two cells inside the opening
80
+ * border and its own padding fills the rest of a `panelSize - 6` text budget, so the frame never crowds
81
+ * the code. A line wider than that budget was already wrapped by {@link bodyLines}, so the panel shows
82
+ * the whole line over further rows and pads the last. Shared by the code and JSON panels.
56
83
  *
57
84
  * shape: none — one box; every row's visible width is arithmetically bounded to the panel width.
58
85
  */
@@ -63,22 +90,17 @@ export function renderPanel(
63
90
  paint: ContentPaint,
64
91
  codeTheme: CodeTheme,
65
92
  ): string[] {
66
- const maxWidth = Math.max(MIN_PANEL_WIDTH, Math.floor(width));
93
+ const panelSize = blockWidth(width);
94
+ const textWidth = Math.max(8, panelSize - ROW_CHROME);
67
95
  const label = lang.toLowerCase() || "code";
68
- const body = bodyLines(code, lang, maxWidth, codeTheme);
69
- const contentWidth = Math.max(1, ...body.map((line) => visibleWidth(line)));
70
- const prefix = `─ ${label} `;
71
- const panelSize = Math.min(
72
- maxWidth,
73
- Math.max(MIN_PANEL_WIDTH, Math.max(contentWidth + ROW_CHROME, visibleWidth(prefix) + ROW_CHROME)),
74
- );
96
+ const body = bodyLines(code, lang, textWidth, codeTheme);
75
97
  const inner = panelSize - 2;
76
- const textWidth = Math.max(8, panelSize - ROW_CHROME);
98
+ const prefix = `─ ${label} `;
77
99
  const dashes = Math.max(0, inner - visibleWidth(prefix));
78
100
  const rows: string[] = [paint.codeBlockBorder(`╭${prefix}${"─".repeat(dashes)}╮`)];
79
101
  for (const line of body) {
80
102
  const pad = " ".repeat(Math.max(0, textWidth - visibleWidth(line)));
81
- rows.push(`${paint.codeBlockBorder("│")} ${line}${pad} ${paint.codeBlockBorder("│")}`);
103
+ rows.push(`${paint.codeBlockBorder("│")} ${line}${pad} ${paint.codeBlockBorder("│")}`);
82
104
  }
83
105
  rows.push(paint.codeBlockBorder(`╰${"─".repeat(inner)}╯`));
84
106
  return rows;
@@ -151,7 +173,7 @@ export function mapFencedBlocks(
151
173
  export function createCodePanel(codeTheme: CodeTheme): Surface {
152
174
  return {
153
175
  rewrite(markdown, ctx, paint) {
154
- const width = panelWidth(ctx.availableWidth);
176
+ const width = blockWidth(ctx.availableWidth);
155
177
  const { text, changed } = mapFencedBlocks(markdown, (lang, code) =>
156
178
  lang && !RAW_FENCES.has(lang) ? toMarkdownRows(renderPanel(code, lang, width, paint, codeTheme)) : undefined,
157
179
  );
@@ -1,10 +1,13 @@
1
1
  /**
2
- * index.ts — the markdown transformer: final-assistant gate, per-surface registry check, dispatch,
3
- * identity return.
2
+ * index.ts — the markdown transformer: final-assistant gate, per-surface registry check, per-surface
3
+ * isolation, dispatch, identity return, and one keyed line for every skip.
4
4
  *
5
5
  * Boundary: the host calls this on the transcript render path, so nothing here throws and nothing
6
- * allocates per message. A disabled surface is skipped, and the input string returns by reference
7
- * whenever no surface changed it — the host's own caching reads that reference.
6
+ * allocates per message. A disabled surface is skipped; a surface that throws is isolated to itself and
7
+ * the message keeps every other rewrite; the input string returns by reference whenever no surface
8
+ * changed it — the host's own caching reads that reference. What the pass cannot do, it names: a closed
9
+ * gate and a table that comes out unboxed each leave one line keyed on the reason, so a live report
10
+ * reads its own cause instead of a screenshot.
8
11
  *
9
12
  * shape: closure returning a function — trigger #4, the factory captures its injected collaborators
10
13
  * (registry, surfaces, paint, log) and holds no state to classify.
@@ -12,7 +15,8 @@
12
15
  * ported from pi-pretty-tui/src/features/canvas/transformer.ts — survives because: the final-assistant
13
16
  * gate, the degrade-to-the-input-verbatim fallback, and mermaid staying with the host's own transformer
14
17
  * are the host-aligned behaviors that file got right. Its parser, settings load, and per-form render
15
- * code do not port: each surface owns one rewrite here, and the registry owns every toggle.
18
+ * code do not port: each surface owns one rewrite here, and the registry owns every toggle. Its single
19
+ * try/catch around the whole pass does not port either: a failure belongs to the surface that threw.
16
20
  */
17
21
 
18
22
  import type { TransformContext } from "../../core/types/host.ts";
@@ -28,7 +32,16 @@ const ARTIFACTS_KEY = "artifacts";
28
32
  const SURFACE_ORDER: readonly SurfaceKey[] = ["table", "codePanel", "jsonPanel", "imageCard"];
29
33
 
30
34
  const SCOPE = "content";
31
- const LOG_KEY = "content:transform";
35
+
36
+ /**
37
+ * What this file calls a table: a row holding a pipe, then a delimiter row of dashes, colons and pipes.
38
+ * It is the shape the table surface parses (T-22), spelled for the raw markdown the transformer receives,
39
+ * so prose that merely quotes a pipe — or an already-boxed frame — never matches.
40
+ */
41
+ const TABLE_SHAPE = /\|[^\n]*\n\|[ :-]*-[-| :]*\|/;
42
+
43
+ /** The tee in the table box's top rule (T-22): present in an output exactly when a table was boxed. */
44
+ const TABLE_BOX_RULE = "┬";
32
45
 
33
46
  /** The surface table T-28 composes: one entry per SurfaceKey, plus the optional fence pass. */
34
47
  export type ContentSurfaces = Record<SurfaceKey, Surface> & {
@@ -40,7 +53,7 @@ export type ContentSurfaces = Record<SurfaceKey, Surface> & {
40
53
  export interface ContentTransformerDeps {
41
54
  /** Live content paint; surfaces receive it per invocation. Required. */
42
55
  paint: ContentPaint;
43
- /** Keyed diagnostics; the never-throw path logs exactly one line. Required. */
56
+ /** Keyed diagnostics: one line per surface failure, gate skip and declined table — a healthy input logs nothing. Required. */
44
57
  log: Logger;
45
58
  }
46
59
 
@@ -54,26 +67,62 @@ export function createContentTransformer(
54
67
  const { paint, log } = deps;
55
68
  const artifacts = surfaces.artifacts;
56
69
 
57
- /** One dispatch step: an absent or disabled slot is skipped, otherwise it rewrites the current text. */
70
+ /**
71
+ * One dispatch step: an absent or disabled slot is skipped, otherwise it rewrites the current text.
72
+ * Per-surface isolation lives here — a surface that throws logs one line keyed on its own name and
73
+ * returns the text it was handed, so the failure costs that surface alone and never the message.
74
+ */
58
75
  const run = (key: string, surface: Surface | undefined, input: string, ctx: TransformContext): string => {
59
76
  if (surface === undefined || !registry.isEnabled(key)) return input;
60
- const next = surface.rewrite(input, ctx, paint);
61
- // A surface signals "nothing matched" by returning its input; comparing by value also discards an
62
- // equal copy, so a no-op pass keeps the reference the host handed us.
63
- return next === input ? input : next;
77
+ try {
78
+ const next = surface.rewrite(input, ctx, paint);
79
+ // A surface signals "nothing matched" by returning its input; comparing by value also discards an
80
+ // equal copy, so a no-op pass keeps the reference the host handed us.
81
+ return next === input ? input : next;
82
+ } catch (error) {
83
+ const detail = error instanceof Error ? error.message : String(error);
84
+ log.logOnce(`content:fail:${key}`, SCOPE, `${key} failed: ${detail}`);
85
+ return input;
86
+ }
64
87
  };
65
88
 
66
89
  return (markdown, ctx) => {
67
- // The predecessor's gate, unchanged: only a settled assistant message is rewritten.
68
- if (ctx.messageType !== "assistant" || ctx.isStreaming) return markdown;
69
- try {
70
- let out = markdown;
71
- for (const key of SURFACE_ORDER) out = run(`content.${key}`, surfaces[key], out, ctx);
72
- return run(`content.${ARTIFACTS_KEY}`, artifacts, out, ctx);
73
- } catch (error) {
74
- // One failing surface must not cost the answer: the original input returns untouched.
75
- log.logOnce(LOG_KEY, SCOPE, `transform failed: ${error instanceof Error ? error.message : String(error)}`);
90
+ // The predecessor's gate, unchanged: only a settled assistant message is rewritten. The key carries
91
+ // the messageType alone (three possible values), so a whole session reports each gate reason once.
92
+ if (ctx.messageType !== "assistant" || ctx.isStreaming) {
93
+ if (TABLE_SHAPE.test(markdown)) {
94
+ log.logOnce(
95
+ `content:gate:${ctx.messageType}`,
96
+ SCOPE,
97
+ `table-shaped content skipped: messageType=${ctx.messageType} isStreaming=${ctx.isStreaming}`,
98
+ );
99
+ }
76
100
  return markdown;
77
101
  }
102
+
103
+ let out = markdown;
104
+ // The step that last changed the text: the surface a table should have come from, named by the
105
+ // decline line below. Undefined means every surface passed its input straight through.
106
+ let lastTouched: string | undefined;
107
+ for (const key of SURFACE_ORDER) {
108
+ const step = run(`content.${key}`, surfaces[key], out, ctx);
109
+ if (step !== out) lastTouched = `content.${key}`;
110
+ out = step;
111
+ }
112
+ const fenced = run(`content.${ARTIFACTS_KEY}`, artifacts, out, ctx);
113
+ if (fenced !== out) lastTouched = `content.${ARTIFACTS_KEY}`;
114
+ out = fenced;
115
+
116
+ // A table that reaches the end of the pass without a box rule was declined by every surface that saw
117
+ // it — the one case where the pipeline succeeded and the box still did not draw. The key carries the
118
+ // input length, so a channel that keeps repeating one table declines once.
119
+ if (TABLE_SHAPE.test(markdown) && !out.includes(TABLE_BOX_RULE)) {
120
+ log.logOnce(
121
+ `content:decline:table:${markdown.length}`,
122
+ SCOPE,
123
+ `table-shaped content left unboxed: last surface that touched the text=${lastTouched ?? "none"}`,
124
+ );
125
+ }
126
+ return out;
78
127
  };
79
128
  }
@@ -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,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
- }