@zenodinh/pi-render 0.1.6 → 0.1.8

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,8 +28,6 @@ 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";
33
31
  import type {
34
32
  CodeTheme,
35
33
  ContentPaint,
@@ -284,7 +282,6 @@ function registerContentSeam(
284
282
  log: Logger,
285
283
  codeTheme: CodeTheme,
286
284
  artifacts: ArtifactsHolder,
287
- holder: ThemeHolder,
288
285
  ): void {
289
286
  const surfaces: ContentSurfaces = {
290
287
  heading,
@@ -297,11 +294,10 @@ function registerContentSeam(
297
294
  }),
298
295
  artifacts: createArtifactsSurface(artifacts, log),
299
296
  };
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.
297
+ // The host's markdown theme is live per call, so its closures are captured once, here.
302
298
  pi.registerMarkdownTransformer(
303
299
  createContentTransformer(registry, surfaces, {
304
- paint: createContentPaint(getMarkdownTheme(), "theme", holder),
300
+ paint: createContentPaint(getMarkdownTheme()),
305
301
  log,
306
302
  }),
307
303
  );
@@ -370,11 +366,8 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
370
366
  // cache/server, which the awaited lanes below fill in.
371
367
  const codeTheme = createLazyCodeTheme();
372
368
  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();
376
369
  settleSync("rows", log, () => installRows(pi, registry, log));
377
- settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts, themeHolder));
370
+ settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts));
378
371
 
379
372
  // The fallible, environment-bound lanes run next and only fill the holders above.
380
373
  await settle("artifacts", log, () => installArtifacts(log, deps, artifacts));
@@ -383,10 +376,8 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
383
376
  // Collapse-first reading model (owner decision): every tool row starts on one line.
384
377
  // The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
385
378
  // interactive mode implements it (the runner's non-UI fallback is a no-op stub), so the
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.
379
+ // call rides session_start: real in TUI, harmless in text/rpc.
388
380
  pi.on("session_start", (_event, ctx) => {
389
- themeHolder.set(ctx.ui?.theme);
390
381
  ctx.ui?.setToolsExpanded?.(false);
391
382
  });
392
383
  }
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.6"
70
+ "version": "0.1.8"
71
71
  }
package/src/core/paint.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { HostTheme, MarkdownTheme } from "./types/host.ts";
18
- import type { ContentPaint, RowPaint, ThemeHolder } from "./types/paint.ts";
18
+ import type { ContentPaint, RowPaint } from "./types/paint.ts";
19
19
 
20
20
  /** Host `fg` throws on a token the active theme lacks (`Theme.tokenAnsi`); a row degrades, never dies. */
