@zenodinh/pi-render 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +4 -13
- package/package.json +1 -1
- package/src/core/paint.ts +11 -51
- package/src/core/types/host.ts +6 -0
- package/src/renderers/tool/fallback.ts +3 -3
- package/src/renderers/tool/index.ts +15 -4
- package/src/renderers/tool/runtime.ts +116 -12
- package/src/core/theme-holder.ts +0 -44
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.
|
|
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()
|
|
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
|
|
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.
|
|
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
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
|
|
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 {
|
|
@@ -70,79 +70,39 @@ export function createRowPaint(theme: HostTheme): RowPaint {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* ink
|
|
76
|
-
*
|
|
73
|
+
* The header band: the header ink with its cell's foreground and background swapped — SGR reverse video.
|
|
74
|
+
* Reversal is what makes the band safe on any theme: it preserves the theme's own ink-on-background
|
|
75
|
+
* contrast ratio instead of pairing an ink with a fill that was picked for something else, and it keeps
|
|
76
|
+
* the band theme-driven, because the header ink is the theme's own. No token, no construction-time
|
|
77
|
+
* choice (owner ruling 2026-10-09; issue #52).
|
|
77
78
|
*/
|
|
78
|
-
export type BandStyle = "theme" | "reverse" | "none";
|
|
79
|
-
|
|
80
79
|
/**
|
|
81
|
-
* SGR reverse video, on and off
|
|
82
|
-
*
|
|
83
|
-
* theme is reachable — a restore, or a session whose event context carried no theme.
|
|
80
|
+
* SGR reverse video, on and off — the ONE escape pair that originates here rather than in a theme.
|
|
81
|
+
* Reverse video needs no token, so it works on any theme and on a frame rendered before one arrives.
|
|
84
82
|
*/
|
|
85
83
|
const REVERSE_ON = "\x1b[7m";
|
|
86
84
|
const REVERSE_OFF = "\x1b[27m";
|
|
87
85
|
|
|
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. */
|
|
86
|
+
/** The band: reverse video over the header ink — the cell's own ink and background swapped. */
|
|
96
87
|
function reverseBand(text: string): string {
|
|
97
88
|
return `${REVERSE_ON}${text}${REVERSE_OFF}`;
|
|
98
89
|
}
|
|
99
90
|
|
|
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
91
|
/**
|
|
117
92
|
* Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
|
|
118
93
|
*
|
|
119
94
|
* The markdown theme's own closures supply the emphasis ink: `bold`/`italic` for inline spans, and its
|
|
120
95
|
* `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
96
|
*/
|
|
125
97
|
// shape: closure returning an object literal — trigger #4, eight stateless content roles over the
|
|
126
98
|
// captured markdown theme, band style and theme holder.
|
|
127
|
-
export function createContentPaint(
|
|
128
|
-
mdTheme: MarkdownTheme,
|
|
129
|
-
bandStyle: BandStyle = "theme",
|
|
130
|
-
holder?: ThemeHolder,
|
|
131
|
-
): ContentPaint {
|
|
99
|
+
export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
|
|
132
100
|
// The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
|
|
133
101
|
// degrades on its own, so a theme missing one still stamps whatever the other gives.
|
|
134
102
|
const header = (text: string): string => {
|
|
135
103
|
const inked = tryStyle(mdTheme.code, text) ?? text;
|
|
136
104
|
return tryStyle(mdTheme.bold, inked) ?? inked;
|
|
137
105
|
};
|
|
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
106
|
return {
|
|
147
107
|
rule: (text) => tryStyle(mdTheme.hr, text) ?? tryStyle(mdTheme.quoteBorder, text) ?? text,
|
|
148
108
|
quote: (text) => tryStyle(mdTheme.quote, text) ?? text,
|
|
@@ -151,6 +111,6 @@ export function createContentPaint(
|
|
|
151
111
|
strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
|
|
152
112
|
em: (text) => tryStyle(mdTheme.italic, text) ?? text,
|
|
153
113
|
header,
|
|
154
|
-
headerBand: (text) =>
|
|
114
|
+
headerBand: (text) => reverseBand(header(text)),
|
|
155
115
|
};
|
|
156
116
|
}
|
package/src/core/types/host.ts
CHANGED
|
@@ -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. */
|
|
@@ -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
|
|
93
|
-
//
|
|
94
|
-
//
|
|
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
|
-
/**
|
|
35
|
-
|
|
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):
|
|
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
|
-
|
|
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);
|
|
@@ -149,21 +149,38 @@ function logFailure(log: Logger, ctx: RenderContext, err: unknown): void {
|
|
|
149
149
|
log.logOnce(key, LOG_SCOPE, `row degraded: ${text}`);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/** The horizontal pad a row applies itself, in cells; a host that hands none means no padding. */
|
|
153
|
+
function padOf(ctx: RenderContext): number {
|
|
154
|
+
const pad = ctx.outputPad;
|
|
155
|
+
return typeof pad === "number" && Number.isFinite(pad) && pad > 0 ? Math.floor(pad) : 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A text component wearing this runtime's pad. The pad arrives on the ctx (once per invocation) while
|
|
160
|
+
* the indent is applied in render(width) (once per frame), so the component carries it across the two —
|
|
161
|
+
* and `setPad` is thereby the marker that says "this one is ours to reuse". The host spells its own
|
|
162
|
+
* padding setter `setPaddingX`, so a component the base renderer left in the slot never answers it.
|
|
163
|
+
*/
|
|
164
|
+
interface PaddedText extends UiComponent {
|
|
165
|
+
/** Replaces the displayed body. Required: the reuse protocol the host's lastComponent enables. */
|
|
166
|
+
setText(body: string): void;
|
|
167
|
+
/** Re-reads the row's indent, so a host padding change lands on the next frame. Required. */
|
|
168
|
+
setPad(pad: number): void;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** `in`-narrowed, never cast: a recorded component is the host's until it answers this protocol. */
|
|
172
|
+
function isPaddedText(component: UiComponent): component is PaddedText {
|
|
173
|
+
return "setPad" in component && typeof component.setPad === "function";
|
|
174
|
+
}
|
|
175
|
+
|
|
152
176
|
/** shape: closure returning an object literal — trigger #4: one stored text behind render/invalidate. */
|
|
153
|
-
function
|
|
154
|
-
const reuse = ctx.lastComponent;
|
|
155
|
-
if (reuse !== undefined) {
|
|
156
|
-
const setText = reuse.setText;
|
|
157
|
-
if (setText !== undefined) {
|
|
158
|
-
setText.call(reuse, body);
|
|
159
|
-
return reuse;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
177
|
+
function textComponent(body: string): UiComponent {
|
|
162
178
|
let current = body;
|
|
163
179
|
return {
|
|
164
180
|
render(_width: number): string[] {
|
|
165
181
|
// One entry per line; an empty body still yields one line, so a degraded row never vanishes.
|
|
166
182
|
// Width is the component's own: renderResult receives none, so nothing here is width-keyed.
|
|
183
|
+
// The offer minus the indent is handed down by the pad wrapper around this component.
|
|
167
184
|
return current.split("\n");
|
|
168
185
|
},
|
|
169
186
|
invalidate(): void {
|
|
@@ -175,9 +192,64 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
|
|
|
175
192
|
};
|
|
176
193
|
}
|
|
177
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The pad wrapper: every line the inner component draws takes the indent, and the inner component gets
|
|
197
|
+
* the offer minus it.
|
|
198
|
+
*
|
|
199
|
+
* Both halves are ours because a row asks the host for the "self" shell (#51), and that shell skips the
|
|
200
|
+
* Box(1,1) which used to indent each line and render its child at `width - paddingX * 2` (pi-tui
|
|
201
|
+
* box.js:83 → :91 → :115). The budget half is what a reused host `Text` needs: it wraps at the width it
|
|
202
|
+
* is given (pi-tui text.js:53-56), so a full-width title would wrap at the whole offer and then be
|
|
203
|
+
* pushed one column past it by the indent.
|
|
204
|
+
*
|
|
205
|
+
* shape: closure returning an object literal — trigger #4: one stored pad behind render/setPad.
|
|
206
|
+
*/
|
|
207
|
+
function padComponent(inner: UiComponent, pad: number): PaddedText {
|
|
208
|
+
let indent = pad;
|
|
209
|
+
return {
|
|
210
|
+
render(width: number): string[] {
|
|
211
|
+
const lines = inner.render(Math.max(0, width - indent));
|
|
212
|
+
if (indent === 0) return lines;
|
|
213
|
+
const spaces = " ".repeat(indent);
|
|
214
|
+
return lines.map((line) => spaces + line);
|
|
215
|
+
},
|
|
216
|
+
invalidate(): void {
|
|
217
|
+
inner.invalidate();
|
|
218
|
+
},
|
|
219
|
+
setText(body: string): void {
|
|
220
|
+
inner.setText?.(body);
|
|
221
|
+
},
|
|
222
|
+
setPad(value: number): void {
|
|
223
|
+
indent = value;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function textFor(ctx: RenderContext, body: string): UiComponent {
|
|
229
|
+
const pad = padOf(ctx);
|
|
230
|
+
const reuse = ctx.lastComponent;
|
|
231
|
+
// Only a component this runtime wrapped can be told a new pad, so it is the only one it reuses. A
|
|
232
|
+
// foreign one — the base renderer's, after a module toggle handed the row back — is replaced once;
|
|
233
|
+
// the host records what we return, so every later frame takes this branch.
|
|
234
|
+
if (reuse !== undefined && isPaddedText(reuse)) {
|
|
235
|
+
reuse.setText(body);
|
|
236
|
+
reuse.setPad(pad);
|
|
237
|
+
return reuse;
|
|
238
|
+
}
|
|
239
|
+
// A host text component in the slot is still worth mutating in place: it caches its own frame, and
|
|
240
|
+
// it is the one inner whose wrap the reduced budget reaches.
|
|
241
|
+
const setText = reuse?.setText;
|
|
242
|
+
if (reuse !== undefined && setText !== undefined) {
|
|
243
|
+
setText.call(reuse, body);
|
|
244
|
+
return padComponent(reuse, pad);
|
|
245
|
+
}
|
|
246
|
+
return padComponent(textComponent(body), pad);
|
|
247
|
+
}
|
|
248
|
+
|
|
178
249
|
/**
|
|
179
250
|
* The collapsed result slot's component: it draws no lines. The host stacks the call slot and the result
|
|
180
|
-
* slot inside
|
|
251
|
+
* slot inside the self shell's container, and that container concatenates each child's lines at the whole
|
|
252
|
+
* offer (pi-tui tui.js:129-140 — no padding of its own, which is why the pad is ours, #51), so an empty
|
|
181
253
|
* array adds nothing — the call line above is the whole collapsed row. Stateless, so one shared instance
|
|
182
254
|
* serves every row and every frame.
|
|
183
255
|
*
|
|
@@ -235,6 +307,35 @@ function scheduleDecorate(
|
|
|
235
307
|
);
|
|
236
308
|
}
|
|
237
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The host sets this the moment a call starts executing (host `core/extensions/types.d.ts`). The repo's
|
|
312
|
+
* declared seam omits it, so the undeclared field is `in`-narrowed before it is read — never asserted.
|
|
313
|
+
*/
|
|
314
|
+
function isExecutionStarted(ctx: RenderContext): boolean {
|
|
315
|
+
return "executionStarted" in ctx && ctx.executionStarted === true;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The runtime's one args rule, three arms — each one a host writer that settles when the arguments
|
|
320
|
+
* can be trusted:
|
|
321
|
+
*
|
|
322
|
+
* - `argsComplete === true`: the host flips it on the live streaming path (`setArgsComplete`).
|
|
323
|
+
* - `isPartial === false`: the host starts a component at `isPartial = true` and clears it only in
|
|
324
|
+
* `updateResult` (tool-execution.js:26/:130-132), so a false means a result was delivered and the
|
|
325
|
+
* arguments behind it are final — with one exception: on the abort path the host restores the row
|
|
326
|
+
* through `updateResult({errorMessage}, isError: true)`, whose default `isPartial = false` lands
|
|
327
|
+
* with the arguments still incomplete (interactive-mode.js:2873-2878 live / :3256 restore), so the
|
|
328
|
+
* arm may title from partial args. Strictly `=== false`, so an absent flag opens nothing.
|
|
329
|
+
* - `executionStarted === true`: the host starts executing a call only after its arguments are complete.
|
|
330
|
+
*
|
|
331
|
+
* A call rebuilt from a stored session runs none of those writers: it is constructed from the stored
|
|
332
|
+
* toolCall and `updateResult(message)`d (interactive-mode.js:3238-3273), so `argsComplete` and
|
|
333
|
+
* `executionStarted` stay false for its whole life and only the middle arm titles its row.
|
|
334
|
+
*/
|
|
335
|
+
function argsUsable(ctx: RenderContext): boolean {
|
|
336
|
+
return ctx.argsComplete === true || ctx.isPartial === false || isExecutionStarted(ctx);
|
|
337
|
+
}
|
|
338
|
+
|
|
238
339
|
export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory; log: Logger }): ToolRenderers {
|
|
239
340
|
const modelFor = (result: ToolResult, ctx: RenderContext): unknown => {
|
|
240
341
|
const content = Array.isArray(result.content) ? result.content : undefined;
|
|
@@ -263,8 +364,11 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
|
|
|
263
364
|
const renderCall = (args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent => {
|
|
264
365
|
try {
|
|
265
366
|
const paint = deps.paint(theme);
|
|
266
|
-
// A partial argument set is not a title
|
|
267
|
-
|
|
367
|
+
// A partial argument set is not a title. The spec is handed the args and the gate resolved
|
|
368
|
+
// together, so its own argsComplete check cannot shut the gate the runtime just opened; with
|
|
369
|
+
// the args unusable it sees {} behind a shut gate — a half-streamed value never titles.
|
|
370
|
+
const usable = argsUsable(ctx);
|
|
371
|
+
const model = spec.buildCall(usable ? args : {}, usable ? { ...ctx, argsComplete: true } : ctx);
|
|
268
372
|
// The name carries the call's three-state ink, and the order is the mapping's point: a failed call
|
|
269
373
|
// is never merely pending, so isError outranks isPartial; with neither flag the call is settled.
|
|
270
374
|
const ink = ctx.isError === true ? paint.error : ctx.isPartial === true ? paint.dim : paint.success;
|
package/src/core/theme-holder.ts
DELETED
|
@@ -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
|
-
}
|