@zenodinh/pi-render 0.1.5 → 0.1.6

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
@@ -28,6 +28,8 @@ import { createLogger } from "./src/core/log.ts";
28
28
  import { createContentPaint, createRowPaint } from "./src/core/paint.ts";
29
29
  import { createRegistry } from "./src/core/registry.ts";
30
30
  import { createSettingsStore } from "./src/core/settings.ts";
31
+ import { createThemeHolder } from "./src/core/theme-holder.ts";
32
+ import type { ThemeHolder } from "./src/core/types/paint.ts";
31
33
  import type {
32
34
  CodeTheme,
33
35
  ContentPaint,
@@ -47,11 +49,13 @@ import {
47
49
  } from "./src/renderers/content/artifacts/engines.ts";
48
50
  import { type ArtifactServer, createArtifactServer } from "./src/renderers/content/artifacts/server.ts";
49
51
  import { createCodePanel, mapFencedBlocks, panelWidth } from "./src/renderers/content/code-panel.ts";
52
+ import { heading } from "./src/renderers/content/heading.ts";
50
53
  import { createImageCardSurface } from "./src/renderers/content/image-card.ts";
51
54
  import { type ContentSurfaces, createContentTransformer } from "./src/renderers/content/index.ts";
52
55
  import { createJsonPanel } from "./src/renderers/content/json-panel.ts";
53
56
  import { mapHtmlLinkCards } from "./src/renderers/content/link-paragraph.ts";
54
57
  import { table } from "./src/renderers/content/table.ts";
58
+ import { FALLBACK_MODULE } from "./src/renderers/tool/fallback.ts";
55
59
  import { createMasterResolver, type SpecEntry } from "./src/renderers/tool/index.ts";
56
60
  import { bashRow } from "./src/renderers/tool/specs/bash.ts";
57
61
  import {
@@ -91,6 +95,7 @@ const ROW_SPECS: Record<string, SpecEntry> = Object.fromEntries(
91
95
  * SA §6). The surface lanes export `rewrite` only, so their descriptors live here.
92
96
  */
93
97
  const CONTENT_MODULES: readonly ModuleDescriptor[] = [
98
+ { key: "content.heading", name: "Heading levels", defaultEnabled: true, settings: [] },
94
99
  { key: "content.table", name: "Table blocks", defaultEnabled: true, settings: [] },
95
100
  { key: "content.codePanel", name: "Code panels", defaultEnabled: true, settings: [] },
96
101
  { key: "content.jsonPanel", name: "JSON panels", defaultEnabled: true, settings: [] },
@@ -102,6 +107,9 @@ const CONTENT_MODULES: readonly ModuleDescriptor[] = [
102
107
  const MODULES: readonly ModuleDescriptor[] = uniqueModules([
103
108
  ...ROW_RECORDS.map((row) => row.descriptor),
104
109
  CODE_THEME_MODULE,
110
+ // The fallback row's key: a name-less module beside the code theme. Not a content surface and not a
111
+ // ROW_RECORDS entry — the resolver's catch-all is keyed on no tool name.
112
+ FALLBACK_MODULE,
105
113
  ...CONTENT_MODULES,
106
114
  ]);
107
115
 
@@ -265,7 +273,7 @@ async function installArtifacts(log: Logger, deps: BootDeps, holder: ArtifactsHo
265
273
  }
266
274
 
267
275
  /**
268
- * Region 2's synchronous half: five surfaces over lazy holders, then the one registerMarkdownTransformer
276
+ * Region 2's synchronous half: six surfaces over lazy holders, then the one registerMarkdownTransformer
269
277
  * call. It runs before piRender's first await, so the host's restored-message construction sees the
270
278
  * transformer (see `piRender`); the shiki theme and the artifact cache/server stay cold until the
271
279
  * awaited lanes fill them.
@@ -276,8 +284,10 @@ function registerContentSeam(
276
284
  log: Logger,
277
285
  codeTheme: CodeTheme,
278
286
  artifacts: ArtifactsHolder,
287
+ holder: ThemeHolder,
279
288
  ): void {
280
289
  const surfaces: ContentSurfaces = {
290
+ heading,
281
291
  table,
282
292
  codePanel: createCodePanel(codeTheme),
283
293
  jsonPanel: createJsonPanel(codeTheme, { read: readLinkedFile, log }),
@@ -287,9 +297,13 @@ function registerContentSeam(
287
297
  }),
288
298
  artifacts: createArtifactsSurface(artifacts, log),
289
299
  };
290
- // The host's markdown theme is live per call, so its closures are captured once, here.
300
+ // The host's markdown theme is live per call, so its closures are captured once, here. The band's ink
301
+ // is the one input that arrives late, through the holder: the paint reads it per render.
291
302
  pi.registerMarkdownTransformer(
292
- createContentTransformer(registry, surfaces, { paint: createContentPaint(getMarkdownTheme()), log }),
303
+ createContentTransformer(registry, surfaces, {
304
+ paint: createContentPaint(getMarkdownTheme(), "theme", holder),
305
+ log,
306
+ }),
293
307
  );
294
308
  }
295
309
 
@@ -356,8 +370,11 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
356
370
  // cache/server, which the awaited lanes below fill in.
357
371
  const codeTheme = createLazyCodeTheme();
358
372
  const artifacts: ArtifactsHolder = {};
373
+ // The band's theme reach: one long-lived handle, fed from the per-event UI context below and read
374
+ // per render by the content paint, so a theme switch repaints with no restart.
375
+ const themeHolder = createThemeHolder();
359
376
  settleSync("rows", log, () => installRows(pi, registry, log));
360
- settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts));
377
+ settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts, themeHolder));
361
378
 
362
379
  // The fallible, environment-bound lanes run next and only fill the holders above.
363
380
  await settle("artifacts", log, () => installArtifacts(log, deps, artifacts));
@@ -366,8 +383,10 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
366
383
  // Collapse-first reading model (owner decision): every tool row starts on one line.
367
384
  // The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
368
385
  // interactive mode implements it (the runner's non-UI fallback is a no-op stub), so the
369
- // call rides session_start: real in TUI, harmless in text/rpc.
386
+ // call rides session_start: real in TUI, harmless in text/rpc. The same event carries the
387
+ // live theme handle; the feed tolerates a context with no UI and a UI with no theme.
370
388
  pi.on("session_start", (_event, ctx) => {
389
+ themeHolder.set(ctx.ui?.theme);
371
390
  ctx.ui?.setToolsExpanded?.(false);
372
391
  });
373
392
  }
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.6"
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`,
@@ -14,7 +15,7 @@
14
15
  */
15
16
 
16
17
  import type { HostTheme, MarkdownTheme } from "./types/host.ts";
17
- import type { ContentPaint, RowPaint } from "./types/paint.ts";
18
+ import type { ContentPaint, RowPaint, ThemeHolder } from "./types/paint.ts";
18
19
 
19
20
  /** Host `fg` throws on a token the active theme lacks (`Theme.tokenAnsi`); a row degrades, never dies. */
20
21
  function fgOrPlain(theme: HostTheme, key: string, text: string): string {
@@ -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,36 +70,79 @@ 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
+ * How the header band fills its cell: `"theme"` reads the ACTIVE theme's own background token (the
74
+ * default, and reverse video on any frame where no theme is reachable), `"reverse"` swaps the cell's
75
+ * ink and background (SGR reverse video), `"none"` leaves the bare header. Picked once at
76
+ * construction, so a side-by-side can sweep it.
72
77
  */
73
- export type BandStyle = "reverse" | "none";
78
+ export type BandStyle = "theme" | "reverse" | "none";
74
79
 
75
80
  /**
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.
81
+ * SGR reverse video, on and off. The band's fallback and the ONE escape pair that originates here
82
+ * rather than in a theme: reverse video needs no token, so it survives a frame rendered before any
83
+ * theme is reachable — a restore, or a session whose event context carried no theme.
79
84
  */
80
85
  const REVERSE_ON = "\x1b[7m";
81
86
  const REVERSE_OFF = "\x1b[27m";
82
87
 
88
+ /**
89
+ * The band's background token. The VALUE is never hard-coded here: it is read from the active theme,
90
+ * so the owner overrides the band by editing one string in their own theme document. `selectedBg` is
91
+ * a `colors` token both shipped themes carry and the host already keeps in its background family.
92
+ */
93
+ const BAND_BG_TOKEN = "selectedBg";
94
+
95
+ /** The fallback band: reverse video over the header ink, needing no theme at all. */
96
+ function reverseBand(text: string): string {
97
+ return `${REVERSE_ON}${text}${REVERSE_OFF}`;
98
+ }
99
+
100
+ /**
101
+ * The band's ink from the live theme — read per call, so a theme switch repaints with no restart and
102
+ * no reload. `undefined` is the caller's fallback signal, and covers all three normal states: no
103
+ * theme was fed, the active theme lacks the token (the host throws `Unknown theme color`), and the
104
+ * host's theme proxy is read before initialisation.
105
+ */
106
+ function themeBand(holder: ThemeHolder | undefined, text: string): string | undefined {
107
+ const theme = holder?.get();
108
+ if (theme === undefined) return undefined;
109
+ try {
110
+ return theme.bg(BAND_BG_TOKEN, text);
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ }
115
+
83
116
  /**
84
117
  * Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
85
118
  *
86
119
  * The markdown theme's own closures supply the emphasis ink: `bold`/`italic` for inline spans, and its
87
120
  * `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.
121
+ * `bandStyle` picks the header band — `"theme"` (the default) reads the active theme's background token
122
+ * through `holder`, `"reverse"` forces the theme-independent fallback, `"none"` leaves the header bare
123
+ * — so the band is a construction-time choice whose ink is read per frame.
90
124
  */
91
125
  // 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 {
126
+ // captured markdown theme, band style and theme holder.
127
+ export function createContentPaint(
128
+ mdTheme: MarkdownTheme,
129
+ bandStyle: BandStyle = "theme",
130
+ holder?: ThemeHolder,
131
+ ): ContentPaint {
94
132
  // The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
95
133
  // degrades on its own, so a theme missing one still stamps whatever the other gives.
96
134
  const header = (text: string): string => {
97
135
  const inked = tryStyle(mdTheme.code, text) ?? text;
98
136
  return tryStyle(mdTheme.bold, inked) ?? inked;
99
137
  };
138
+ // shape: dispatch object — trigger #1, three band styles on one discriminator, handlers stateless;
139
+ // `Record` makes a fourth style a compile error rather than a silently bare header.
140
+ const bands: Record<BandStyle, (inked: string) => string> = {
141
+ theme: (inked) => themeBand(holder, inked) ?? reverseBand(inked),
142
+ reverse: reverseBand,
143
+ none: (inked) => inked,
144
+ };
145
+ const band = bands[bandStyle];
100
146
  return {
101
147
  rule: (text) => tryStyle(mdTheme.hr, text) ?? tryStyle(mdTheme.quoteBorder, text) ?? text,
102
148
  quote: (text) => tryStyle(mdTheme.quote, text) ?? text,
@@ -105,6 +151,6 @@ export function createContentPaint(mdTheme: MarkdownTheme, bandStyle: BandStyle
105
151
  strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
106
152
  em: (text) => tryStyle(mdTheme.italic, text) ?? text,
107
153
  header,
108
- headerBand: (text) => (bandStyle === "reverse" ? `${REVERSE_ON}${header(text)}${REVERSE_OFF}` : header(text)),
154
+ headerBand: (text) => band(header(text)),
109
155
  };
110
156
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * theme-holder.ts — the band's reach into the live theme: fed per event, read per frame.
3
+ *
4
+ * Boundary: the extension API carries no theme member, so the handle arrives on the per-event UI
5
+ * context (`ctx.ui.theme`, a getter over the host's theme proxy) and the composition root feeds it
6
+ * here; the content paint reads it per invocation. Nothing here emits an escape and nothing here
7
+ * logs — an unfed holder is a normal state whose only consequence is the band's documented fallback.
8
+ *
9
+ * shape: closure returning an object literal — trigger #4, one mutable handle slot behind set/get.
10
+ */
11
+
12
+ import type { HostBackgroundTheme } from "./types/host.ts";
13
+ import type { ThemeHolder } from "./types/paint.ts";
14
+
15
+ /**
16
+ * The feed crosses a host boundary, so it is checked rather than annotated. The member is READ, never
17
+ * probed with `in`: the host's live theme is `new Proxy({}, { get })` (`theme.js:515-522`), so `in`
18
+ * answers false even for a working theme, and any read before `initTheme()` throws. Both observed by
19
+ * executed probe against the installed host 1.0.4, 2026-10-08. Neither is an error to report: no
20
+ * theme is a normal state, and the band's fallback is its only consequence.
21
+ */
22
+ function isBackgroundTheme(value: unknown): value is HostBackgroundTheme {
23
+ if (typeof value !== "object" || value === null) return false;
24
+ try {
25
+ return typeof Reflect.get(value, "bg") === "function";
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /** One handle slot: filled by the session-start feed, read by every band. */
32
+ export function createThemeHolder(): ThemeHolder {
33
+ // The handle is what is kept, never a resolved colour: caching an escape would freeze the band on
34
+ // the theme selected at load, which is the one defect this seam exists to avoid.
35
+ let current: HostBackgroundTheme | undefined;
36
+ return {
37
+ set(theme) {
38
+ current = isBackgroundTheme(theme) ? theme : undefined;
39
+ },
40
+ get() {
41
+ return current;
42
+ },
43
+ };
44
+ }
@@ -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. */
@@ -157,5 +169,10 @@ export interface EventContext {
157
169
  ui?: {
158
170
  /** Collapses (false) / expands (true) every tool row. The collapse-first default rides this. Optional. */
159
171
  setToolsExpanded?(expanded: boolean): void;
172
+ /**
173
+ * The live theme handle — a getter over the host's theme proxy, so one read sees every later
174
+ * switch. Optional: it is the band ink's only reach, and the extension API carries no theme.
175
+ */
176
+ theme?: HostBackgroundTheme;
160
177
  };
161
178
  }
@@ -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 argsComplete gate hands buildCall {} until the host says arguments are
93
+ // final, so zero keys covers a gated row and a genuinely argument-less tool alike —
94
+ // neither is an 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,6 +27,7 @@ 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
 
@@ -61,6 +63,11 @@ function withoutComponent(ctx: RenderContext): RenderContext {
61
63
  return { ...ctx, lastComponent: undefined };
62
64
  }
63
65
 
66
+ /** A chain answer the host can draw: either slot suffices; a definition with neither is a peer shape the host defaults. */
67
+ function hasUsableSlot(base: ToolRenderers | undefined): boolean {
68
+ return base !== undefined && (base.renderCall !== undefined || base.renderResult !== undefined);
69
+ }
70
+
64
71
  // shape: closure returning a resolver function — trigger #4: one runtime binding per spec is captured by
65
72
  // the factory; the resolver allocates only what a single row construction needs.
66
73
  export function createMasterResolver(
@@ -85,7 +92,16 @@ export function createMasterResolver(
85
92
 
86
93
  return (toolName, next) => {
87
94
  const entry = runtimes.get(toolName);
88
- if (entry === undefined) return next(); // not our table: the host chain answers, verbatim
95
+ if (entry === undefined) {
96
+ // Delegation is load-bearing: the chain answers FIRST, so a peer or the host still wins a
97
+ // name it owns; the gate is consulted only at the point the fallback would answer.
98
+ const base = next();
99
+ // Two-shape trigger: the host draws its own default both for a name with no definition and
100
+ // for a definition shipping neither slot — the second is the shape an installed peer's tool
101
+ // takes, which an emptiness-only test would miss and leave the reported symptom in place.
102
+ if (hasUsableSlot(base) || !registry.isEnabled(FALLBACK_MODULE.key)) return base;
103
+ return { renderShell: SHELL, ...createFallbackRenderers(toolName, deps) };
104
+ }
89
105
  const base = next(); // captured here and only here — next() is gone once this resolver returns
90
106
  const key = entry.key; // explicit: search's four names share "row.search" (T-20 ruling)
91
107
  const runtime = entry.renderers;
@@ -265,7 +265,10 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
265
265
  const paint = deps.paint(theme);
266
266
  // A partial argument set is not a title: buildCall sees {} until the host says argsComplete.
267
267
  const model = spec.buildCall(ctx.argsComplete === true ? args : {}, ctx);
268
- const title = paint.title(model.title);
268
+ // The name carries the call's three-state ink, and the order is the mapping's point: a failed call
269
+ // is never merely pending, so isError outranks isPartial; with neither flag the call is settled.
270
+ const ink = ctx.isError === true ? paint.error : ctx.isPartial === true ? paint.dim : paint.success;
271
+ const title = ink(model.title);
269
272
  // Collapsed rows are one line: the summary the result slot settled rides this title line.
270
273
  const summary = ctx.expanded === true ? undefined : readSummary(ctx);
271
274
  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",