21
21
  function fgOrPlain(theme: HostTheme, key: string, text: string): string {
@@ -60,7 +60,9 @@ export function createRowPaint(theme: HostTheme): RowPaint {
60
60
  accent: (text) => fgOrPlain(theme, "accent", text),
61
61
  error: (text) => fgOrPlain(theme, "error", text),
62
62
  warning: (text) => fgOrPlain(theme, "warning", text),
63
- success: (text) => fgOrPlain(theme, "success", text),
63
+ // The settled ink is `muted` by owner pick (#58): the theme's green read too bright. The role
64
+ // keeps its name, so the three state call sites stay one expression.
65
+ success: (text) => fgOrPlain(theme, "muted", text),
64
66
  gutter: (text) => fgOrPlain(theme, "muted", text),
65
67
  match: (text) => matchOrAccent(theme, text),
66
68
  diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
@@ -70,79 +72,39 @@ export function createRowPaint(theme: HostTheme): RowPaint {
70
72
  }
71
73
 
72
74
  /**
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.
75
+ * The header band: the header ink with its cell's foreground and background swapped — SGR reverse video.
76
+ * Reversal is what makes the band safe on any theme: it preserves the theme's own ink-on-background
77
+ * contrast ratio instead of pairing an ink with a fill that was picked for something else, and it keeps
78
+ * the band theme-driven, because the header ink is the theme's own. No token, no construction-time
79
+ * choice (owner ruling 2026-10-09; issue #52).
77
80
  */
78
- export type BandStyle = "theme" | "reverse" | "none";
79
-
80
81
  /**
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.
82
+ * SGR reverse video, on and off — the ONE escape pair that originates here rather than in a theme.
83
+ * Reverse video needs no token, so it works on any theme and on a frame rendered before one arrives.
84
84
  */
85
85
  const REVERSE_ON = "\x1b[7m";
86
86
  const REVERSE_OFF = "\x1b[27m";
87
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. */
88
+ /** The band: reverse video over the header ink — the cell's own ink and background swapped. */
96
89
  function reverseBand(text: string): string {
97
90
  return `${REVERSE_ON}${text}${REVERSE_OFF}`;
98
91
  }
99
92
 
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
-
116
93
  /**
117
94
  * Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
118
95
  *
119
96
  * The markdown theme's own closures supply the emphasis ink: `bold`/`italic` for inline spans, and its
120
97
  * `code` closure (the `mdCode` token, identical to `accent` in both stock themes) for the header ink.
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.
124
98
  */
125
99
  // shape: closure returning an object literal — trigger #4, eight stateless content roles over the
126
100
  // captured markdown theme, band style and theme holder.
127
- export function createContentPaint(
128
- mdTheme: MarkdownTheme,
129
- bandStyle: BandStyle = "theme",
130
- holder?: ThemeHolder,
131
- ): ContentPaint {
101
+ export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
132
102
  // The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
133
103
  // degrades on its own, so a theme missing one still stamps whatever the other gives.
134
104
  const header = (text: string): string => {
135
105
  const inked = tryStyle(mdTheme.code, text) ?? text;
136
106
  return tryStyle(mdTheme.bold, inked) ?? inked;
137
107
  };
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];
146
108
  return {
147
109
  rule: (text) => tryStyle(mdTheme.hr, text) ?? tryStyle(mdTheme.quoteBorder, text) ?? text,
148
110
  quote: (text) => tryStyle(mdTheme.quote, text) ?? text,
@@ -151,6 +113,6 @@ export function createContentPaint(
151
113
  strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
152
114
  em: (text) => tryStyle(mdTheme.italic, text) ?? text,
153
115
  header,
154
- headerBand: (text) => band(header(text)),
116
+ headerBand: (text) => reverseBand(header(text)),
155
117
  };
156
118
  }
@@ -90,6 +90,12 @@ export interface RenderContext {
90
90
  isError?: boolean;
91
91
  /** True while the result is still streaming; the row must stay cheap. Optional. */
92
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;
93
99
  }
94
100
 
95
101
  /** One result content block; only text blocks are rendered. Structural host shape. */
@@ -26,7 +26,10 @@ export interface RowPaint {
26
26
  error(text: string): string;
27
27
  /** Warning text for a degraded or partial result. Required. */
28
28
  warning(text: string): string;
29
- /** The success ink: a settled call that did not fail. Required. */
29
+ /**
30
+ * The settled ink: a call that did not fail, drawn in the `muted` token (thinking's gray) by owner
31
+ * pick (#58). The role keeps its name so the state call sites stay one expression. Required.
32
+ */
30
33
  success(text: string): string;
31
34
  /** The row's left gutter marker. Required. */
32
35
  gutter(text: string): string;
@@ -9,9 +9,10 @@
9
9
  *
10
10
  * The header row is stamped through the header-band role — bold over the accent ink inside a
11
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.
12
+ * reads as one bar (FP-11); where the band abuts the outer frame, the frame column joins it too
13
+ * (#57), so the field reaches the border. When the header row is empty (`| | |`), that stamp moves
14
+ * onto the first column instead of spending a blank band. `**strong**` and `*em*` cells route
15
+ * through their own roles.
15
16
  *
16
17
  * Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
17
18
  * out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
@@ -165,7 +166,13 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
165
166
  const unit = index < cells.length - 1 ? ` ${padded} ${bar}` : ` ${padded} `;
166
167
  return banded(index) ? paint.headerBand(unit) : unit;
167
168
  });
168
- physical.push(`${bar}${parts.join("")}${bar}`);
169
+ // #57 (owner pick): the band reaches the outer frame where it abuts it — a banded first cell
170
+ // takes the left frame column into the band, a banded last cell the right one. The frame glyph
171
+ // joins the band's own style (dark-on-green), not the rule ink, so no unbanded sliver remains
172
+ // between the field and the border.
173
+ const left = banded(0) ? paint.headerBand("│") : bar;
174
+ const right = banded(cells.length - 1) ? paint.headerBand("│") : bar;
175
+ physical.push(`${left}${parts.join("")}${right}`);
169
176
  }
170
177
  };
171
178
 
