@zenodinh/pi-render 0.1.5 → 0.1.7

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/index.ts CHANGED
@@ -47,11 +47,13 @@ import {
47
47
  } from "./src/renderers/content/artifacts/engines.ts";
48
48
  import { type ArtifactServer, createArtifactServer } from "./src/renderers/content/artifacts/server.ts";
49
49
  import { createCodePanel, mapFencedBlocks, panelWidth } from "./src/renderers/content/code-panel.ts";
50
+ import { heading } from "./src/renderers/content/heading.ts";
50
51
  import { createImageCardSurface } from "./src/renderers/content/image-card.ts";
51
52
  import { type ContentSurfaces, createContentTransformer } from "./src/renderers/content/index.ts";
52
53
  import { createJsonPanel } from "./src/renderers/content/json-panel.ts";
53
54
  import { mapHtmlLinkCards } from "./src/renderers/content/link-paragraph.ts";
54
55
  import { table } from "./src/renderers/content/table.ts";
56
+ import { FALLBACK_MODULE } from "./src/renderers/tool/fallback.ts";
55
57
  import { createMasterResolver, type SpecEntry } from "./src/renderers/tool/index.ts";
56
58
  import { bashRow } from "./src/renderers/tool/specs/bash.ts";
57
59
  import {
@@ -91,6 +93,7 @@ const ROW_SPECS: Record<string, SpecEntry> = Object.fromEntries(
91
93
  * SA §6). The surface lanes export `rewrite` only, so their descriptors live here.
92
94
  */
93
95
  const CONTENT_MODULES: readonly ModuleDescriptor[] = [
96
+ { key: "content.heading", name: "Heading levels", defaultEnabled: true, settings: [] },
94
97
  { key: "content.table", name: "Table blocks", defaultEnabled: true, settings: [] },
95
98
  { key: "content.codePanel", name: "Code panels", defaultEnabled: true, settings: [] },
96
99
  { key: "content.jsonPanel", name: "JSON panels", defaultEnabled: true, settings: [] },
@@ -102,6 +105,9 @@ const CONTENT_MODULES: readonly ModuleDescriptor[] = [
102
105
  const MODULES: readonly ModuleDescriptor[] = uniqueModules([
103
106
  ...ROW_RECORDS.map((row) => row.descriptor),
104
107
  CODE_THEME_MODULE,
108
+ // The fallback row's key: a name-less module beside the code theme. Not a content surface and not a
109
+ // ROW_RECORDS entry — the resolver's catch-all is keyed on no tool name.
110
+ FALLBACK_MODULE,
105
111
  ...CONTENT_MODULES,
106
112
  ]);
107
113
 
@@ -265,7 +271,7 @@ async function installArtifacts(log: Logger, deps: BootDeps, holder: ArtifactsHo
265
271
  }
266
272
 
267
273
  /**
268
- * Region 2's synchronous half: five surfaces over lazy holders, then the one registerMarkdownTransformer
274
+ * Region 2's synchronous half: six surfaces over lazy holders, then the one registerMarkdownTransformer
269
275
  * call. It runs before piRender's first await, so the host's restored-message construction sees the
270
276
  * transformer (see `piRender`); the shiki theme and the artifact cache/server stay cold until the
271
277
  * awaited lanes fill them.
@@ -278,6 +284,7 @@ function registerContentSeam(
278
284
  artifacts: ArtifactsHolder,
279
285
  ): void {
280
286
  const surfaces: ContentSurfaces = {
287
+ heading,
281
288
  table,
282
289
  codePanel: createCodePanel(codeTheme),
283
290
  jsonPanel: createJsonPanel(codeTheme, { read: readLinkedFile, log }),
@@ -289,7 +296,10 @@ function registerContentSeam(
289
296
  };
290
297
  // The host's markdown theme is live per call, so its closures are captured once, here.
291
298
  pi.registerMarkdownTransformer(
292
- createContentTransformer(registry, surfaces, { paint: createContentPaint(getMarkdownTheme()), log }),
299
+ createContentTransformer(registry, surfaces, {
300
+ paint: createContentPaint(getMarkdownTheme()),
301
+ log,
302
+ }),
293
303
  );
294
304
  }
295
305
 
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.5"
70
+ "version": "0.1.7"
71
71
  }
package/src/core/paint.ts CHANGED
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * Boundary: renderers call role methods and never emit an escape (AGENTS: every escape originates
5
5
  * here); both theme inputs are structural, so a plain-object fixture drives either factory with no
6
- * host import.
6
+ * host import. The band's background ink is the one input that arrives late — through the theme
7
+ * holder — so it is read per call and falls back to reverse video when nothing was fed.
7
8
  *
8
- * shape: none — dispatch object does not apply: no discriminator, every role is a token lookup. The
9
- * two closure factories below declare their own shape.
9
+ * shape: none at module level — a role is a token lookup with no discriminator. The two closure
10
+ * factories and the band-style dispatch inside the second one declare their own shape.
10
11
  *
11
12
  * ported from pi-pretty-tui/src/config.ts:71-100 — survives because: "read the theme token, degrade to
12
13
  * default when it is absent" is what resolveBaseBackground did; its dead links (`toolBg`,
@@ -49,15 +50,17 @@ function tryStyle(fn: ((text: string) => string) | undefined, text: string): str
49
50
  }
50
51
  }
51
52
  /** Region 1 — the tool-row factory. Reads `theme` live, so the next call paints the current theme. */
52
- // shape: closure returning an object literal — trigger #4, eight stateless row roles over the captured theme.
53
+ // shape: closure returning an object literal — trigger #4, thirteen stateless row roles over the captured theme.
53
54
  export function createRowPaint(theme: HostTheme): RowPaint {
54
55
  return {
55
56
  title: (text) => fgOrPlain(theme, "toolTitle", text),
56
57
  output: (text) => fgOrPlain(theme, "toolOutput", text),
57
58
  muted: (text) => fgOrPlain(theme, "muted", text),
59
+ dim: (text) => fgOrPlain(theme, "dim", text),
58
60
  accent: (text) => fgOrPlain(theme, "accent", text),
59
61
  error: (text) => fgOrPlain(theme, "error", text),
60
62
  warning: (text) => fgOrPlain(theme, "warning", text),
63
+ success: (text) => fgOrPlain(theme, "success", text),
61
64
  gutter: (text) => fgOrPlain(theme, "muted", text),
62
65
  match: (text) => matchOrAccent(theme, text),
63
66
  diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
@@ -67,30 +70,33 @@ export function createRowPaint(theme: HostTheme): RowPaint {
67
70
  }
68
71
 
69
72
  /**
70
- * How the header band fills its cell: `"reverse"` swaps the cell's ink and background (SGR reverse
71
- * video), `"none"` leaves the bare header. Picked once at construction, so a side-by-side can sweep it.
73
+ * The header band: the header ink with its cell's foreground and background swapped — SGR reverse video.
74
+ * Reversal is what makes the band safe on any theme: it preserves the theme's own ink-on-background
75
+ * contrast ratio instead of pairing an ink with a fill that was picked for something else, and it keeps
76
+ * the band theme-driven, because the header ink is the theme's own. No token, no construction-time
77
+ * choice (owner ruling 2026-10-09; issue #52).
72
78
  */
73
- export type BandStyle = "reverse" | "none";
74
-
75
79
  /**
76
- * SGR reverse video, on and off. The band is the ONE escape pair that originates here rather than in a
77
- * theme: no background token is reachable on the content seam, so the band is built from reverse video,
78
- * which needs none and therefore survives a restore that happens before any theme is known.
80
+ * SGR reverse video, on and off — the ONE escape pair that originates here rather than in a theme.
81
+ * Reverse video needs no token, so it works on any theme and on a frame rendered before one arrives.
79
82
  */
80
83
  const REVERSE_ON = "\x1b[7m";
81
84
  const REVERSE_OFF = "\x1b[27m";
82
85
 
86
+ /** The band: reverse video over the header ink — the cell's own ink and background swapped. */
87
+ function reverseBand(text: string): string {
88
+ return `${REVERSE_ON}${text}${REVERSE_OFF}`;
89
+ }
90
+
83
91
  /**
84
92
  * Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
85
93
  *
86
94
  * The markdown theme's own closures supply the emphasis ink: `bold`/`italic` for inline spans, and its
87
95
  * `code` closure (the `mdCode` token, identical to `accent` in both stock themes) for the header ink.
88
- * `bandStyle` picks the header band — `"reverse"` (the default) composes reverse video over the header,
89
- * `"none"` leaves it bare — so the one band reachable on this seam is a construction-time choice.
90
96
  */
91
97
  // shape: closure returning an object literal — trigger #4, eight stateless content roles over the
92
- // captured markdown theme and the band style.
93
- export function createContentPaint(mdTheme: MarkdownTheme, bandStyle: BandStyle = "reverse"): ContentPaint {
98
+ // captured markdown theme, band style and theme holder.
99
+ export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
94
100
  // The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
95
101
  // degrades on its own, so a theme missing one still stamps whatever the other gives.
96
102
  const header = (text: string): string => {
@@ -105,6 +111,6 @@ export function createContentPaint(mdTheme: MarkdownTheme, bandStyle: BandStyle
105
111
  strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
106
112
  em: (text) => tryStyle(mdTheme.italic, text) ?? text,
107
113
  header,
108
- headerBand: (text) => (bandStyle === "reverse" ? `${REVERSE_ON}${header(text)}${REVERSE_OFF}` : header(text)),
114
+ headerBand: (text) => reverseBand(header(text)),
109
115
  };
110
116
  }
@@ -20,6 +20,18 @@ export interface HostTheme {
20
20
  bold(text: string): string;
21
21
  }
22
22
 
23
+ /**
24
+ * The background-capable theme slice the header band reads. Structural host shape: the host's live
25
+ * theme is a proxy over the active theme, so any object answering `bg` qualifies.
26
+ *
27
+ * Declared apart from {@link HostTheme} on purpose — that type is what every row renderer and every
28
+ * golden fixture constructs, so a background member there would break call sites that draw no band.
29
+ */
30
+ export interface HostBackgroundTheme {
31
+ /** Wraps text in a background token's escape, e.g. bg("selectedBg", "x"). Required. */
32
+ bg(key: string, text: string): string;
33
+ }
34
+
23
35
  /** Live markdown-theme closures the content painter wraps. Structural host shape — no host import. */
24
36
  export interface MarkdownTheme {
25
37
  /** Horizontal-rule escape wrapper. Optional: a host without this token degrades to plain text. */
@@ -78,6 +90,12 @@ export interface RenderContext {
78
90
  isError?: boolean;
79
91
  /** True while the result is still streaming; the row must stay cheap. Optional. */
80
92
  isPartial?: boolean;
93
+ /**
94
+ * Horizontal padding the host's own shell would have applied, in character cells. Optional: a row
95
+ * that asks for the "self" shell applies it itself, and the pinned host's render context does not
96
+ * carry it — so an absent value means no padding, never a guessed one.
97
+ */
98
+ outputPad?: number;
81
99
  }
82
100
 
83
101
  /** One result content block; only text blocks are rendered. Structural host shape. */
@@ -157,5 +175,10 @@ export interface EventContext {
157
175
  ui?: {
158
176
  /** Collapses (false) / expands (true) every tool row. The collapse-first default rides this. Optional. */
159
177
  setToolsExpanded?(expanded: boolean): void;
178
+ /**
179
+ * The live theme handle — a getter over the host's theme proxy, so one read sees every later
180
+ * switch. Optional: it is the band ink's only reach, and the extension API carries no theme.
181
+ */
182
+ theme?: HostBackgroundTheme;
160
183
  };
161
184
  }
@@ -1,5 +1,6 @@
1
1
  /**
2
- * paint.ts — the painting contract: the two role sets every renderer draws through.
2
+ * paint.ts — the painting contract: the two role sets every renderer draws through, plus the theme
3
+ * reach the header band reads.
3
4
  *
4
5
  * Boundary: region 1 is the tool row, region 2 is markdown content. Renderers call role methods
5
6
  * and never emit an escape sequence themselves (AGENTS: escapes originate in core/paint.ts).
@@ -7,7 +8,7 @@
7
8
  * shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
8
9
  */
9
10
 
10
- import type { HostTheme } from "./host.ts";
11
+ import type { HostBackgroundTheme, HostTheme } from "./host.ts";
11
12
 
12
13
  /** Region 1 — the tool-row roles. Text in, theme-derived escape-wrapped text out. */
13
14
  export interface RowPaint {
@@ -17,12 +18,16 @@ export interface RowPaint {
17
18
  output(text: string): string;
18
19
  /** De-emphasized metadata such as durations and counts. Required. */
19
20
  muted(text: string): string;
21
+ /** The pending ink: a name still in flight, quieter than {@link muted}. Required. */
22
+ dim(text: string): string;
20
23
  /** Accent for the active or highlighted element. Required. */
21
24
  accent(text: string): string;
22
25
  /** Error text on a failed call. Required. */
23
26
  error(text: string): string;
24
27
  /** Warning text for a degraded or partial result. Required. */
25
28
  warning(text: string): string;
29
+ /** The success ink: a settled call that did not fail. Required. */
30
+ success(text: string): string;
26
31
  /** The row's left gutter marker. Required. */
27
32
  gutter(text: string): string;
28
33
  /** A matched substring highlighted inside a result line. Required. */
@@ -41,6 +46,19 @@ export type RowPaintFactory = (
41
46
  theme: HostTheme,
42
47
  ) => RowPaint;
43
48
 
49
+ /**
50
+ * The band's theme reach: one long-lived handle, fed from the per-event UI context and read per frame.
51
+ *
52
+ * The HANDLE is what travels, never a resolved colour: the host's theme is a proxy over the active
53
+ * theme, so a cached escape would freeze the band on the theme selected at load.
54
+ */
55
+ export interface ThemeHolder {
56
+ /** Stores a live theme handle; a value that cannot paint a background is stored as no theme. */
57
+ set(theme: unknown): void;
58
+ /** The handle for this frame, or undefined when none was fed — the band's fallback state. */
59
+ get(): HostBackgroundTheme | undefined;
60
+ }
61
+
44
62
  /** Region 2 — the markdown-content roles. */
45
63
  export interface ContentPaint {
46
64
  /** Horizontal rule; falls back hr → quoteBorder → plain rather than throw. Required. */
@@ -61,8 +79,9 @@ export interface ContentPaint {
61
79
  */
62
80
  header(text: string): string;
63
81
  /**
64
- * One header-band cell: {@link header} carrying the band style — reverse video by default, so the
65
- * cell's own ink becomes its background. Required.
82
+ * One header-band cell: {@link header} carrying the band's own background ink, read from the ACTIVE
83
+ * theme through the holder and therefore refreshed per render. Reverse video is the fallback for a
84
+ * frame with no theme reachable. Required.
66
85
  */
67
86
  headerBand(text: string): string;
68
87
  }
@@ -44,7 +44,8 @@ export type ArtifactServerState =
44
44
  | { readonly ok: false; readonly reason: string };
45
45
 
46
46
  export interface ArtifactServer {
47
- /** Binds once on the loopback interface; a bind failure is reported, never thrown. */
47
+ /** Binds once on the loopback interface; a bind failure is reported, never thrown. The listener
48
+ * never holds the process open — a headless run must be able to exit (#46). */
48
49
  start(): Promise<ArtifactServerState>;
49
50
  /** Releases the port and resets the state; idempotent. */
50
51
  stop(): Promise<void>;
@@ -145,6 +146,10 @@ export function createArtifactServer(options: ArtifactServerOptions = {}): Artif
145
146
  await listen(bound);
146
147
  const address = bound.address();
147
148
  if (address === null || typeof address === "string") throw new Error("no port was assigned");
149
+ // The listener serves the session; it must never be the reason the process outlives it. Bound but
150
+ // unref'd, it answers requests while anything else holds the loop, and a headless run exits the
151
+ // moment the prompt resolves (#46: `pi -p` printed its answer and then never exited).
152
+ bound.unref();
148
153
  server = bound;
149
154
  baseUrl = `http://${HOST}:${address.port}`;
150
155
  return { ok: true, url: baseUrl };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * heading.ts — the heading surface: levels 1–3 numbered at depth 2, level 4 and deeper demoted to bold.
3
+ *
4
+ * The message's shallowest heading is level 1; every heading's level is its depth relative to that, so
5
+ * a message opening at `###` still numbers from `1.` Levels 1–3 receive `1.` / `1.1.` / `1.1.1.` and are
6
+ * rewritten to depth 2; level 4 and deeper lose the number and the `#` markers and become bold text.
7
+ * The numbering is the substitute for a per-level heading token the host theme does not have: it carries
8
+ * exactly one heading token, and the host renderer re-inserts literal `#` characters from depth 3
9
+ * downward, so depth 2 is the cap that removes the marks (SA §1.3). Ink is the host's own job — this
10
+ * surface rewrites markdown text and never calls paint, or the line would be inked twice.
11
+ *
12
+ * Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back out;
13
+ * a message with no heading token returns the identical reference, so the host's render cache survives.
14
+ *
15
+ * shape: module-scope const object — trigger #3, one stateless Surface per process; the token walk and
16
+ * the counters hide behind `rewrite` (deep module) and are born per call, so no state crosses messages.
17
+ */
18
+
19
+ import { Marked, type Token } from "@earendil-works/pi-tui";
20
+ import type { TransformContext } from "../../core/types/host.ts";
21
+ import type { ContentPaint } from "../../core/types/paint.ts";
22
+ import type { Surface } from "./types.ts";
23
+
24
+ interface HeadingLike {
25
+ depth: number;
26
+ text: string;
27
+ }
28
+
29
+ /** The depth every numbered heading is rewritten to — the cap that removes the host's `#` marks (SA §1.3). */
30
+ const NUMBERED_PREFIX = "##";
31
+
32
+ /** The last level that receives a number; deeper levels are demoted to bold text. */
33
+ const LAST_NUMBERED_LEVEL = 3;
34
+
35
+ const markdownParser = new Marked();
36
+
37
+ /** Marked parses untrusted markdown and its Token union carries a Generic member — the shape narrows, never `type` alone (BND-1). */
38
+ function isHeadingToken(token: Token): token is Token & HeadingLike {
39
+ return (
40
+ token.type === "heading" &&
41
+ "depth" in token &&
42
+ typeof token.depth === "number" &&
43
+ "text" in token &&
44
+ typeof token.text === "string"
45
+ );
46
+ }
47
+
48
+ /**
49
+ * The hierarchical number for one relative level: siblings increment, deeper levels reset, a level
50
+ * skipped on the way down reads as 1, and a return to a shallower level resumes that level's counter
51
+ * (PRD 7.1 extensions 3a/3b). `counters` is per message and never shared between calls.
52
+ */
53
+ function levelNumber(counters: number[], level: number): string {
54
+ for (let skipped = 0; skipped < level - 1; skipped++) {
55
+ if (counters[skipped] === 0) counters[skipped] = 1;
56
+ }
57
+ counters[level - 1] = (counters[level - 1] ?? 0) + 1;
58
+ for (let deeper = level; deeper < counters.length; deeper++) counters[deeper] = 0;
59
+ return `${counters.slice(0, level).join(".")}.`;
60
+ }
61
+
62
+ /** shape: module-scope const object — trigger #3, one stateless heading surface per process. */
63
+ export const heading: Surface = { rewrite };
64
+
65
+ /**
66
+ * WHY a token walk and never a line scan: the parser emits a fenced block as ONE code token and emits
67
+ * no heading token for a `#` inside a table cell, so both hazards are unreachable by construction; a
68
+ * line scanner would have to re-implement fence tracking to reach the same safety. Setext headings are
69
+ * heading tokens and are numbered like any other — the source form does not decide the treatment.
70
+ *
71
+ * Markdown-position rule (the non-obvious one): unchanged tokens are spliced back by their own raw
72
+ * text, and a heading token's raw carries its trailing newline only at end of input — the blank line
73
+ * between blocks lives in the following space token's raw — so the replacement keeps that newline and
74
+ * every untouched block stays at its own position, byte-identical.
75
+ */
76
+ function rewrite(markdown: string, _ctx: TransformContext, _paint: ContentPaint): string {
77
+ const tokens = markdownParser.lexer(markdown);
78
+ const depths = tokens.filter(isHeadingToken).map((token) => token.depth);
79
+ // No heading token: the input itself returns, by reference — an equal copy would invalidate the
80
+ // host's render cache, and joining raw text is not byte-exact anyway (Marked eats CRLF).
81
+ if (depths.length === 0) return markdown;
82
+
83
+ // Numbering is message-local: the shallowest heading present is level 1, and the counters are born
84
+ // inside this call, so nothing survives to the next message.
85
+ const minDepth = Math.min(...depths);
86
+ const counters = [0, 0, 0];
87
+ const parts: string[] = [];
88
+ for (const token of tokens) {
89
+ if (!isHeadingToken(token)) {
90
+ parts.push(token.raw);
91
+ continue;
92
+ }
93
+ const level = token.depth - minDepth + 1;
94
+ const trailing = /\n+$/.exec(token.raw)?.[0] ?? "";
95
+ // `token.text` holds the heading's inline markup as source, so prefixing the number keeps
96
+ // code spans and bold alive (PRD 7.1 extension 4b); the demoted form is markdown bold, which
97
+ // the host inks on its own — no escape may originate here.
98
+ parts.push(
99
+ level <= LAST_NUMBERED_LEVEL
100
+ ? `${NUMBERED_PREFIX} ${levelNumber(counters, level)}${token.text === "" ? "" : ` ${token.text}`}${trailing}`
101
+ : `**${token.text}**${trailing}`,
102
+ );
103
+ }
104
+ return parts.join("");
105
+ }
@@ -1,22 +1,25 @@
1
1
  /**
2
- * index.ts — the markdown transformer: final-assistant gate, per-surface registry check, per-surface
2
+ * index.ts — the markdown transformer: per-surface gate, per-surface registry check, per-surface
3
3
  * isolation, dispatch, identity return, and one keyed line for every skip.
4
4
  *
5
+ * The gate is per-surface (owner D5): the heading pass runs on a settled assistant or user message; the
6
+ * four markdown surfaces and the late fence pass stay assistant-only, so a user's pasted markdown is not
7
+ * restyled by them. A disabled surface is skipped; a surface that throws is isolated to itself and the
8
+ * message keeps every other rewrite; the input string returns by reference whenever no surface changed it
9
+ * — the host's own caching reads that reference. What the pass cannot do, it names: a closed gate and a
10
+ * table that comes out unboxed each leave one line keyed on the reason.
11
+ *
5
12
  * 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; 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.
13
+ * allocates per message.
11
14
  *
12
15
  * shape: closure returning a function — trigger #4, the factory captures its injected collaborators
13
16
  * (registry, surfaces, paint, log) and holds no state to classify.
14
17
  *
15
- * ported from pi-pretty-tui/src/features/canvas/transformer.ts — survives because: the final-assistant
16
- * gate, the degrade-to-the-input-verbatim fallback, and mermaid staying with the host's own transformer
17
- * are the host-aligned behaviors that file got right. Its parser, settings load, and per-form render
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.
18
+ * ported from pi-pretty-tui/src/features/canvas/transformer.ts — survives because: the degrade-to-the-input-verbatim
19
+ * fallback and mermaid staying with the host's own transformer are the host-aligned behaviors that file got
20
+ * right. Its parser, settings load, and per-form render code do not port: each surface owns one rewrite here,
21
+ * and the registry owns every toggle. Its single try/catch around the whole pass does not port either: a
22
+ * failure belongs to the surface that threw.
20
23
  */
21
24
 
22
25
  import type { TransformContext } from "../../core/types/host.ts";
@@ -28,8 +31,9 @@ import type { Surface, SurfaceKey } from "./types.ts";
28
31
  /** Registry key of the artifacts fence pass — T-28 injects its surface when artifacts land (T-25/T-26). */
29
32
  const ARTIFACTS_KEY = "artifacts";
30
33
 
31
- /** Dispatch order: the four markdown surfaces, then the fence pass over their output. */
32
- const SURFACE_ORDER: readonly SurfaceKey[] = ["table", "codePanel", "jsonPanel", "imageCard"];
34
+ /** Dispatch order: the heading pass first (it rewrites text, so it must not see an already-boxed table),
35
+ * then the four markdown surfaces; the artifacts fence pass runs last, over their output. */
36
+ const SURFACE_ORDER: readonly SurfaceKey[] = ["heading", "table", "codePanel", "jsonPanel", "imageCard"];
33
37
 
34
38
  const SCOPE = "content";
35
39
 
@@ -87,36 +91,43 @@ export function createContentTransformer(
87
91
  };
88
92
 
89
93
  return (markdown, ctx) => {
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
- }
100
- return markdown;
94
+ // Per-surface gate (owner D5): the heading pass admits a settled user message — the host already
95
+ // delivers it (user-message.js builds its transform with messageType: "user") — while the four
96
+ // markdown surfaces and the late fence pass stay assistant-only. The key carries the messageType
97
+ // alone (three values), so a whole session reports each gate reason once.
98
+ const settled = !ctx.isStreaming;
99
+ const assistant = settled && ctx.messageType === "assistant";
100
+ const headingOnly = settled && ctx.messageType === "user";
101
+ // The gate line explains the table surface, which is skipped on every non-assistant input.
102
+ if (!assistant && TABLE_SHAPE.test(markdown)) {
103
+ log.logOnce(
104
+ `content:gate:${ctx.messageType}`,
105
+ SCOPE,
106
+ `table-shaped content skipped: messageType=${ctx.messageType} isStreaming=${ctx.isStreaming}`,
107
+ );
101
108
  }
109
+ if (!assistant && !headingOnly) return markdown;
102
110
 
103
111
  let out = markdown;
104
112
  // The step that last changed the text: the surface a table should have come from, named by the
105
113
  // decline line below. Undefined means every surface passed its input straight through.
106
114
  let lastTouched: string | undefined;
107
115
  for (const key of SURFACE_ORDER) {
116
+ // The heading pass is the one surface a settled user message runs; the rest stay assistant-only.
117
+ if (key !== "heading" && !assistant) continue;
108
118
  const step = run(`content.${key}`, surfaces[key], out, ctx);
109
119
  if (step !== out) lastTouched = `content.${key}`;
110
120
  out = step;
111
121
  }
112
- const fenced = run(`content.${ARTIFACTS_KEY}`, artifacts, out, ctx);
122
+ const fenced = assistant ? run(`content.${ARTIFACTS_KEY}`, artifacts, out, ctx) : out;
113
123
  if (fenced !== out) lastTouched = `content.${ARTIFACTS_KEY}`;
114
124
  out = fenced;
115
125
 
116
126
  // A table that reaches the end of the pass without a box rule was declined by every surface that saw
117
127
  // 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)) {
128
+ // input length, so a channel that keeps repeating one table declines once. Assistant-only, because it
129
+ // is only on that branch that the table surface could have drawn the box.
130
+ if (assistant && TABLE_SHAPE.test(markdown) && !out.includes(TABLE_BOX_RULE)) {
120
131
  log.logOnce(
121
132
  `content:decline:table:${markdown.length}`,
122
133
  SCOPE,
@@ -7,9 +7,11 @@
7
7
  /**
8
8
  * table.ts — the table surface: a markdown table becomes a banded box.
9
9
  *
10
- * The header row is stamped through the header-band role — bold over the accent ink inside a band — or,
11
- * when the header row is empty (`| | |`), that stamp moves onto the first column so no blank band is
12
- * spent. `**strong**` and `*em*` cells route through their own roles.
10
+ * The header row is stamped through the header-band role — bold over the accent ink inside a
11
+ * full-bleed band spanning each cell's whole interior plus its trailing separator, so the header
12
+ * reads as one bar (FP-11) — or, when the header row is empty (`| | |`), that stamp moves onto the
13
+ * first column so no blank band is spent. `**strong**` and `*em*` cells route through their own
14
+ * roles.
13
15
  *
14
16
  * Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
15
17
  * out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
@@ -36,8 +38,6 @@ interface TableToken {
36
38
 
37
39
  /** Per-cell padding plus the bars: below this a column cannot hold its own frame. */
38
40
  const MIN_COLUMN = 6;
39
- /** A column wider than this is a paragraph, not a table cell. */
40
- const MAX_COLUMN = 60;
41
41
 
42
42
  const markdownParser = new Marked();
43
43
 
@@ -100,7 +100,7 @@ function padCell(content: string, width: number): string {
100
100
  return fit + " ".repeat(Math.max(0, width - visibleWidth(fit)));
101
101
  }
102
102
 
103
- /** shape: none — one greedy width solve; a table is small and the rule is exact. */
103
+ /** shape: none — one greedy width solve plus a largest-remainder fill; a table is small and the rule is exact. */
104
104
  function renderTable(token: TableToken, width: number, paint: ContentPaint): string[] {
105
105
  const header = token.header.map((cell) => cellText(cell, paint));
106
106
  const rows = token.rows.map((row) => row.map((cell) => cellText(cell, paint)));
@@ -108,7 +108,7 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
108
108
  const natural: number[] = [];
109
109
  for (let index = 0; index < columns; index++) {
110
110
  const cells = [header[index] ?? "", ...rows.map((row) => row[index] ?? "")];
111
- natural.push(Math.min(MAX_COLUMN, Math.max(MIN_COLUMN, ...cells.map((cell) => visibleWidth(cell)))));
111
+ natural.push(Math.max(MIN_COLUMN, ...cells.map((cell) => visibleWidth(cell))));
112
112
  }
113
113
  const widths = [...natural];
114
114
  // Width budget: per-cell padding (2 each), the column bars, and the two outer bars.
@@ -119,6 +119,28 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
119
119
  widths[widest] = (widths[widest] ?? 0) - 1;
120
120
  }
121
121
 
122
+ // Fill the reading column: a short table grows to the clamp so its frame edge lines up with the
123
+ // block above it. Slack spreads in proportion to natural width — largest remainder, ties by column
124
+ // order. Invariant that keeps this safe: a shrink step lowers the total by exactly one and the loop
125
+ // exits the moment the total equals the offer, while its other exit leaves every column at
126
+ // MIN_COLUMN with the total still above it — so a positive slack proves the shrink loop never
127
+ // iterated, and fill and shrink can never both run.
128
+ const slack = width - total();
129
+ if (slack > 0) {
130
+ const naturalTotal = natural.reduce((sum, column) => sum + column, 0);
131
+ const shares = natural.map((column, index) => {
132
+ const exact = (slack * column) / naturalTotal;
133
+ return { index, floor: Math.floor(exact), remainder: exact - Math.floor(exact) };
134
+ });
135
+ for (const { index, floor } of shares) widths[index] = (widths[index] ?? 0) + floor;
136
+ let leftover = slack - shares.reduce((sum, { floor }) => sum + floor, 0);
137
+ for (const { index } of shares.sort((a, b) => b.remainder - a.remainder)) {
138
+ if (leftover <= 0) break;
139
+ widths[index] = (widths[index] ?? 0) + 1;
140
+ leftover--;
141
+ }
142
+ }
143
+
122
144
  // An all-empty header (`| | |`) means the table carries its labels in the first column: the blank
123
145
  // header row and its separator are dropped, and the band moves onto that first column instead.
124
146
  const keyColumn = header.every((cell) => cell === "");
@@ -137,9 +159,13 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
137
159
  for (let line = 0; line < height; line++) {
138
160
  const parts = wrapped.map((lines, index) => {
139
161
  const padded = padCell(lines[line] ?? "", widths[index] ?? MIN_COLUMN);
140
- return banded(index) ? ` ${paint.headerBand(padded)} ` : ` ${padded} `;
162
+ // Full-bleed unit (FP-11): the cell's whole interior — both padding columns — plus its
163
+ // trailing separator (the bar keeps its rule ink inside the band), so banded cells form one
164
+ // unbroken bar; the last cell has no trailing separator, so its band ends at its own padding.
165
+ const unit = index < cells.length - 1 ? ` ${padded} ${bar}` : ` ${padded} `;
166
+ return banded(index) ? paint.headerBand(unit) : unit;
141
167
  });
142
- physical.push(`${bar}${parts.join(bar)}${bar}`);
168
+ physical.push(`${bar}${parts.join("")}${bar}`);
143
169
  }
144
170
  };
145
171
 
@@ -11,7 +11,7 @@ import type { TransformContext } from "../../core/types/host.ts";
11
11
  import type { ContentPaint } from "../../core/types/paint.ts";
12
12
 
13
13
  /** Surface registry keys. The module key is "content." + the key, e.g. "content.table". */
14
- export type SurfaceKey = "table" | "codePanel" | "jsonPanel" | "imageCard";
14
+ export type SurfaceKey = "heading" | "table" | "codePanel" | "jsonPanel" | "imageCard";
15
15
 
16
16
  /** One markdown rewrite stage: the input string returns by reference whenever nothing matched. */
17
17
  export interface Surface {
@@ -0,0 +1,136 @@
1
+ /**
2
+ * fallback.ts — the fallback row: one collapsed line in the package's own style for any tool name
3
+ * neither the table nor the chain draws.
4
+ *
5
+ * Boundary: the master resolver composes this when the chain's answer has no usable slot, gated on
6
+ * the row.fallback key; this module registers nothing and claims no name — it answers inside the
7
+ * resolver chain. Arguments and result blocks are host payloads, narrowed field by field. An argument
8
+ * shape with no string value degrades to the name alone and logs one line keyed on the call id.
9
+ *
10
+ * shape: closure returning the row runtime's renderers — trigger #4: the private spec captures the
11
+ * tool name and the keyed logger, and drawing reuses createRowRenderers, so no second renderer path
12
+ * and no spec file exist for it.
13
+ */
14
+
15
+ import type { RenderContext, ToolRenderers, ToolResult } from "../../core/types/host.ts";
16
+ import type { Logger } from "../../core/types/log.ts";
17
+ import type { RowPaint, RowPaintFactory } from "../../core/types/paint.ts";
18
+ import type { ModuleDescriptor } from "../../core/types/registry.ts";
19
+ import { createRowRenderers } from "./runtime.ts";
20
+ import type { CallModel, RowSpec, RowView } from "./types.ts";
21
+
22
+ const INDENT = " ";
23
+ const HINT = "ctrl+o to expand";
24
+ /** The hint stays short: the collapsed row is one line, and the host's own default already shows more. */
25
+ const HINT_MAX = 60;
26
+ /** read.ts's one-metadata-line cap: a longer message is the expanded body's job. */
27
+ const SUMMARY_MAX = 120;
28
+ const SCOPE = "tool.fallback";
29
+
30
+ /** The descriptor the composition root registers for the /render panel; exported here, never registered here. */
31
+ // shape: object literal — trigger #7, fields known statically (the CODE_THEME_MODULE precedent).
32
+ export const FALLBACK_MODULE: ModuleDescriptor = {
33
+ key: "row.fallback",
34
+ name: "Fallback row",
35
+ defaultEnabled: true,
36
+ settings: [],
37
+ };
38
+
39
+ /** What build parses once: the result's text plus the wrapper's own failure flag. */
40
+ interface FallbackModel {
41
+ /** The result's text blocks joined in host order, newlines normalized; "" when it carries none. */
42
+ text: string;
43
+ /** The result wrapper's failure flag; ctx's flag reaches project fresh through view.isError. */
44
+ failed: boolean;
45
+ }
46
+
47
+ /** boundary: content blocks are host payloads; only "text" blocks with a string body are read. */
48
+ function textOf(result: ToolResult): string {
49
+ const blocks = result.content;
50
+ if (!Array.isArray(blocks)) return "";
51
+ const parts: string[] = [];
52
+ for (const block of blocks) {
53
+ if (typeof block !== "object" || block === null) continue;
54
+ if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
55
+ }
56
+ return parts.join("\n").replaceAll("\r\n", "\n").replaceAll("\r", "\n");
57
+ }
58
+
59
+ /**
60
+ * boundary: arguments are host payloads; only a non-empty string value is hint text. The hint draws
61
+ * from the argument text the host itself displays — never from a file the arguments merely name.
62
+ */
63
+ function hintOf(args: Record<string, unknown>): string | undefined {
64
+ for (const value of Object.values(args)) {
65
+ if (typeof value !== "string") continue;
66
+ const flat = value.replace(/\s+/g, " ").trim();
67
+ if (flat === "") continue;
68
+ return flat.length > HINT_MAX ? `${flat.slice(0, HINT_MAX - 1)}…` : flat;
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ /** The call id when the host gave one; the tool name keeps a nameless row's line once-per-name. */
74
+ function logKey(ctx: RenderContext, toolName: string): string {
75
+ return typeof ctx.toolCallId === "string" ? ctx.toolCallId : `args:${toolName}`;
76
+ }
77
+
78
+ function capSummary(text: string): string {
79
+ return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
80
+ }
81
+
82
+ /** The failure summary's head: the first non-blank line, or one word when the result carried none. */
83
+ function failureHead(lines: readonly string[]): string {
84
+ return capSummary(lines.find((line) => line.trim() !== "") ?? "failed");
85
+ }
86
+
87
+ // shape: closure returning an object literal — trigger #4: buildCall captures the tool name and the
88
+ // keyed logger; the three contract fields keep no per-row state beyond that capture.
89
+ function createFallbackSpec(toolName: string, log: Logger): RowSpec<FallbackModel> {
90
+ return {
91
+ buildCall(args, ctx): CallModel {
92
+ // The runtime's args gate hands buildCall {} until a host flag says the arguments are final,
93
+ // so zero keys covers a gated row and a genuinely argument-less tool alike — neither is an
94
+ // unexpected shape, so neither logs.
95
+ if (Object.keys(args).length === 0) return { title: toolName };
96
+ const hint = hintOf(args);
97
+ if (hint === undefined) {
98
+ log.logOnce(logKey(ctx, toolName), SCOPE, `unrecognized argument shape for ${toolName} — name alone`);
99
+ return { title: toolName };
100
+ }
101
+ return { title: `${toolName}: ${hint}` };
102
+ },
103
+
104
+ build(result): FallbackModel {
105
+ return { text: textOf(result), failed: result.isError === true };
106
+ },
107
+
108
+ project(m: FallbackModel, view: RowView, paint: RowPaint): string {
109
+ // Either failure flag means an error row (read.ts's contract): the wrapper's is parsed
110
+ // once, ctx's arrives fresh per frame through the view.
111
+ const failed = m.failed || view.isError;
112
+ // Trailing whitespace is a terminator, not a line — the count and the body agree on that.
113
+ const body = m.text.replace(/\s+$/, "");
114
+ const lines = body === "" ? [] : body.split("\n");
115
+ const stats = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`;
116
+ if (!view.expanded) {
117
+ // Collapse-first: exactly one line, which the runtime hands to the call slot.
118
+ if (!failed) return paint.muted(`${stats} · ${HINT}`);
119
+ return `${paint.error(failureHead(lines))}${paint.muted(` · ${HINT}`)}`;
120
+ }
121
+ if (lines.length === 0) return failed ? paint.error("failed") : paint.muted(stats);
122
+ const painted = lines.map((line) =>
123
+ line.trim() === "" ? "" : `${INDENT}${failed ? paint.error(line) : paint.output(line)}`,
124
+ );
125
+ return [paint.muted(stats), "", ...painted].join("\n");
126
+ },
127
+ };
128
+ }
129
+
130
+ /** The fallback row's two slots, built by the shared row runtime; the resolver composes the shell. */
131
+ export function createFallbackRenderers(
132
+ toolName: string,
133
+ deps: { paint: RowPaintFactory; log: Logger },
134
+ ): ToolRenderers {
135
+ return createRowRenderers(createFallbackSpec(toolName, deps.log), deps);
136
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * index.ts — the master resolver: the tool-name table, the captured host base, and one registry check
3
- * per invocation — the pluggable toggle for every tool row.
2
+ * index.ts — the master resolver: the tool-name table, the captured host base, one registry check
3
+ * per invocation — the pluggable toggle for every tool row — and the gated fallback row for names
4
+ * neither the table nor the chain draws.
4
5
  *
5
6
  * Boundary: the host consults this ONCE per row component construction, keyed on tool name alone, and
6
7
  * `next()` exists only at that moment; the base renderers it hands back are captured here and reused for
@@ -26,11 +27,17 @@ import type {
26
27
  ToolResult,
27
28
  UiComponent,
28
29
  } from "../../core/types.ts";
30
+ import { createFallbackRenderers, FALLBACK_MODULE } from "./fallback.ts";
29
31
  import { createRowRenderers } from "./runtime.ts";
30
32
  import type { RowSpec } from "./types.ts";
31
33
 
32
- /** The host's own shell frame: chrome belongs to pi-zentui, so a row never asks for a custom one. */
33
- const SHELL = "default";
34
+ /**
35
+ * The shell every row asks the host for: "self" skips the host's Box(1,1) padding rows around our
36
+ * content, so a row is the lines we compose (#51) — which is also why the horizontal pad is ours to
37
+ * apply (runtime.ts reads it off the ctx). The host still prepends one blank line of its own, and
38
+ * closing that last blank is the upstream grouping ask (ADR-0004), not something a renderer reaches.
39
+ */
40
+ const SHELL = "self";
34
41
  const SCOPE = "tool.resolve";
35
42
 
36
43
  /** The resolver's injected collaborators — the row paint factory, plus the keyed diagnostic sink. */
@@ -61,6 +68,11 @@ function withoutComponent(ctx: RenderContext): RenderContext {
61
68
  return { ...ctx, lastComponent: undefined };
62
69
  }
63
70
 
71
+ /** A chain answer the host can draw: either slot suffices; a definition with neither is a peer shape the host defaults. */
72
+ function hasUsableSlot(base: ToolRenderers | undefined): base is ToolRenderers {
73
+ return base !== undefined && (base.renderCall !== undefined || base.renderResult !== undefined);
74
+ }
75
+
64
76
  // shape: closure returning a resolver function — trigger #4: one runtime binding per spec is captured by
65
77
  // the factory; the resolver allocates only what a single row construction needs.
66
78
  export function createMasterResolver(
@@ -85,13 +97,28 @@ export function createMasterResolver(
85
97
 
86
98
  return (toolName, next) => {
87
99
  const entry = runtimes.get(toolName);
88
- if (entry === undefined) return next(); // not our table: the host chain answers, verbatim
100
+ if (entry === undefined) {
101
+ // Delegation is load-bearing: the chain answers FIRST, so a peer or the host still wins a
102
+ // name it owns; the gate is consulted only at the point the fallback would answer.
103
+ const base = next();
104
+ // Two-shape trigger: the host draws its own default both for a name with no definition and
105
+ // for a definition shipping neither slot — the second is the shape an installed peer's tool
106
+ // takes, which an emptiness-only test would miss and leave the reported symptom in place.
107
+ if (hasUsableSlot(base) || !registry.isEnabled(FALLBACK_MODULE.key)) return base;
108
+ return { renderShell: SHELL, ...createFallbackRenderers(toolName, deps) };
109
+ }
89
110
  const base = next(); // captured here and only here — next() is gone once this resolver returns
90
111
  const key = entry.key; // explicit: search's four names share "row.search" (T-20 ruling)
91
112
  const runtime = entry.renderers;
92
113
 
93
114
  return {
94
- renderShell: SHELL,
115
+ // A row the base draws wears the BASE's shell: our "self" would strip the Box(1,1) whose padding
116
+ // and background the base's own lines were composed for. Read once, at resolution — the host
117
+ // binds the shell's container in the component's constructor and never re-parents it
118
+ // (tool-execution.js:52-54, against :200 which only picks the container to fill), so a shell
119
+ // that tracked the registry per frame would fill one container while the tree still held the
120
+ // other. The slots keep polling; the shell cannot.
121
+ renderShell: registry.isEnabled(key) || !hasUsableSlot(base) ? SHELL : base.renderShell,
95
122
  renderCall(args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent {
96
123
  const baseCall = base?.renderCall;
97
124
  if (registry.isEnabled(key) || baseCall === undefined) return runtime.renderCall(args, theme, ctx);
@@ -149,21 +149,38 @@ function logFailure(log: Logger, ctx: RenderContext, err: unknown): void {
149
149
  log.logOnce(key, LOG_SCOPE, `row degraded: ${text}`);
150
150
  }
151
151
 
152
+ /** The horizontal pad a row applies itself, in cells; a host that hands none means no padding. */
153
+ function padOf(ctx: RenderContext): number {
154
+ const pad = ctx.outputPad;
155
+ return typeof pad === "number" && Number.isFinite(pad) && pad > 0 ? Math.floor(pad) : 0;
156
+ }
157
+
158
+ /**
159
+ * A text component wearing this runtime's pad. The pad arrives on the ctx (once per invocation) while
160
+ * the indent is applied in render(width) (once per frame), so the component carries it across the two —
161
+ * and `setPad` is thereby the marker that says "this one is ours to reuse". The host spells its own
162
+ * padding setter `setPaddingX`, so a component the base renderer left in the slot never answers it.
163
+ */
164
+ interface PaddedText extends UiComponent {
165
+ /** Replaces the displayed body. Required: the reuse protocol the host's lastComponent enables. */
166
+ setText(body: string): void;
167
+ /** Re-reads the row's indent, so a host padding change lands on the next frame. Required. */
168
+ setPad(pad: number): void;
169
+ }
170
+
171
+ /** `in`-narrowed, never cast: a recorded component is the host's until it answers this protocol. */
172
+ function isPaddedText(component: UiComponent): component is PaddedText {
173
+ return "setPad" in component && typeof component.setPad === "function";
174
+ }
175
+
152
176
  /** shape: closure returning an object literal — trigger #4: one stored text behind render/invalidate. */
153
- function textFor(ctx: RenderContext, body: string): UiComponent {
154
- const reuse = ctx.lastComponent;
155
- if (reuse !== undefined) {
156
- const setText = reuse.setText;
157
- if (setText !== undefined) {
158
- setText.call(reuse, body);
159
- return reuse;
160
- }
161
- }
177
+ function textComponent(body: string): UiComponent {
162
178
  let current = body;
163
179
  return {
164
180
  render(_width: number): string[] {
165
181
  // One entry per line; an empty body still yields one line, so a degraded row never vanishes.
166
182
  // Width is the component's own: renderResult receives none, so nothing here is width-keyed.
183
+ // The offer minus the indent is handed down by the pad wrapper around this component.
167
184
  return current.split("\n");
168
185
  },
169
186
  invalidate(): void {
@@ -175,9 +192,64 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
175
192
  };
176
193
  }
177
194
 
195
+ /**
196
+ * The pad wrapper: every line the inner component draws takes the indent, and the inner component gets
197
+ * the offer minus it.
198
+ *
199
+ * Both halves are ours because a row asks the host for the "self" shell (#51), and that shell skips the
200
+ * Box(1,1) which used to indent each line and render its child at `width - paddingX * 2` (pi-tui
201
+ * box.js:83 → :91 → :115). The budget half is what a reused host `Text` needs: it wraps at the width it
202
+ * is given (pi-tui text.js:53-56), so a full-width title would wrap at the whole offer and then be
203
+ * pushed one column past it by the indent.
204
+ *
205
+ * shape: closure returning an object literal — trigger #4: one stored pad behind render/setPad.
206
+ */
207
+ function padComponent(inner: UiComponent, pad: number): PaddedText {
208
+ let indent = pad;
209
+ return {
210
+ render(width: number): string[] {
211
+ const lines = inner.render(Math.max(0, width - indent));
212
+ if (indent === 0) return lines;
213
+ const spaces = " ".repeat(indent);
214
+ return lines.map((line) => spaces + line);
215
+ },
216
+ invalidate(): void {
217
+ inner.invalidate();
218
+ },
219
+ setText(body: string): void {
220
+ inner.setText?.(body);
221
+ },
222
+ setPad(value: number): void {
223
+ indent = value;
224
+ },
225
+ };
226
+ }
227
+
228
+ function textFor(ctx: RenderContext, body: string): UiComponent {
229
+ const pad = padOf(ctx);
230
+ const reuse = ctx.lastComponent;
231
+ // Only a component this runtime wrapped can be told a new pad, so it is the only one it reuses. A
232
+ // foreign one — the base renderer's, after a module toggle handed the row back — is replaced once;
233
+ // the host records what we return, so every later frame takes this branch.
234
+ if (reuse !== undefined && isPaddedText(reuse)) {
235
+ reuse.setText(body);
236
+ reuse.setPad(pad);
237
+ return reuse;
238
+ }
239
+ // A host text component in the slot is still worth mutating in place: it caches its own frame, and
240
+ // it is the one inner whose wrap the reduced budget reaches.
241
+ const setText = reuse?.setText;
242
+ if (reuse !== undefined && setText !== undefined) {
243
+ setText.call(reuse, body);
244
+ return padComponent(reuse, pad);
245
+ }
246
+ return padComponent(textComponent(body), pad);
247
+ }
248
+
178
249
  /**
179
250
  * 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
251
+ * slot inside the self shell's container, and that container concatenates each child's lines at the whole
252
+ * offer (pi-tui tui.js:129-140 — no padding of its own, which is why the pad is ours, #51), so an empty
181
253
  * array adds nothing — the call line above is the whole collapsed row. Stateless, so one shared instance
182
254
  * serves every row and every frame.
183
255
  *
@@ -235,6 +307,35 @@ function scheduleDecorate(
235
307
  );
236
308
  }
237
309
 
310
+ /**
311
+ * The host sets this the moment a call starts executing (host `core/extensions/types.d.ts`). The repo's
312
+ * declared seam omits it, so the undeclared field is `in`-narrowed before it is read — never asserted.
313
+ */
314
+ function isExecutionStarted(ctx: RenderContext): boolean {
315
+ return "executionStarted" in ctx && ctx.executionStarted === true;
316
+ }
317
+
318
+ /**
319
+ * The runtime's one args rule, three arms — each one a host writer that settles when the arguments
320
+ * can be trusted:
321
+ *
322
+ * - `argsComplete === true`: the host flips it on the live streaming path (`setArgsComplete`).
323
+ * - `isPartial === false`: the host starts a component at `isPartial = true` and clears it only in
324
+ * `updateResult` (tool-execution.js:26/:130-132), so a false means a result was delivered and the
325
+ * arguments behind it are final — with one exception: on the abort path the host restores the row
326
+ * through `updateResult({errorMessage}, isError: true)`, whose default `isPartial = false` lands
327
+ * with the arguments still incomplete (interactive-mode.js:2873-2878 live / :3256 restore), so the
328
+ * arm may title from partial args. Strictly `=== false`, so an absent flag opens nothing.
329
+ * - `executionStarted === true`: the host starts executing a call only after its arguments are complete.
330
+ *
331
+ * A call rebuilt from a stored session runs none of those writers: it is constructed from the stored
332
+ * toolCall and `updateResult(message)`d (interactive-mode.js:3238-3273), so `argsComplete` and
333
+ * `executionStarted` stay false for its whole life and only the middle arm titles its row.
334
+ */
335
+ function argsUsable(ctx: RenderContext): boolean {
336
+ return ctx.argsComplete === true || ctx.isPartial === false || isExecutionStarted(ctx);
337
+ }
338
+
238
339
  export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory; log: Logger }): ToolRenderers {
239
340
  const modelFor = (result: ToolResult, ctx: RenderContext): unknown => {
240
341
  const content = Array.isArray(result.content) ? result.content : undefined;
@@ -263,9 +364,15 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
263
364
  const renderCall = (args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent => {
264
365
  try {
265
366
  const paint = deps.paint(theme);
266
- // A partial argument set is not a title: buildCall sees {} until the host says argsComplete.
267
- const model = spec.buildCall(ctx.argsComplete === true ? args : {}, ctx);
268
- const title = paint.title(model.title);
367
+ // A partial argument set is not a title. The spec is handed the args and the gate resolved
368
+ // together, so its own argsComplete check cannot shut the gate the runtime just opened; with
369
+ // the args unusable it sees {} behind a shut gate — a half-streamed value never titles.
370
+ const usable = argsUsable(ctx);
371
+ const model = spec.buildCall(usable ? args : {}, usable ? { ...ctx, argsComplete: true } : ctx);
372
+ // The name carries the call's three-state ink, and the order is the mapping's point: a failed call
373
+ // is never merely pending, so isError outranks isPartial; with neither flag the call is settled.
374
+ const ink = ctx.isError === true ? paint.error : ctx.isPartial === true ? paint.dim : paint.success;
375
+ const title = ink(model.title);
269
376
  // Collapsed rows are one line: the summary the result slot settled rides this title line.
270
377
  const summary = ctx.expanded === true ? undefined : readSummary(ctx);
271
378
  return textFor(ctx, summary === undefined ? title : `${title} · ${summary}`);
@@ -53,13 +53,22 @@ const MAX_ERROR_SUMMARY = 80;
53
53
  /** The host writes the header as its own text block, ending at "Output:" — dist/extensions/codemode/renderer.js:14. */
54
54
  const SCRIPT_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n/;
55
55
 
56
- // shape: dispatch object — trigger #1, four stateless icon handlers on one status discriminator.
57
- // `accent` carries "ok": RowPaint has no success role, and `muted` would read as metadata.
58
- const STATUS_ICON: Record<CallStatus, (paint: RowPaint) => string> = {
59
- running: (paint) => paint.warning("…"),
60
- ok: (paint) => paint.accent("✓"),
61
- error: (paint) => paint.error("✗"),
62
- cancelled: (paint) => paint.muted("⊘"),
56
+ /** One status's two marks: the icon glyph, the ink the glyph takes, and the state role the name takes. */
57
+ type StatusMark = {
58
+ readonly glyph: string;
59
+ /** Brighter than the state role, because a glyph reads as a mark, not as text. */
60
+ readonly glyphInk: (paint: RowPaint) => (text: string) => string;
61
+ /** The line's name ink: the state vocabulary's role for this status (issue #41). */
62
+ readonly nameInk: (paint: RowPaint) => (text: string) => string;
63
+ };
64
+
65
+ // shape: dispatch object — trigger #1, four stateless mark handlers on one status discriminator.
66
+ // One table holds both marks, so a line cannot show a finished call beside a pending name (issue #41).
67
+ const STATUS_MARK: Record<CallStatus, StatusMark> = {
68
+ running: { glyph: "…", glyphInk: (paint) => paint.warning, nameInk: (paint) => paint.dim },
69
+ ok: { glyph: "✓", glyphInk: (paint) => paint.accent, nameInk: (paint) => paint.success },
70
+ error: { glyph: "✗", glyphInk: (paint) => paint.error, nameInk: (paint) => paint.error },
71
+ cancelled: { glyph: "⊘", glyphInk: (paint) => paint.muted, nameInk: (paint) => paint.muted },
63
72
  };
64
73
 
65
74
  // shape: fixed-shape record — trigger #7, three pure stages over the model above, no state.
@@ -156,6 +165,8 @@ function errorRow(model: CodemodeModel, view: RowView, paint: RowPaint): string
156
165
  }
157
166
 
158
167
  function expandedBody(model: CodemodeModel, view: RowView, paint: RowPaint): string {
168
+ // R3-07 + issue #41: each nested line states its own call's status, so a finished call never takes
169
+ // the row's pending ink beside its own "✓".
159
170
  const rows: string[] = [];
160
171
  for (const call of model.calls) rows.push(...callLines(call, paint));
161
172
  // A streaming script's output is not final: its calls stream, its body waits (the host's contract).
@@ -170,7 +181,7 @@ function expandedBody(model: CodemodeModel, view: RowView, paint: RowPaint): str
170
181
  }
171
182
 
172
183
  function callLines(call: NestedCall, paint: RowPaint): string[] {
173
- let line = `${statusIcon(call.status, paint)} ${paint.title(call.name)}`;
184
+ let line = `${statusIcon(call.status, paint)} ${callInk(call.status, paint)(call.name)}`;
174
185
  if (call.args !== "") line += ` ${paint.muted(clip(call.args, MAX_ARGS_PREVIEW))}`;
175
186
  const duration = formatDuration(call.durationMs);
176
187
  if (duration !== "") line += ` ${paint.muted(duration)}`;
@@ -198,8 +209,20 @@ function aggregateStatus(model: CodemodeModel, isPartial: boolean): CallStatus {
198
209
  return isPartial ? "running" : "ok";
199
210
  }
200
211
 
212
+ /** A status's mark, or undefined for a status the host did not declare. */
213
+ function statusMark(status: string): StatusMark | undefined {
214
+ return isCallStatus(status) ? STATUS_MARK[status] : undefined;
215
+ }
216
+
217
+ /** The status icon, its glyph inked. An unknown status reads as the muted "?". */
201
218
  function statusIcon(status: string, paint: RowPaint): string {
202
- return isCallStatus(status) ? STATUS_ICON[status](paint) : paint.muted("?");
219
+ const mark = statusMark(status);
220
+ return mark === undefined ? paint.muted("?") : mark.glyphInk(paint)(mark.glyph);
221
+ }
222
+
223
+ /** The name ink on a nested line: the call's own status, not the row's (issue #41). */
224
+ function callInk(status: string, paint: RowPaint): (text: string) => string {
225
+ return statusMark(status)?.nameInk(paint) ?? paint.muted;
203
226
  }
204
227
 
205
228
  /** A switch, not a table lookup: an inherited member such as "toString" must not read as a status. */
@@ -61,11 +61,11 @@ const SUMMARY_MAX = 120;
61
61
  /** The host's user-limit notice — the one trailer it appends with no details.truncation block (read.js:132-137). */
62
62
  const MORE_LINES_NOTICE = /^\[\d+ more lines in file\. Use offset=\d+ to continue\.\]$/;
63
63
 
64
- /** The lead text per classification, one stateless painter each. */
64
+ /** The lead text per classification: a name-like lead takes the state ink, the marker keeps accent. */
65
65
  // shape: dispatch object — trigger #1, three branches on `header`, handlers stateless.
66
- const HEADER: Record<HeaderKind, (paint: RowPaint) => string> = {
67
- path: (paint) => paint.title("→ read"),
68
- docs: (paint) => paint.title("→ read docs"),
66
+ const HEADER: Record<HeaderKind, (paint: RowPaint, ink: (text: string) => string) => string> = {
67
+ path: (_paint, ink) => ink("→ read"),
68
+ docs: (_paint, ink) => ink("→ read docs"),
69
69
  skill: (paint) => paint.accent("[skill]"),
70
70
  };
71
71
 
@@ -104,23 +104,25 @@ function build(result: ToolResult, ctx: RenderContext): ReadModel | "unknown" {
104
104
  }
105
105
 
106
106
  function project(m: ReadModel, view: RowView, paint: RowPaint): string {
107
- if (m.kind === "error") return view.expanded ? errorBody(m, paint) : errorSummary(m, paint);
108
- return view.expanded ? fileBody(m, paint) : fileSummary(m, paint);
107
+ // R3-07: a name-like label takes the row name's state ink, so one line never shows two inks for it.
108
+ const ink = view.isError ? paint.error : view.isPartial ? paint.dim : paint.success;
109
+ if (m.kind === "error") return view.expanded ? errorBody(m, paint) : errorSummary(m, paint, ink);
110
+ return view.expanded ? fileBody(m, paint, ink) : fileSummary(m, paint, ink);
109
111
  }
110
112
 
111
113
  /** `→ read <label>` / `→ read docs <label>` / `[skill] <name>`, with the range when the read starts late. */
112
- function header(m: FileModel, paint: RowPaint): string {
114
+ function header(m: FileModel, paint: RowPaint, ink: (text: string) => string): string {
113
115
  const range = m.offset > 1 ? paint.muted(`:${m.offset}`) : "";
114
- const label = m.label === "" ? "" : ` ${paint.title(m.label)}`;
115
- return `${HEADER[m.header](paint)}${label}${range}`;
116
+ const label = m.label === "" ? "" : ` ${ink(m.label)}`;
117
+ return `${HEADER[m.header](paint, ink)}${label}${range}`;
116
118
  }
117
119
 
118
- function fileSummary(m: FileModel, paint: RowPaint): string {
120
+ function fileSummary(m: FileModel, paint: RowPaint, ink: (text: string) => string): string {
119
121
  const lines = m.lines.length;
120
- return `${header(m, paint)} ${paint.muted(`${lines} ${lines === 1 ? "line" : "lines"} — ctrl+o to expand`)}`;
122
+ return `${header(m, paint, ink)} ${paint.muted(`${lines} ${lines === 1 ? "line" : "lines"} — ctrl+o to expand`)}`;
121
123
  }
122
124
 
123
- function fileBody(m: FileModel, paint: RowPaint): string {
125
+ function fileBody(m: FileModel, paint: RowPaint, ink: (text: string) => string): string {
124
126
  // A fixed gutter width keeps the numbers aligned across frames; 3 is the predecessor's floor.
125
127
  const width = Math.max(3, String(m.offset + m.lines.length - 1).length);
126
128
  const numbered = m.lines.map(
@@ -129,13 +131,13 @@ function fileBody(m: FileModel, paint: RowPaint): string {
129
131
  );
130
132
  const hint = m.header === "skill" ? ` ${paint.muted("ctrl+o to collapse")}` : "";
131
133
  const notice = m.notice === undefined ? [] : [paint.warning(m.notice)];
132
- return [header(m, paint) + hint, ...numbered, ...notice].join("\n");
134
+ return [header(m, paint, ink) + hint, ...numbered, ...notice].join("\n");
133
135
  }
134
136
 
135
- function errorSummary(m: ErrorModel, paint: RowPaint): string {
137
+ function errorSummary(m: ErrorModel, paint: RowPaint, ink: (text: string) => string): string {
136
138
  const first = m.lines.find((line) => line.trim() !== "") ?? "failed";
137
139
  const message = first.length > SUMMARY_MAX ? `${first.slice(0, SUMMARY_MAX - 1)}…` : first;
138
- const label = m.label === "" ? "" : ` ${paint.title(m.label)}`;
140
+ const label = m.label === "" ? "" : ` ${ink(m.label)}`;
139
141
  return `${paint.error("→ read")}${label} ${paint.error(message)}`;
140
142
  }
141
143
 
@@ -76,8 +76,12 @@ function firstLine(text: string): string {
76
76
  }
77
77
 
78
78
  /** shape: none — a two-value expansion branch with one string body each. */
79
- function writtenLine(m: Extract<WriteModel, { kind: "written" }>, paint: RowPaint): string {
80
- const head = m.display ? `${ICON} ${paint.title(m.display)}` : ICON;
79
+ function writtenLine(
80
+ m: Extract<WriteModel, { kind: "written" }>,
81
+ paint: RowPaint,
82
+ ink: (text: string) => string,
83
+ ): string {
84
+ const head = m.display ? `${ICON} ${ink(m.display)}` : ICON;
81
85
  // No content yet (args still streaming) is the pinned special case: no fabricated counts.
82
86
  if (m.content === undefined) return `${head} ${paint.muted("· writing…")}`;
83
87
  return `${head} ${paint.muted(`(${m.lines} lines · ${m.bytes} bytes)`)}`;
@@ -94,8 +98,8 @@ function writtenBody(m: Extract<WriteModel, { kind: "written" }>, paint: RowPain
94
98
  }
95
99
 
96
100
  /** shape: none — a single collapsed line; error text is truncated, never re-wrapped. */
97
- function errorLine(m: Extract<WriteModel, { kind: "error" }>, paint: RowPaint): string {
98
- const head = m.display ? `${ICON} ${paint.title(m.display)} ` : "";
101
+ function errorLine(m: Extract<WriteModel, { kind: "error" }>, paint: RowPaint, ink: (text: string) => string): string {
102
+ const head = m.display ? `${ICON} ${ink(m.display)} ` : "";
99
103
  return `${head}${paint.error(`${ERROR_MARK} ${firstLine(m.error)}`)}`;
100
104
  }
101
105
 
@@ -127,8 +131,10 @@ function build(result: ToolResult, ctx: RenderContext): WriteModel | "unknown" {
127
131
 
128
132
  /** shape: none — two straight-line branches (state, then model kind); no dispatch object. */
129
133
  function project(m: WriteModel, view: RowView, paint: RowPaint): string {
130
- if (m.kind === "error") return view.expanded ? errorBody(m, paint) : errorLine(m, paint);
131
- return view.expanded ? writtenBody(m, paint) : writtenLine(m, paint);
134
+ // R3-07: the path is name-like text, so it takes the row name's state ink (error / dim / success).
135
+ const ink = view.isError ? paint.error : view.isPartial ? paint.dim : paint.success;
136
+ if (m.kind === "error") return view.expanded ? errorBody(m, paint) : errorLine(m, paint, ink);
137
+ return view.expanded ? writtenBody(m, paint) : writtenLine(m, paint, ink);
132
138
  }
133
139
 
134
140
  export const writeRow: {
@@ -37,9 +37,9 @@
37
37
  "customMessageBg": "#403d53",
38
38
  "customMessageText": "muted",
39
39
  "customMessageLabel": "purple",
40
- "toolPendingBg": "#2e313e",
41
- "toolSuccessBg": "#324c44",
42
- "toolErrorBg": "#4c353f",
40
+ "toolPendingBg": "",
41
+ "toolSuccessBg": "",
42
+ "toolErrorBg": "",
43
43
  "toolTitle": "text",
44
44
  "toolOutput": "muted",
45
45
  "mdHeading": "purple",
@@ -36,9 +36,9 @@
36
36
  "customMessageBg": "#41384f",
37
37
  "customMessageText": "muted",
38
38
  "customMessageLabel": "purple",
39
- "toolPendingBg": "#323842",
40
- "toolSuccessBg": "#3c4740",
41
- "toolErrorBg": "#493840",
39
+ "toolPendingBg": "",
40
+ "toolSuccessBg": "",
41
+ "toolErrorBg": "",
42
42
  "toolTitle": "text",
43
43
  "toolOutput": "muted",
44
44
  "mdHeading": "red",