@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 +4 -13
- package/package.json +1 -1
- package/src/core/paint.ts +14 -52
- package/src/core/types/host.ts +6 -0
- package/src/core/types/paint.ts +4 -1
- package/src/renderers/content/table.ts +11 -4
- package/src/renderers/tool/fallback.ts +3 -3
- package/src/renderers/tool/index.ts +15 -4
- package/src/renderers/tool/runtime.ts +154 -19
- 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 {
|
|
@@ -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
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* ink
|
|
76
|
-
*
|
|
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
|
|
82
|
-
*
|
|
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) =>
|
|
116
|
+
headerBand: (text) => reverseBand(header(text)),
|
|
155
117
|
};
|
|
156
118
|
}
|
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. */
|
package/src/core/types/paint.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
|
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);
|
|
@@ -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
|
|
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
|
|
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
|
|
267
|
-
|
|
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
|
-
|
|
274
|
-
|
|
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
|
|
291
|
-
|
|
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
|
|
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
|
-
}
|