@@ -89,9 +89,9 @@ function failureHead(lines: readonly string[]): string {
89
89
  function createFallbackSpec(toolName: string, log: Logger): RowSpec<FallbackModel> {
90
90
  return {
91
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.
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
95
  if (Object.keys(args).length === 0) return { title: toolName };
96
96
  const hint = hintOf(args);
97
97
  if (hint === undefined) {
@@ -31,8 +31,13 @@ import { createFallbackRenderers, FALLBACK_MODULE } from "./fallback.ts";
31
31
  import { createRowRenderers } from "./runtime.ts";
32
32
  import type { RowSpec } from "./types.ts";
33
33
 
34
- /** The host's own shell frame: chrome belongs to pi-zentui, so a row never asks for a custom one. */
35
- 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";
36
41
  const SCOPE = "tool.resolve";
37
42
 
38
43
  /** The resolver's injected collaborators — the row paint factory, plus the keyed diagnostic sink. */
@@ -64,7 +69,7 @@ function withoutComponent(ctx: RenderContext): RenderContext {
64
69
  }
65
70
 
66
71
  /** 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 {
72
+ function hasUsableSlot(base: ToolRenderers | undefined): base is ToolRenderers {
68
73
  return base !== undefined && (base.renderCall !== undefined || base.renderResult !== undefined);
69
74
  }
70
75
 
@@ -107,7 +112,13 @@ export function createMasterResolver(
107
112
  const runtime = entry.renderers;
108
113
 
109
114
  return {
110
- 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,
111
122
  renderCall(args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent {
112
123
  const baseCall = base?.renderCall;
113
124
  if (registry.isEnabled(key) || baseCall === undefined) return runtime.renderCall(args, theme, ctx);
@@ -15,6 +15,7 @@
15
15
  * renderCall/renderResult methods, with no subclassing.
16
16
  */
17
17
 
18
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
18
19
  import type {
19
20
  HostTheme,
20
21
  RenderContext,
@@ -149,21 +150,44 @@ function logFailure(log: Logger, ctx: RenderContext, err: unknown): void {
149
150
  log.logOnce(key, LOG_SCOPE, `row degraded: ${text}`);
150
151
  }
151
152
 
153
+ /** The horizontal pad a row applies itself, in cells; a host that hands none means no padding. */
154
+ function padOf(ctx: RenderContext): number {
155
+ const pad = ctx.outputPad;
156
+ return typeof pad === "number" && Number.isFinite(pad) && pad > 0 ? Math.floor(pad) : 0;
157
+ }
158
+
159
+ /**
160
+ * A text component wearing this runtime's pad. The pad arrives on the ctx (once per invocation) while
161
+ * the indent is applied in render(width) (once per frame), so the component carries it across the two —
162
+ * and `setPad` is thereby the marker that says "this one is ours to reuse". The host spells its own
163
+ * padding setter `setPaddingX`, so a component the base renderer left in the slot never answers it.
164
+ */
165
+ interface PaddedText extends UiComponent {
166
+ /** Replaces the displayed body. Required: the reuse protocol the host's lastComponent enables. */
167
+ setText(body: string): void;
168
+ /** Re-reads the row's indent, so a host padding change lands on the next frame. Required. */
169
+ setPad(pad: number): void;
170
+ /**
171
+ * Sets how an over-wide line is spent: false cuts it to the budget with an ellipsis, true reflows it.
172
+ * Required: one component serves both row states, so the mode must follow the frame.
173
+ */
174
+ setWrap(wrap: boolean): void;
175
+ }
176
+
177
+ /** `in`-narrowed, never cast: a recorded component is the host's until it answers this protocol. */
178
+ function isPaddedText(component: UiComponent): component is PaddedText {
179
+ return "setPad" in component && typeof component.setPad === "function";
180
+ }
181
+
152
182
  /** 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
- }
183
+ function textComponent(body: string): UiComponent {
162
184
  let current = body;
163
185
  return {
164
186
  render(_width: number): string[] {
165
187
  // One entry per line; an empty body still yields one line, so a degraded row never vanishes.
166
188
  // Width is the component's own: renderResult receives none, so nothing here is width-keyed.
189
+ // The offer minus the indent is handed down by the pad wrapper, which also spends what comes back —
190
+ // cut when collapsed, reflowed when expanded.
167
191
  return current.split("\n");
168
192
  },
169
193
  invalidate(): void {
@@ -175,9 +199,85 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
175
199
  };
176
200
  }
177
201
 
202
+ /**
203
+ * The pad wrapper: the inner component gets the offer minus the indent, every line it draws takes the
204
+ * indent, and every line is fitted to the budget — an inner is told the budget, not obliged to spend it.
205
+ *
206
+ * Both halves are ours because a row asks the host for the "self" shell (#51), and that shell skips the
207
+ * Box(1,1) which used to indent each line and render its child at `width - paddingX * 2` (pi-tui
208
+ * box.js:83 → :91 → :115). The budget half is what a reused host `Text` needs: it wraps at the width it
209
+ * is given (pi-tui text.js:53-56), so a full-width title would wrap at the whole offer and then be
210
+ * pushed one column past it by the indent. Spending is the other half of the same duty (#56): our own
211
+ * text component ignores the width it is handed, so an over-wide title used to run past the offer.
212
+ *
213
+ * How an over-wide line is spent follows the row's state — the owner's review on #59: an expansion must
214
+ * show the FULL request and result. Collapsed cuts to the budget, ellipsis-marked: one logical line is the
215
+ * collapse contract. Expanded reflows with pi-tui's `wrapTextWithAnsi` — the call the table and code-panel
216
+ * surfaces already spend their cells with — while truncation stays only as the backstop for a slice that
217
+ * no width can hold (table.ts:187's rule). A line within budget passes through untouched either way.
218
+ *
219
+ * shape: closure returning an object literal — trigger #4: one stored pad and one spending mode behind
220
+ * render/setPad/setWrap.
221
+ */
222
+ function padComponent(inner: UiComponent, pad: number, wrap: boolean): PaddedText {
223
+ let indent = pad;
224
+ let wrapping = wrap;
225
+ return {
226
+ render(width: number): string[] {
227
+ const budget = Math.max(0, width - indent);
228
+ const lines = inner.render(budget).flatMap((line) => {
229
+ if (visibleWidth(line) <= budget) return [line];
230
+ // Collapsed cuts, expanded reflows (see the block comment): only an over-wide line is spent.
231
+ const spent = wrapping && budget > 0 ? wrapTextWithAnsi(line, budget) : [truncateToWidth(line, budget, "…")];
232
+ // Backstop: a wrapped slice can still exceed the budget (a wide grapheme), and a line no
233
+ // width can hold is cut rather than left to run past the edge (#56).
234
+ return spent.map((piece) => (visibleWidth(piece) > budget ? truncateToWidth(piece, budget, "…") : piece));
235
+ });
236
+ if (indent === 0) return lines;
237
+ const spaces = " ".repeat(indent);
238
+ return lines.map((line) => spaces + line);
239
+ },
240
+ invalidate(): void {
241
+ inner.invalidate();
242
+ },
243
+ setText(body: string): void {
244
+ inner.setText?.(body);
245
+ },
246
+ setPad(value: number): void {
247
+ indent = value;
248
+ },
249
+ setWrap(value: boolean): void {
250
+ wrapping = value;
251
+ },
252
+ };
253
+ }
254
+
255
+ function textFor(ctx: RenderContext, body: string, wrap: boolean): UiComponent {
256
+ const pad = padOf(ctx);
257
+ const reuse = ctx.lastComponent;
258
+ // Only a component this runtime wrapped can be told a new pad, so it is the only one it reuses. A
259
+ // foreign one — the base renderer's, after a module toggle handed the row back — is replaced once;
260
+ // the host records what we return, so every later frame takes this branch.
261
+ if (reuse !== undefined && isPaddedText(reuse)) {
262
+ reuse.setText(body);
263
+ reuse.setPad(pad);
264
+ reuse.setWrap(wrap);
265
+ return reuse;
266
+ }
267
+ // A host text component in the slot is still worth mutating in place: it caches its own frame, and
268
+ // it is the one inner whose wrap the reduced budget reaches.
269
+ const setText = reuse?.setText;
270
+ if (reuse !== undefined && setText !== undefined) {
271
+ setText.call(reuse, body);
272
+ return padComponent(reuse, pad, wrap);
273
+ }
274
+ return padComponent(textComponent(body), pad, wrap);
275
+ }
276
+
178
277
  /**
179
278
  * 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
279
+ * slot inside the self shell's container, and that container concatenates each child's lines at the whole
280
+ * offer (pi-tui tui.js:129-140 — no padding of its own, which is why the pad is ours, #51), so an empty
181
281
  * array adds nothing — the call line above is the whole collapsed row. Stateless, so one shared instance
182
282
  * serves every row and every frame.
183
283
  *
@@ -235,6 +335,35 @@ function scheduleDecorate(
235
335
  );
236
336
  }
237
337
 
338
+ /**
339
+ * The host sets this the moment a call starts executing (host `core/extensions/types.d.ts`). The repo's
340
+ * declared seam omits it, so the undeclared field is `in`-narrowed before it is read — never asserted.
341
+ */
342
+ function isExecutionStarted(ctx: RenderContext): boolean {
343
+ return "executionStarted" in ctx && ctx.executionStarted === true;
344
+ }
345
+
346
+ /**
347
+ * The runtime's one args rule, three arms — each one a host writer that settles when the arguments
348
+ * can be trusted:
349
+ *
350
+ * - `argsComplete === true`: the host flips it on the live streaming path (`setArgsComplete`).
351
+ * - `isPartial === false`: the host starts a component at `isPartial = true` and clears it only in
352
+ * `updateResult` (tool-execution.js:26/:130-132), so a false means a result was delivered and the
353
+ * arguments behind it are final — with one exception: on the abort path the host restores the row
354
+ * through `updateResult({errorMessage}, isError: true)`, whose default `isPartial = false` lands
355
+ * with the arguments still incomplete (interactive-mode.js:2873-2878 live / :3256 restore), so the
356
+ * arm may title from partial args. Strictly `=== false`, so an absent flag opens nothing.
357
+ * - `executionStarted === true`: the host starts executing a call only after its arguments are complete.
358
+ *
359
+ * A call rebuilt from a stored session runs none of those writers: it is constructed from the stored
360
+ * toolCall and `updateResult(message)`d (interactive-mode.js:3238-3273), so `argsComplete` and
361
+ * `executionStarted` stay false for its whole life and only the middle arm titles its row.
362
+ */
363
+ function argsUsable(ctx: RenderContext): boolean {
364
+ return ctx.argsComplete === true || ctx.isPartial === false || isExecutionStarted(ctx);
365
+ }
366
+
238
367
  export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory; log: Logger }): ToolRenderers {
239
368
  const modelFor = (result: ToolResult, ctx: RenderContext): unknown => {
240
369
  const content = Array.isArray(result.content) ? result.content : undefined;
@@ -263,18 +392,23 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
263
392
  const renderCall = (args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent => {
264
393
  try {
265
394
  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);
395
+ // A partial argument set is not a title. The spec is handed the args and the gate resolved
396
+ // together, so its own argsComplete check cannot shut the gate the runtime just opened; with
397
+ // the args unusable it sees {} behind a shut gate — a half-streamed value never titles.
398
+ const usable = argsUsable(ctx);
399
+ const model = spec.buildCall(usable ? args : {}, usable ? { ...ctx, argsComplete: true } : ctx);
268
400
  // The name carries the call's three-state ink, and the order is the mapping's point: a failed call
269
401
  // is never merely pending, so isError outranks isPartial; with neither flag the call is settled.
270
402
  const ink = ctx.isError === true ? paint.error : ctx.isPartial === true ? paint.dim : paint.success;
271
403
  const title = ink(model.title);
272
404
  // Collapsed rows are one line: the summary the result slot settled rides this title line.
273
- const summary = ctx.expanded === true ? undefined : readSummary(ctx);
274
- return textFor(ctx, summary === undefined ? title : `${title} · ${summary}`);
405
+ // Expanded reflows, so a long title stays readable in full; collapsed cuts to its one line.
406
+ const expanded = ctx.expanded === true;
407
+ const summary = expanded ? undefined : readSummary(ctx);
408
+ return textFor(ctx, summary === undefined ? title : `${title} · ${summary}`, expanded);
275
409
  } catch (err) {
276
410
  logFailure(deps.log, ctx, err);
277
- return textFor(ctx, "");
411
+ return textFor(ctx, "", ctx.expanded === true);
278
412
  }
279
413
  };
280
414
 
@@ -287,10 +421,11 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
287
421
  try {
288
422
  const paint = deps.paint(theme);
289
423
  const model = modelFor(result, ctx);
290
- // A degraded row keeps today's one raw line and settles no summary for the call slot to adopt.
291
- if (model === UNKNOWN) return textFor(ctx, rawBody(result, paint));
424
+ // A degraded row keeps the raw one-line body and settles no summary for the call slot to adopt;
425
+ // an expanded frame spends that line by the same reflow rule as any other.
426
+ if (model === UNKNOWN) return textFor(ctx, rawBody(result, paint), options.expanded === true);
292
427
  const body = spec.project(model, viewOf(options, ctx), paint);
293
- if (options.expanded) return textFor(ctx, body);
428
+ if (options.expanded) return textFor(ctx, body, true);
294
429
  // Collapsed: hand the one-line summary to the call slot through the shared state bag and draw no
295
430
  // line here. Redraw only when it changed — an unchanged summary is not a new frame.
296
431
  if (readSummary(ctx) !== body) {
@@ -301,7 +436,7 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
301
436
  } catch (err) {
302
437
  // The paint may itself be the thrower, so the degraded row is built without it.
303
438
  logFailure(deps.log, ctx, err);
304
- return textFor(ctx, rawBody(result, undefined));
439
+ return textFor(ctx, rawBody(result, undefined), options.expanded === true);
305
440
  }
306
441
  };
307
442
 
@@ -1,44 +0,0 @@
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
- }