@zenodinh/pi-render 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +24 -5
- package/package.json +1 -1
- package/src/core/paint.ts +62 -16
- package/src/core/theme-holder.ts +44 -0
- package/src/core/types/host.ts +17 -0
- package/src/core/types/paint.ts +23 -4
- package/src/renderers/content/artifacts/server.ts +6 -1
- package/src/renderers/content/heading.ts +105 -0
- package/src/renderers/content/index.ts +38 -27
- package/src/renderers/content/table.ts +35 -9
- package/src/renderers/content/types.ts +1 -1
- package/src/renderers/tool/fallback.ts +136 -0
- package/src/renderers/tool/index.ts +19 -3
- package/src/renderers/tool/runtime.ts +4 -1
- package/src/renderers/tool/specs/codemode.ts +32 -9
- package/src/renderers/tool/specs/read.ts +17 -15
- package/src/renderers/tool/specs/write.ts +12 -6
- package/themes/dracula-soft.json +3 -3
- package/themes/one-dark.json +3 -3
package/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { createLogger } from "./src/core/log.ts";
|
|
|
28
28
|
import { createContentPaint, createRowPaint } from "./src/core/paint.ts";
|
|
29
29
|
import { createRegistry } from "./src/core/registry.ts";
|
|
30
30
|
import { createSettingsStore } from "./src/core/settings.ts";
|
|
31
|
+
import { createThemeHolder } from "./src/core/theme-holder.ts";
|
|
32
|
+
import type { ThemeHolder } from "./src/core/types/paint.ts";
|
|
31
33
|
import type {
|
|
32
34
|
CodeTheme,
|
|
33
35
|
ContentPaint,
|
|
@@ -47,11 +49,13 @@ import {
|
|
|
47
49
|
} from "./src/renderers/content/artifacts/engines.ts";
|
|
48
50
|
import { type ArtifactServer, createArtifactServer } from "./src/renderers/content/artifacts/server.ts";
|
|
49
51
|
import { createCodePanel, mapFencedBlocks, panelWidth } from "./src/renderers/content/code-panel.ts";
|
|
52
|
+
import { heading } from "./src/renderers/content/heading.ts";
|
|
50
53
|
import { createImageCardSurface } from "./src/renderers/content/image-card.ts";
|
|
51
54
|
import { type ContentSurfaces, createContentTransformer } from "./src/renderers/content/index.ts";
|
|
52
55
|
import { createJsonPanel } from "./src/renderers/content/json-panel.ts";
|
|
53
56
|
import { mapHtmlLinkCards } from "./src/renderers/content/link-paragraph.ts";
|
|
54
57
|
import { table } from "./src/renderers/content/table.ts";
|
|
58
|
+
import { FALLBACK_MODULE } from "./src/renderers/tool/fallback.ts";
|
|
55
59
|
import { createMasterResolver, type SpecEntry } from "./src/renderers/tool/index.ts";
|
|
56
60
|
import { bashRow } from "./src/renderers/tool/specs/bash.ts";
|
|
57
61
|
import {
|
|
@@ -91,6 +95,7 @@ const ROW_SPECS: Record<string, SpecEntry> = Object.fromEntries(
|
|
|
91
95
|
* SA §6). The surface lanes export `rewrite` only, so their descriptors live here.
|
|
92
96
|
*/
|
|
93
97
|
const CONTENT_MODULES: readonly ModuleDescriptor[] = [
|
|
98
|
+
{ key: "content.heading", name: "Heading levels", defaultEnabled: true, settings: [] },
|
|
94
99
|
{ key: "content.table", name: "Table blocks", defaultEnabled: true, settings: [] },
|
|
95
100
|
{ key: "content.codePanel", name: "Code panels", defaultEnabled: true, settings: [] },
|
|
96
101
|
{ key: "content.jsonPanel", name: "JSON panels", defaultEnabled: true, settings: [] },
|
|
@@ -102,6 +107,9 @@ const CONTENT_MODULES: readonly ModuleDescriptor[] = [
|
|
|
102
107
|
const MODULES: readonly ModuleDescriptor[] = uniqueModules([
|
|
103
108
|
...ROW_RECORDS.map((row) => row.descriptor),
|
|
104
109
|
CODE_THEME_MODULE,
|
|
110
|
+
// The fallback row's key: a name-less module beside the code theme. Not a content surface and not a
|
|
111
|
+
// ROW_RECORDS entry — the resolver's catch-all is keyed on no tool name.
|
|
112
|
+
FALLBACK_MODULE,
|
|
105
113
|
...CONTENT_MODULES,
|
|
106
114
|
]);
|
|
107
115
|
|
|
@@ -265,7 +273,7 @@ async function installArtifacts(log: Logger, deps: BootDeps, holder: ArtifactsHo
|
|
|
265
273
|
}
|
|
266
274
|
|
|
267
275
|
/**
|
|
268
|
-
* Region 2's synchronous half:
|
|
276
|
+
* Region 2's synchronous half: six surfaces over lazy holders, then the one registerMarkdownTransformer
|
|
269
277
|
* call. It runs before piRender's first await, so the host's restored-message construction sees the
|
|
270
278
|
* transformer (see `piRender`); the shiki theme and the artifact cache/server stay cold until the
|
|
271
279
|
* awaited lanes fill them.
|
|
@@ -276,8 +284,10 @@ function registerContentSeam(
|
|
|
276
284
|
log: Logger,
|
|
277
285
|
codeTheme: CodeTheme,
|
|
278
286
|
artifacts: ArtifactsHolder,
|
|
287
|
+
holder: ThemeHolder,
|
|
279
288
|
): void {
|
|
280
289
|
const surfaces: ContentSurfaces = {
|
|
290
|
+
heading,
|
|
281
291
|
table,
|
|
282
292
|
codePanel: createCodePanel(codeTheme),
|
|
283
293
|
jsonPanel: createJsonPanel(codeTheme, { read: readLinkedFile, log }),
|
|
@@ -287,9 +297,13 @@ function registerContentSeam(
|
|
|
287
297
|
}),
|
|
288
298
|
artifacts: createArtifactsSurface(artifacts, log),
|
|
289
299
|
};
|
|
290
|
-
// The host's markdown theme is live per call, so its closures are captured once, here.
|
|
300
|
+
// The host's markdown theme is live per call, so its closures are captured once, here. The band's ink
|
|
301
|
+
// is the one input that arrives late, through the holder: the paint reads it per render.
|
|
291
302
|
pi.registerMarkdownTransformer(
|
|
292
|
-
createContentTransformer(registry, surfaces, {
|
|
303
|
+
createContentTransformer(registry, surfaces, {
|
|
304
|
+
paint: createContentPaint(getMarkdownTheme(), "theme", holder),
|
|
305
|
+
log,
|
|
306
|
+
}),
|
|
293
307
|
);
|
|
294
308
|
}
|
|
295
309
|
|
|
@@ -356,8 +370,11 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
|
|
|
356
370
|
// cache/server, which the awaited lanes below fill in.
|
|
357
371
|
const codeTheme = createLazyCodeTheme();
|
|
358
372
|
const artifacts: ArtifactsHolder = {};
|
|
373
|
+
// The band's theme reach: one long-lived handle, fed from the per-event UI context below and read
|
|
374
|
+
// per render by the content paint, so a theme switch repaints with no restart.
|
|
375
|
+
const themeHolder = createThemeHolder();
|
|
359
376
|
settleSync("rows", log, () => installRows(pi, registry, log));
|
|
360
|
-
settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts));
|
|
377
|
+
settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts, themeHolder));
|
|
361
378
|
|
|
362
379
|
// The fallible, environment-bound lanes run next and only fill the holders above.
|
|
363
380
|
await settle("artifacts", log, () => installArtifacts(log, deps, artifacts));
|
|
@@ -366,8 +383,10 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
|
|
|
366
383
|
// Collapse-first reading model (owner decision): every tool row starts on one line.
|
|
367
384
|
// The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
|
|
368
385
|
// interactive mode implements it (the runner's non-UI fallback is a no-op stub), so the
|
|
369
|
-
// call rides session_start: real in TUI, harmless in text/rpc.
|
|
386
|
+
// call rides session_start: real in TUI, harmless in text/rpc. The same event carries the
|
|
387
|
+
// live theme handle; the feed tolerates a context with no UI and a UI with no theme.
|
|
370
388
|
pi.on("session_start", (_event, ctx) => {
|
|
389
|
+
themeHolder.set(ctx.ui?.theme);
|
|
371
390
|
ctx.ui?.setToolsExpanded?.(false);
|
|
372
391
|
});
|
|
373
392
|
}
|
package/package.json
CHANGED
package/src/core/paint.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Boundary: renderers call role methods and never emit an escape (AGENTS: every escape originates
|
|
5
5
|
* here); both theme inputs are structural, so a plain-object fixture drives either factory with no
|
|
6
|
-
* host import.
|
|
6
|
+
* host import. The band's background ink is the one input that arrives late — through the theme
|
|
7
|
+
* holder — so it is read per call and falls back to reverse video when nothing was fed.
|
|
7
8
|
*
|
|
8
|
-
* shape: none
|
|
9
|
-
*
|
|
9
|
+
* shape: none at module level — a role is a token lookup with no discriminator. The two closure
|
|
10
|
+
* factories and the band-style dispatch inside the second one declare their own shape.
|
|
10
11
|
*
|
|
11
12
|
* ported from pi-pretty-tui/src/config.ts:71-100 — survives because: "read the theme token, degrade to
|
|
12
13
|
* default when it is absent" is what resolveBaseBackground did; its dead links (`toolBg`,
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import type { HostTheme, MarkdownTheme } from "./types/host.ts";
|
|
17
|
-
import type { ContentPaint, RowPaint } from "./types/paint.ts";
|
|
18
|
+
import type { ContentPaint, RowPaint, ThemeHolder } from "./types/paint.ts";
|
|
18
19
|
|
|
19
20
|
/** Host `fg` throws on a token the active theme lacks (`Theme.tokenAnsi`); a row degrades, never dies. */
|
|
20
21
|
function fgOrPlain(theme: HostTheme, key: string, text: string): string {
|
|
@@ -49,15 +50,17 @@ function tryStyle(fn: ((text: string) => string) | undefined, text: string): str
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
/** Region 1 — the tool-row factory. Reads `theme` live, so the next call paints the current theme. */
|
|
52
|
-
// shape: closure returning an object literal — trigger #4,
|
|
53
|
+
// shape: closure returning an object literal — trigger #4, thirteen stateless row roles over the captured theme.
|
|
53
54
|
export function createRowPaint(theme: HostTheme): RowPaint {
|
|
54
55
|
return {
|
|
55
56
|
title: (text) => fgOrPlain(theme, "toolTitle", text),
|
|
56
57
|
output: (text) => fgOrPlain(theme, "toolOutput", text),
|
|
57
58
|
muted: (text) => fgOrPlain(theme, "muted", text),
|
|
59
|
+
dim: (text) => fgOrPlain(theme, "dim", text),
|
|
58
60
|
accent: (text) => fgOrPlain(theme, "accent", text),
|
|
59
61
|
error: (text) => fgOrPlain(theme, "error", text),
|
|
60
62
|
warning: (text) => fgOrPlain(theme, "warning", text),
|
|
63
|
+
success: (text) => fgOrPlain(theme, "success", text),
|
|
61
64
|
gutter: (text) => fgOrPlain(theme, "muted", text),
|
|
62
65
|
match: (text) => matchOrAccent(theme, text),
|
|
63
66
|
diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
|
|
@@ -67,36 +70,79 @@ export function createRowPaint(theme: HostTheme): RowPaint {
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/**
|
|
70
|
-
* How the header band fills its cell: `"
|
|
71
|
-
*
|
|
73
|
+
* How the header band fills its cell: `"theme"` reads the ACTIVE theme's own background token (the
|
|
74
|
+
* default, and reverse video on any frame where no theme is reachable), `"reverse"` swaps the cell's
|
|
75
|
+
* ink and background (SGR reverse video), `"none"` leaves the bare header. Picked once at
|
|
76
|
+
* construction, so a side-by-side can sweep it.
|
|
72
77
|
*/
|
|
73
|
-
export type BandStyle = "reverse" | "none";
|
|
78
|
+
export type BandStyle = "theme" | "reverse" | "none";
|
|
74
79
|
|
|
75
80
|
/**
|
|
76
|
-
* SGR reverse video, on and off. The band
|
|
77
|
-
*
|
|
78
|
-
*
|
|
81
|
+
* SGR reverse video, on and off. The band's fallback and the ONE escape pair that originates here
|
|
82
|
+
* rather than in a theme: reverse video needs no token, so it survives a frame rendered before any
|
|
83
|
+
* theme is reachable — a restore, or a session whose event context carried no theme.
|
|
79
84
|
*/
|
|
80
85
|
const REVERSE_ON = "\x1b[7m";
|
|
81
86
|
const REVERSE_OFF = "\x1b[27m";
|
|
82
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The band's background token. The VALUE is never hard-coded here: it is read from the active theme,
|
|
90
|
+
* so the owner overrides the band by editing one string in their own theme document. `selectedBg` is
|
|
91
|
+
* a `colors` token both shipped themes carry and the host already keeps in its background family.
|
|
92
|
+
*/
|
|
93
|
+
const BAND_BG_TOKEN = "selectedBg";
|
|
94
|
+
|
|
95
|
+
/** The fallback band: reverse video over the header ink, needing no theme at all. */
|
|
96
|
+
function reverseBand(text: string): string {
|
|
97
|
+
return `${REVERSE_ON}${text}${REVERSE_OFF}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The band's ink from the live theme — read per call, so a theme switch repaints with no restart and
|
|
102
|
+
* no reload. `undefined` is the caller's fallback signal, and covers all three normal states: no
|
|
103
|
+
* theme was fed, the active theme lacks the token (the host throws `Unknown theme color`), and the
|
|
104
|
+
* host's theme proxy is read before initialisation.
|
|
105
|
+
*/
|
|
106
|
+
function themeBand(holder: ThemeHolder | undefined, text: string): string | undefined {
|
|
107
|
+
const theme = holder?.get();
|
|
108
|
+
if (theme === undefined) return undefined;
|
|
109
|
+
try {
|
|
110
|
+
return theme.bg(BAND_BG_TOKEN, text);
|
|
111
|
+
} catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
83
116
|
/**
|
|
84
117
|
* Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
|
|
85
118
|
*
|
|
86
119
|
* The markdown theme's own closures supply the emphasis ink: `bold`/`italic` for inline spans, and its
|
|
87
120
|
* `code` closure (the `mdCode` token, identical to `accent` in both stock themes) for the header ink.
|
|
88
|
-
* `bandStyle` picks the header band — `"
|
|
89
|
-
* `"
|
|
121
|
+
* `bandStyle` picks the header band — `"theme"` (the default) reads the active theme's background token
|
|
122
|
+
* through `holder`, `"reverse"` forces the theme-independent fallback, `"none"` leaves the header bare
|
|
123
|
+
* — so the band is a construction-time choice whose ink is read per frame.
|
|
90
124
|
*/
|
|
91
125
|
// shape: closure returning an object literal — trigger #4, eight stateless content roles over the
|
|
92
|
-
// captured markdown theme and
|
|
93
|
-
export function createContentPaint(
|
|
126
|
+
// captured markdown theme, band style and theme holder.
|
|
127
|
+
export function createContentPaint(
|
|
128
|
+
mdTheme: MarkdownTheme,
|
|
129
|
+
bandStyle: BandStyle = "theme",
|
|
130
|
+
holder?: ThemeHolder,
|
|
131
|
+
): ContentPaint {
|
|
94
132
|
// The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
|
|
95
133
|
// degrades on its own, so a theme missing one still stamps whatever the other gives.
|
|
96
134
|
const header = (text: string): string => {
|
|
97
135
|
const inked = tryStyle(mdTheme.code, text) ?? text;
|
|
98
136
|
return tryStyle(mdTheme.bold, inked) ?? inked;
|
|
99
137
|
};
|
|
138
|
+
// shape: dispatch object — trigger #1, three band styles on one discriminator, handlers stateless;
|
|
139
|
+
// `Record` makes a fourth style a compile error rather than a silently bare header.
|
|
140
|
+
const bands: Record<BandStyle, (inked: string) => string> = {
|
|
141
|
+
theme: (inked) => themeBand(holder, inked) ?? reverseBand(inked),
|
|
142
|
+
reverse: reverseBand,
|
|
143
|
+
none: (inked) => inked,
|
|
144
|
+
};
|
|
145
|
+
const band = bands[bandStyle];
|
|
100
146
|
return {
|
|
101
147
|
rule: (text) => tryStyle(mdTheme.hr, text) ?? tryStyle(mdTheme.quoteBorder, text) ?? text,
|
|
102
148
|
quote: (text) => tryStyle(mdTheme.quote, text) ?? text,
|
|
@@ -105,6 +151,6 @@ export function createContentPaint(mdTheme: MarkdownTheme, bandStyle: BandStyle
|
|
|
105
151
|
strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
|
|
106
152
|
em: (text) => tryStyle(mdTheme.italic, text) ?? text,
|
|
107
153
|
header,
|
|
108
|
-
headerBand: (text) => (
|
|
154
|
+
headerBand: (text) => band(header(text)),
|
|
109
155
|
};
|
|
110
156
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* theme-holder.ts — the band's reach into the live theme: fed per event, read per frame.
|
|
3
|
+
*
|
|
4
|
+
* Boundary: the extension API carries no theme member, so the handle arrives on the per-event UI
|
|
5
|
+
* context (`ctx.ui.theme`, a getter over the host's theme proxy) and the composition root feeds it
|
|
6
|
+
* here; the content paint reads it per invocation. Nothing here emits an escape and nothing here
|
|
7
|
+
* logs — an unfed holder is a normal state whose only consequence is the band's documented fallback.
|
|
8
|
+
*
|
|
9
|
+
* shape: closure returning an object literal — trigger #4, one mutable handle slot behind set/get.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { HostBackgroundTheme } from "./types/host.ts";
|
|
13
|
+
import type { ThemeHolder } from "./types/paint.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The feed crosses a host boundary, so it is checked rather than annotated. The member is READ, never
|
|
17
|
+
* probed with `in`: the host's live theme is `new Proxy({}, { get })` (`theme.js:515-522`), so `in`
|
|
18
|
+
* answers false even for a working theme, and any read before `initTheme()` throws. Both observed by
|
|
19
|
+
* executed probe against the installed host 1.0.4, 2026-10-08. Neither is an error to report: no
|
|
20
|
+
* theme is a normal state, and the band's fallback is its only consequence.
|
|
21
|
+
*/
|
|
22
|
+
function isBackgroundTheme(value: unknown): value is HostBackgroundTheme {
|
|
23
|
+
if (typeof value !== "object" || value === null) return false;
|
|
24
|
+
try {
|
|
25
|
+
return typeof Reflect.get(value, "bg") === "function";
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One handle slot: filled by the session-start feed, read by every band. */
|
|
32
|
+
export function createThemeHolder(): ThemeHolder {
|
|
33
|
+
// The handle is what is kept, never a resolved colour: caching an escape would freeze the band on
|
|
34
|
+
// the theme selected at load, which is the one defect this seam exists to avoid.
|
|
35
|
+
let current: HostBackgroundTheme | undefined;
|
|
36
|
+
return {
|
|
37
|
+
set(theme) {
|
|
38
|
+
current = isBackgroundTheme(theme) ? theme : undefined;
|
|
39
|
+
},
|
|
40
|
+
get() {
|
|
41
|
+
return current;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/core/types/host.ts
CHANGED
|
@@ -20,6 +20,18 @@ export interface HostTheme {
|
|
|
20
20
|
bold(text: string): string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The background-capable theme slice the header band reads. Structural host shape: the host's live
|
|
25
|
+
* theme is a proxy over the active theme, so any object answering `bg` qualifies.
|
|
26
|
+
*
|
|
27
|
+
* Declared apart from {@link HostTheme} on purpose — that type is what every row renderer and every
|
|
28
|
+
* golden fixture constructs, so a background member there would break call sites that draw no band.
|
|
29
|
+
*/
|
|
30
|
+
export interface HostBackgroundTheme {
|
|
31
|
+
/** Wraps text in a background token's escape, e.g. bg("selectedBg", "x"). Required. */
|
|
32
|
+
bg(key: string, text: string): string;
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
/** Live markdown-theme closures the content painter wraps. Structural host shape — no host import. */
|
|
24
36
|
export interface MarkdownTheme {
|
|
25
37
|
/** Horizontal-rule escape wrapper. Optional: a host without this token degrades to plain text. */
|
|
@@ -157,5 +169,10 @@ export interface EventContext {
|
|
|
157
169
|
ui?: {
|
|
158
170
|
/** Collapses (false) / expands (true) every tool row. The collapse-first default rides this. Optional. */
|
|
159
171
|
setToolsExpanded?(expanded: boolean): void;
|
|
172
|
+
/**
|
|
173
|
+
* The live theme handle — a getter over the host's theme proxy, so one read sees every later
|
|
174
|
+
* switch. Optional: it is the band ink's only reach, and the extension API carries no theme.
|
|
175
|
+
*/
|
|
176
|
+
theme?: HostBackgroundTheme;
|
|
160
177
|
};
|
|
161
178
|
}
|
package/src/core/types/paint.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* paint.ts — the painting contract: the two role sets every renderer draws through
|
|
2
|
+
* paint.ts — the painting contract: the two role sets every renderer draws through, plus the theme
|
|
3
|
+
* reach the header band reads.
|
|
3
4
|
*
|
|
4
5
|
* Boundary: region 1 is the tool row, region 2 is markdown content. Renderers call role methods
|
|
5
6
|
* and never emit an escape sequence themselves (AGENTS: escapes originate in core/paint.ts).
|
|
@@ -7,7 +8,7 @@
|
|
|
7
8
|
* shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
import type { HostTheme } from "./host.ts";
|
|
11
|
+
import type { HostBackgroundTheme, HostTheme } from "./host.ts";
|
|
11
12
|
|
|
12
13
|
/** Region 1 — the tool-row roles. Text in, theme-derived escape-wrapped text out. */
|
|
13
14
|
export interface RowPaint {
|
|
@@ -17,12 +18,16 @@ export interface RowPaint {
|
|
|
17
18
|
output(text: string): string;
|
|
18
19
|
/** De-emphasized metadata such as durations and counts. Required. */
|
|
19
20
|
muted(text: string): string;
|
|
21
|
+
/** The pending ink: a name still in flight, quieter than {@link muted}. Required. */
|
|
22
|
+
dim(text: string): string;
|
|
20
23
|
/** Accent for the active or highlighted element. Required. */
|
|
21
24
|
accent(text: string): string;
|
|
22
25
|
/** Error text on a failed call. Required. */
|
|
23
26
|
error(text: string): string;
|
|
24
27
|
/** Warning text for a degraded or partial result. Required. */
|
|
25
28
|
warning(text: string): string;
|
|
29
|
+
/** The success ink: a settled call that did not fail. Required. */
|
|
30
|
+
success(text: string): string;
|
|
26
31
|
/** The row's left gutter marker. Required. */
|
|
27
32
|
gutter(text: string): string;
|
|
28
33
|
/** A matched substring highlighted inside a result line. Required. */
|
|
@@ -41,6 +46,19 @@ export type RowPaintFactory = (
|
|
|
41
46
|
theme: HostTheme,
|
|
42
47
|
) => RowPaint;
|
|
43
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The band's theme reach: one long-lived handle, fed from the per-event UI context and read per frame.
|
|
51
|
+
*
|
|
52
|
+
* The HANDLE is what travels, never a resolved colour: the host's theme is a proxy over the active
|
|
53
|
+
* theme, so a cached escape would freeze the band on the theme selected at load.
|
|
54
|
+
*/
|
|
55
|
+
export interface ThemeHolder {
|
|
56
|
+
/** Stores a live theme handle; a value that cannot paint a background is stored as no theme. */
|
|
57
|
+
set(theme: unknown): void;
|
|
58
|
+
/** The handle for this frame, or undefined when none was fed — the band's fallback state. */
|
|
59
|
+
get(): HostBackgroundTheme | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
/** Region 2 — the markdown-content roles. */
|
|
45
63
|
export interface ContentPaint {
|
|
46
64
|
/** Horizontal rule; falls back hr → quoteBorder → plain rather than throw. Required. */
|
|
@@ -61,8 +79,9 @@ export interface ContentPaint {
|
|
|
61
79
|
*/
|
|
62
80
|
header(text: string): string;
|
|
63
81
|
/**
|
|
64
|
-
* One header-band cell: {@link header} carrying the band
|
|
65
|
-
*
|
|
82
|
+
* One header-band cell: {@link header} carrying the band's own background ink, read from the ACTIVE
|
|
83
|
+
* theme through the holder and therefore refreshed per render. Reverse video is the fallback for a
|
|
84
|
+
* frame with no theme reachable. Required.
|
|
66
85
|
*/
|
|
67
86
|
headerBand(text: string): string;
|
|
68
87
|
}
|
|
@@ -44,7 +44,8 @@ export type ArtifactServerState =
|
|
|
44
44
|
| { readonly ok: false; readonly reason: string };
|
|
45
45
|
|
|
46
46
|
export interface ArtifactServer {
|
|
47
|
-
/** Binds once on the loopback interface; a bind failure is reported, never thrown.
|
|
47
|
+
/** Binds once on the loopback interface; a bind failure is reported, never thrown. The listener
|
|
48
|
+
* never holds the process open — a headless run must be able to exit (#46). */
|
|
48
49
|
start(): Promise<ArtifactServerState>;
|
|
49
50
|
/** Releases the port and resets the state; idempotent. */
|
|
50
51
|
stop(): Promise<void>;
|
|
@@ -145,6 +146,10 @@ export function createArtifactServer(options: ArtifactServerOptions = {}): Artif
|
|
|
145
146
|
await listen(bound);
|
|
146
147
|
const address = bound.address();
|
|
147
148
|
if (address === null || typeof address === "string") throw new Error("no port was assigned");
|
|
149
|
+
// The listener serves the session; it must never be the reason the process outlives it. Bound but
|
|
150
|
+
// unref'd, it answers requests while anything else holds the loop, and a headless run exits the
|
|
151
|
+
// moment the prompt resolves (#46: `pi -p` printed its answer and then never exited).
|
|
152
|
+
bound.unref();
|
|
148
153
|
server = bound;
|
|
149
154
|
baseUrl = `http://${HOST}:${address.port}`;
|
|
150
155
|
return { ok: true, url: baseUrl };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heading.ts — the heading surface: levels 1–3 numbered at depth 2, level 4 and deeper demoted to bold.
|
|
3
|
+
*
|
|
4
|
+
* The message's shallowest heading is level 1; every heading's level is its depth relative to that, so
|
|
5
|
+
* a message opening at `###` still numbers from `1.` Levels 1–3 receive `1.` / `1.1.` / `1.1.1.` and are
|
|
6
|
+
* rewritten to depth 2; level 4 and deeper lose the number and the `#` markers and become bold text.
|
|
7
|
+
* The numbering is the substitute for a per-level heading token the host theme does not have: it carries
|
|
8
|
+
* exactly one heading token, and the host renderer re-inserts literal `#` characters from depth 3
|
|
9
|
+
* downward, so depth 2 is the cap that removes the marks (SA §1.3). Ink is the host's own job — this
|
|
10
|
+
* surface rewrites markdown text and never calls paint, or the line would be inked twice.
|
|
11
|
+
*
|
|
12
|
+
* Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back out;
|
|
13
|
+
* a message with no heading token returns the identical reference, so the host's render cache survives.
|
|
14
|
+
*
|
|
15
|
+
* shape: module-scope const object — trigger #3, one stateless Surface per process; the token walk and
|
|
16
|
+
* the counters hide behind `rewrite` (deep module) and are born per call, so no state crosses messages.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Marked, type Token } from "@earendil-works/pi-tui";
|
|
20
|
+
import type { TransformContext } from "../../core/types/host.ts";
|
|
21
|
+
import type { ContentPaint } from "../../core/types/paint.ts";
|
|
22
|
+
import type { Surface } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
interface HeadingLike {
|
|
25
|
+
depth: number;
|
|
26
|
+
text: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The depth every numbered heading is rewritten to — the cap that removes the host's `#` marks (SA §1.3). */
|
|
30
|
+
const NUMBERED_PREFIX = "##";
|
|
31
|
+
|
|
32
|
+
/** The last level that receives a number; deeper levels are demoted to bold text. */
|
|
33
|
+
const LAST_NUMBERED_LEVEL = 3;
|
|
34
|
+
|
|
35
|
+
const markdownParser = new Marked();
|
|
36
|
+
|
|
37
|
+
/** Marked parses untrusted markdown and its Token union carries a Generic member — the shape narrows, never `type` alone (BND-1). */
|
|
38
|
+
function isHeadingToken(token: Token): token is Token & HeadingLike {
|
|
39
|
+
return (
|
|
40
|
+
token.type === "heading" &&
|
|
41
|
+
"depth" in token &&
|
|
42
|
+
typeof token.depth === "number" &&
|
|
43
|
+
"text" in token &&
|
|
44
|
+
typeof token.text === "string"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The hierarchical number for one relative level: siblings increment, deeper levels reset, a level
|
|
50
|
+
* skipped on the way down reads as 1, and a return to a shallower level resumes that level's counter
|
|
51
|
+
* (PRD 7.1 extensions 3a/3b). `counters` is per message and never shared between calls.
|
|
52
|
+
*/
|
|
53
|
+
function levelNumber(counters: number[], level: number): string {
|
|
54
|
+
for (let skipped = 0; skipped < level - 1; skipped++) {
|
|
55
|
+
if (counters[skipped] === 0) counters[skipped] = 1;
|
|
56
|
+
}
|
|
57
|
+
counters[level - 1] = (counters[level - 1] ?? 0) + 1;
|
|
58
|
+
for (let deeper = level; deeper < counters.length; deeper++) counters[deeper] = 0;
|
|
59
|
+
return `${counters.slice(0, level).join(".")}.`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** shape: module-scope const object — trigger #3, one stateless heading surface per process. */
|
|
63
|
+
export const heading: Surface = { rewrite };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* WHY a token walk and never a line scan: the parser emits a fenced block as ONE code token and emits
|
|
67
|
+
* no heading token for a `#` inside a table cell, so both hazards are unreachable by construction; a
|
|
68
|
+
* line scanner would have to re-implement fence tracking to reach the same safety. Setext headings are
|
|
69
|
+
* heading tokens and are numbered like any other — the source form does not decide the treatment.
|
|
70
|
+
*
|
|
71
|
+
* Markdown-position rule (the non-obvious one): unchanged tokens are spliced back by their own raw
|
|
72
|
+
* text, and a heading token's raw carries its trailing newline only at end of input — the blank line
|
|
73
|
+
* between blocks lives in the following space token's raw — so the replacement keeps that newline and
|
|
74
|
+
* every untouched block stays at its own position, byte-identical.
|
|
75
|
+
*/
|
|
76
|
+
function rewrite(markdown: string, _ctx: TransformContext, _paint: ContentPaint): string {
|
|
77
|
+
const tokens = markdownParser.lexer(markdown);
|
|
78
|
+
const depths = tokens.filter(isHeadingToken).map((token) => token.depth);
|
|
79
|
+
// No heading token: the input itself returns, by reference — an equal copy would invalidate the
|
|
80
|
+
// host's render cache, and joining raw text is not byte-exact anyway (Marked eats CRLF).
|
|
81
|
+
if (depths.length === 0) return markdown;
|
|
82
|
+
|
|
83
|
+
// Numbering is message-local: the shallowest heading present is level 1, and the counters are born
|
|
84
|
+
// inside this call, so nothing survives to the next message.
|
|
85
|
+
const minDepth = Math.min(...depths);
|
|
86
|
+
const counters = [0, 0, 0];
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
for (const token of tokens) {
|
|
89
|
+
if (!isHeadingToken(token)) {
|
|
90
|
+
parts.push(token.raw);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const level = token.depth - minDepth + 1;
|
|
94
|
+
const trailing = /\n+$/.exec(token.raw)?.[0] ?? "";
|
|
95
|
+
// `token.text` holds the heading's inline markup as source, so prefixing the number keeps
|
|
96
|
+
// code spans and bold alive (PRD 7.1 extension 4b); the demoted form is markdown bold, which
|
|
97
|
+
// the host inks on its own — no escape may originate here.
|
|
98
|
+
parts.push(
|
|
99
|
+
level <= LAST_NUMBERED_LEVEL
|
|
100
|
+
? `${NUMBERED_PREFIX} ${levelNumber(counters, level)}${token.text === "" ? "" : ` ${token.text}`}${trailing}`
|
|
101
|
+
: `**${token.text}**${trailing}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return parts.join("");
|
|
105
|
+
}
|
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* index.ts — the markdown transformer:
|
|
2
|
+
* index.ts — the markdown transformer: per-surface gate, per-surface registry check, per-surface
|
|
3
3
|
* isolation, dispatch, identity return, and one keyed line for every skip.
|
|
4
4
|
*
|
|
5
|
+
* The gate is per-surface (owner D5): the heading pass runs on a settled assistant or user message; the
|
|
6
|
+
* four markdown surfaces and the late fence pass stay assistant-only, so a user's pasted markdown is not
|
|
7
|
+
* restyled by them. A disabled surface is skipped; a surface that throws is isolated to itself and the
|
|
8
|
+
* message keeps every other rewrite; the input string returns by reference whenever no surface changed it
|
|
9
|
+
* — the host's own caching reads that reference. What the pass cannot do, it names: a closed gate and a
|
|
10
|
+
* table that comes out unboxed each leave one line keyed on the reason.
|
|
11
|
+
*
|
|
5
12
|
* Boundary: the host calls this on the transcript render path, so nothing here throws and nothing
|
|
6
|
-
* allocates per message.
|
|
7
|
-
* the message keeps every other rewrite; the input string returns by reference whenever no surface
|
|
8
|
-
* changed it — the host's own caching reads that reference. What the pass cannot do, it names: a closed
|
|
9
|
-
* gate and a table that comes out unboxed each leave one line keyed on the reason, so a live report
|
|
10
|
-
* reads its own cause instead of a screenshot.
|
|
13
|
+
* allocates per message.
|
|
11
14
|
*
|
|
12
15
|
* shape: closure returning a function — trigger #4, the factory captures its injected collaborators
|
|
13
16
|
* (registry, surfaces, paint, log) and holds no state to classify.
|
|
14
17
|
*
|
|
15
|
-
* ported from pi-pretty-tui/src/features/canvas/transformer.ts — survives because: the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* ported from pi-pretty-tui/src/features/canvas/transformer.ts — survives because: the degrade-to-the-input-verbatim
|
|
19
|
+
* fallback and mermaid staying with the host's own transformer are the host-aligned behaviors that file got
|
|
20
|
+
* right. Its parser, settings load, and per-form render code do not port: each surface owns one rewrite here,
|
|
21
|
+
* and the registry owns every toggle. Its single try/catch around the whole pass does not port either: a
|
|
22
|
+
* failure belongs to the surface that threw.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import type { TransformContext } from "../../core/types/host.ts";
|
|
@@ -28,8 +31,9 @@ import type { Surface, SurfaceKey } from "./types.ts";
|
|
|
28
31
|
/** Registry key of the artifacts fence pass — T-28 injects its surface when artifacts land (T-25/T-26). */
|
|
29
32
|
const ARTIFACTS_KEY = "artifacts";
|
|
30
33
|
|
|
31
|
-
/** Dispatch order: the
|
|
32
|
-
|
|
34
|
+
/** Dispatch order: the heading pass first (it rewrites text, so it must not see an already-boxed table),
|
|
35
|
+
* then the four markdown surfaces; the artifacts fence pass runs last, over their output. */
|
|
36
|
+
const SURFACE_ORDER: readonly SurfaceKey[] = ["heading", "table", "codePanel", "jsonPanel", "imageCard"];
|
|
33
37
|
|
|
34
38
|
const SCOPE = "content";
|
|
35
39
|
|
|
@@ -87,36 +91,43 @@ export function createContentTransformer(
|
|
|
87
91
|
};
|
|
88
92
|
|
|
89
93
|
return (markdown, ctx) => {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
// Per-surface gate (owner D5): the heading pass admits a settled user message — the host already
|
|
95
|
+
// delivers it (user-message.js builds its transform with messageType: "user") — while the four
|
|
96
|
+
// markdown surfaces and the late fence pass stay assistant-only. The key carries the messageType
|
|
97
|
+
// alone (three values), so a whole session reports each gate reason once.
|
|
98
|
+
const settled = !ctx.isStreaming;
|
|
99
|
+
const assistant = settled && ctx.messageType === "assistant";
|
|
100
|
+
const headingOnly = settled && ctx.messageType === "user";
|
|
101
|
+
// The gate line explains the table surface, which is skipped on every non-assistant input.
|
|
102
|
+
if (!assistant && TABLE_SHAPE.test(markdown)) {
|
|
103
|
+
log.logOnce(
|
|
104
|
+
`content:gate:${ctx.messageType}`,
|
|
105
|
+
SCOPE,
|
|
106
|
+
`table-shaped content skipped: messageType=${ctx.messageType} isStreaming=${ctx.isStreaming}`,
|
|
107
|
+
);
|
|
101
108
|
}
|
|
109
|
+
if (!assistant && !headingOnly) return markdown;
|
|
102
110
|
|
|
103
111
|
let out = markdown;
|
|
104
112
|
// The step that last changed the text: the surface a table should have come from, named by the
|
|
105
113
|
// decline line below. Undefined means every surface passed its input straight through.
|
|
106
114
|
let lastTouched: string | undefined;
|
|
107
115
|
for (const key of SURFACE_ORDER) {
|
|
116
|
+
// The heading pass is the one surface a settled user message runs; the rest stay assistant-only.
|
|
117
|
+
if (key !== "heading" && !assistant) continue;
|
|
108
118
|
const step = run(`content.${key}`, surfaces[key], out, ctx);
|
|
109
119
|
if (step !== out) lastTouched = `content.${key}`;
|
|
110
120
|
out = step;
|
|
111
121
|
}
|
|
112
|
-
const fenced = run(`content.${ARTIFACTS_KEY}`, artifacts, out, ctx);
|
|
122
|
+
const fenced = assistant ? run(`content.${ARTIFACTS_KEY}`, artifacts, out, ctx) : out;
|
|
113
123
|
if (fenced !== out) lastTouched = `content.${ARTIFACTS_KEY}`;
|
|
114
124
|
out = fenced;
|
|
115
125
|
|
|
116
126
|
// A table that reaches the end of the pass without a box rule was declined by every surface that saw
|
|
117
127
|
// it — the one case where the pipeline succeeded and the box still did not draw. The key carries the
|
|
118
|
-
// input length, so a channel that keeps repeating one table declines once.
|
|
119
|
-
|
|
128
|
+
// input length, so a channel that keeps repeating one table declines once. Assistant-only, because it
|
|
129
|
+
// is only on that branch that the table surface could have drawn the box.
|
|
130
|
+
if (assistant && TABLE_SHAPE.test(markdown) && !out.includes(TABLE_BOX_RULE)) {
|
|
120
131
|
log.logOnce(
|
|
121
132
|
`content:decline:table:${markdown.length}`,
|
|
122
133
|
SCOPE,
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* table.ts — the table surface: a markdown table becomes a banded box.
|
|
9
9
|
*
|
|
10
|
-
* The header row is stamped through the header-band role — bold over the accent ink inside a
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* The header row is stamped through the header-band role — bold over the accent ink inside a
|
|
11
|
+
* full-bleed band spanning each cell's whole interior plus its trailing separator, so the header
|
|
12
|
+
* reads as one bar (FP-11) — or, when the header row is empty (`| | |`), that stamp moves onto the
|
|
13
|
+
* first column so no blank band is spent. `**strong**` and `*em*` cells route through their own
|
|
14
|
+
* roles.
|
|
13
15
|
*
|
|
14
16
|
* Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
|
|
15
17
|
* out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
|
|
@@ -36,8 +38,6 @@ interface TableToken {
|
|
|
36
38
|
|
|
37
39
|
/** Per-cell padding plus the bars: below this a column cannot hold its own frame. */
|
|
38
40
|
const MIN_COLUMN = 6;
|
|
39
|
-
/** A column wider than this is a paragraph, not a table cell. */
|
|
40
|
-
const MAX_COLUMN = 60;
|
|
41
41
|
|
|
42
42
|
const markdownParser = new Marked();
|
|
43
43
|
|
|
@@ -100,7 +100,7 @@ function padCell(content: string, width: number): string {
|
|
|
100
100
|
return fit + " ".repeat(Math.max(0, width - visibleWidth(fit)));
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
/** shape: none — one greedy width solve; a table is small and the rule is exact. */
|
|
103
|
+
/** shape: none — one greedy width solve plus a largest-remainder fill; a table is small and the rule is exact. */
|
|
104
104
|
function renderTable(token: TableToken, width: number, paint: ContentPaint): string[] {
|
|
105
105
|
const header = token.header.map((cell) => cellText(cell, paint));
|
|
106
106
|
const rows = token.rows.map((row) => row.map((cell) => cellText(cell, paint)));
|
|
@@ -108,7 +108,7 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
108
108
|
const natural: number[] = [];
|
|
109
109
|
for (let index = 0; index < columns; index++) {
|
|
110
110
|
const cells = [header[index] ?? "", ...rows.map((row) => row[index] ?? "")];
|
|
111
|
-
natural.push(Math.
|
|
111
|
+
natural.push(Math.max(MIN_COLUMN, ...cells.map((cell) => visibleWidth(cell))));
|
|
112
112
|
}
|
|
113
113
|
const widths = [...natural];
|
|
114
114
|
// Width budget: per-cell padding (2 each), the column bars, and the two outer bars.
|
|
@@ -119,6 +119,28 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
119
119
|
widths[widest] = (widths[widest] ?? 0) - 1;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Fill the reading column: a short table grows to the clamp so its frame edge lines up with the
|
|
123
|
+
// block above it. Slack spreads in proportion to natural width — largest remainder, ties by column
|
|
124
|
+
// order. Invariant that keeps this safe: a shrink step lowers the total by exactly one and the loop
|
|
125
|
+
// exits the moment the total equals the offer, while its other exit leaves every column at
|
|
126
|
+
// MIN_COLUMN with the total still above it — so a positive slack proves the shrink loop never
|
|
127
|
+
// iterated, and fill and shrink can never both run.
|
|
128
|
+
const slack = width - total();
|
|
129
|
+
if (slack > 0) {
|
|
130
|
+
const naturalTotal = natural.reduce((sum, column) => sum + column, 0);
|
|
131
|
+
const shares = natural.map((column, index) => {
|
|
132
|
+
const exact = (slack * column) / naturalTotal;
|
|
133
|
+
return { index, floor: Math.floor(exact), remainder: exact - Math.floor(exact) };
|
|
134
|
+
});
|
|
135
|
+
for (const { index, floor } of shares) widths[index] = (widths[index] ?? 0) + floor;
|
|
136
|
+
let leftover = slack - shares.reduce((sum, { floor }) => sum + floor, 0);
|
|
137
|
+
for (const { index } of shares.sort((a, b) => b.remainder - a.remainder)) {
|
|
138
|
+
if (leftover <= 0) break;
|
|
139
|
+
widths[index] = (widths[index] ?? 0) + 1;
|
|
140
|
+
leftover--;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
122
144
|
// An all-empty header (`| | |`) means the table carries its labels in the first column: the blank
|
|
123
145
|
// header row and its separator are dropped, and the band moves onto that first column instead.
|
|
124
146
|
const keyColumn = header.every((cell) => cell === "");
|
|
@@ -137,9 +159,13 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
137
159
|
for (let line = 0; line < height; line++) {
|
|
138
160
|
const parts = wrapped.map((lines, index) => {
|
|
139
161
|
const padded = padCell(lines[line] ?? "", widths[index] ?? MIN_COLUMN);
|
|
140
|
-
|
|
162
|
+
// Full-bleed unit (FP-11): the cell's whole interior — both padding columns — plus its
|
|
163
|
+
// trailing separator (the bar keeps its rule ink inside the band), so banded cells form one
|
|
164
|
+
// unbroken bar; the last cell has no trailing separator, so its band ends at its own padding.
|
|
165
|
+
const unit = index < cells.length - 1 ? ` ${padded} ${bar}` : ` ${padded} `;
|
|
166
|
+
return banded(index) ? paint.headerBand(unit) : unit;
|
|
141
167
|
});
|
|
142
|
-
physical.push(`${bar}${parts.join(
|
|
168
|
+
physical.push(`${bar}${parts.join("")}${bar}`);
|
|
143
169
|
}
|
|
144
170
|
};
|
|
145
171
|
|
|
@@ -11,7 +11,7 @@ import type { TransformContext } from "../../core/types/host.ts";
|
|
|
11
11
|
import type { ContentPaint } from "../../core/types/paint.ts";
|
|
12
12
|
|
|
13
13
|
/** Surface registry keys. The module key is "content." + the key, e.g. "content.table". */
|
|
14
|
-
export type SurfaceKey = "table" | "codePanel" | "jsonPanel" | "imageCard";
|
|
14
|
+
export type SurfaceKey = "heading" | "table" | "codePanel" | "jsonPanel" | "imageCard";
|
|
15
15
|
|
|
16
16
|
/** One markdown rewrite stage: the input string returns by reference whenever nothing matched. */
|
|
17
17
|
export interface Surface {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fallback.ts — the fallback row: one collapsed line in the package's own style for any tool name
|
|
3
|
+
* neither the table nor the chain draws.
|
|
4
|
+
*
|
|
5
|
+
* Boundary: the master resolver composes this when the chain's answer has no usable slot, gated on
|
|
6
|
+
* the row.fallback key; this module registers nothing and claims no name — it answers inside the
|
|
7
|
+
* resolver chain. Arguments and result blocks are host payloads, narrowed field by field. An argument
|
|
8
|
+
* shape with no string value degrades to the name alone and logs one line keyed on the call id.
|
|
9
|
+
*
|
|
10
|
+
* shape: closure returning the row runtime's renderers — trigger #4: the private spec captures the
|
|
11
|
+
* tool name and the keyed logger, and drawing reuses createRowRenderers, so no second renderer path
|
|
12
|
+
* and no spec file exist for it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { RenderContext, ToolRenderers, ToolResult } from "../../core/types/host.ts";
|
|
16
|
+
import type { Logger } from "../../core/types/log.ts";
|
|
17
|
+
import type { RowPaint, RowPaintFactory } from "../../core/types/paint.ts";
|
|
18
|
+
import type { ModuleDescriptor } from "../../core/types/registry.ts";
|
|
19
|
+
import { createRowRenderers } from "./runtime.ts";
|
|
20
|
+
import type { CallModel, RowSpec, RowView } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
const INDENT = " ";
|
|
23
|
+
const HINT = "ctrl+o to expand";
|
|
24
|
+
/** The hint stays short: the collapsed row is one line, and the host's own default already shows more. */
|
|
25
|
+
const HINT_MAX = 60;
|
|
26
|
+
/** read.ts's one-metadata-line cap: a longer message is the expanded body's job. */
|
|
27
|
+
const SUMMARY_MAX = 120;
|
|
28
|
+
const SCOPE = "tool.fallback";
|
|
29
|
+
|
|
30
|
+
/** The descriptor the composition root registers for the /render panel; exported here, never registered here. */
|
|
31
|
+
// shape: object literal — trigger #7, fields known statically (the CODE_THEME_MODULE precedent).
|
|
32
|
+
export const FALLBACK_MODULE: ModuleDescriptor = {
|
|
33
|
+
key: "row.fallback",
|
|
34
|
+
name: "Fallback row",
|
|
35
|
+
defaultEnabled: true,
|
|
36
|
+
settings: [],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** What build parses once: the result's text plus the wrapper's own failure flag. */
|
|
40
|
+
interface FallbackModel {
|
|
41
|
+
/** The result's text blocks joined in host order, newlines normalized; "" when it carries none. */
|
|
42
|
+
text: string;
|
|
43
|
+
/** The result wrapper's failure flag; ctx's flag reaches project fresh through view.isError. */
|
|
44
|
+
failed: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** boundary: content blocks are host payloads; only "text" blocks with a string body are read. */
|
|
48
|
+
function textOf(result: ToolResult): string {
|
|
49
|
+
const blocks = result.content;
|
|
50
|
+
if (!Array.isArray(blocks)) return "";
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
for (const block of blocks) {
|
|
53
|
+
if (typeof block !== "object" || block === null) continue;
|
|
54
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
55
|
+
}
|
|
56
|
+
return parts.join("\n").replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* boundary: arguments are host payloads; only a non-empty string value is hint text. The hint draws
|
|
61
|
+
* from the argument text the host itself displays — never from a file the arguments merely name.
|
|
62
|
+
*/
|
|
63
|
+
function hintOf(args: Record<string, unknown>): string | undefined {
|
|
64
|
+
for (const value of Object.values(args)) {
|
|
65
|
+
if (typeof value !== "string") continue;
|
|
66
|
+
const flat = value.replace(/\s+/g, " ").trim();
|
|
67
|
+
if (flat === "") continue;
|
|
68
|
+
return flat.length > HINT_MAX ? `${flat.slice(0, HINT_MAX - 1)}…` : flat;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The call id when the host gave one; the tool name keeps a nameless row's line once-per-name. */
|
|
74
|
+
function logKey(ctx: RenderContext, toolName: string): string {
|
|
75
|
+
return typeof ctx.toolCallId === "string" ? ctx.toolCallId : `args:${toolName}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function capSummary(text: string): string {
|
|
79
|
+
return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The failure summary's head: the first non-blank line, or one word when the result carried none. */
|
|
83
|
+
function failureHead(lines: readonly string[]): string {
|
|
84
|
+
return capSummary(lines.find((line) => line.trim() !== "") ?? "failed");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// shape: closure returning an object literal — trigger #4: buildCall captures the tool name and the
|
|
88
|
+
// keyed logger; the three contract fields keep no per-row state beyond that capture.
|
|
89
|
+
function createFallbackSpec(toolName: string, log: Logger): RowSpec<FallbackModel> {
|
|
90
|
+
return {
|
|
91
|
+
buildCall(args, ctx): CallModel {
|
|
92
|
+
// The runtime's argsComplete gate hands buildCall {} until the host says arguments are
|
|
93
|
+
// final, so zero keys covers a gated row and a genuinely argument-less tool alike —
|
|
94
|
+
// neither is an unexpected shape, so neither logs.
|
|
95
|
+
if (Object.keys(args).length === 0) return { title: toolName };
|
|
96
|
+
const hint = hintOf(args);
|
|
97
|
+
if (hint === undefined) {
|
|
98
|
+
log.logOnce(logKey(ctx, toolName), SCOPE, `unrecognized argument shape for ${toolName} — name alone`);
|
|
99
|
+
return { title: toolName };
|
|
100
|
+
}
|
|
101
|
+
return { title: `${toolName}: ${hint}` };
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
build(result): FallbackModel {
|
|
105
|
+
return { text: textOf(result), failed: result.isError === true };
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
project(m: FallbackModel, view: RowView, paint: RowPaint): string {
|
|
109
|
+
// Either failure flag means an error row (read.ts's contract): the wrapper's is parsed
|
|
110
|
+
// once, ctx's arrives fresh per frame through the view.
|
|
111
|
+
const failed = m.failed || view.isError;
|
|
112
|
+
// Trailing whitespace is a terminator, not a line — the count and the body agree on that.
|
|
113
|
+
const body = m.text.replace(/\s+$/, "");
|
|
114
|
+
const lines = body === "" ? [] : body.split("\n");
|
|
115
|
+
const stats = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`;
|
|
116
|
+
if (!view.expanded) {
|
|
117
|
+
// Collapse-first: exactly one line, which the runtime hands to the call slot.
|
|
118
|
+
if (!failed) return paint.muted(`${stats} · ${HINT}`);
|
|
119
|
+
return `${paint.error(failureHead(lines))}${paint.muted(` · ${HINT}`)}`;
|
|
120
|
+
}
|
|
121
|
+
if (lines.length === 0) return failed ? paint.error("failed") : paint.muted(stats);
|
|
122
|
+
const painted = lines.map((line) =>
|
|
123
|
+
line.trim() === "" ? "" : `${INDENT}${failed ? paint.error(line) : paint.output(line)}`,
|
|
124
|
+
);
|
|
125
|
+
return [paint.muted(stats), "", ...painted].join("\n");
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The fallback row's two slots, built by the shared row runtime; the resolver composes the shell. */
|
|
131
|
+
export function createFallbackRenderers(
|
|
132
|
+
toolName: string,
|
|
133
|
+
deps: { paint: RowPaintFactory; log: Logger },
|
|
134
|
+
): ToolRenderers {
|
|
135
|
+
return createRowRenderers(createFallbackSpec(toolName, deps.log), deps);
|
|
136
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* index.ts — the master resolver: the tool-name table, the captured host base,
|
|
3
|
-
* per invocation — the pluggable toggle for every tool row
|
|
2
|
+
* index.ts — the master resolver: the tool-name table, the captured host base, one registry check
|
|
3
|
+
* per invocation — the pluggable toggle for every tool row — and the gated fallback row for names
|
|
4
|
+
* neither the table nor the chain draws.
|
|
4
5
|
*
|
|
5
6
|
* Boundary: the host consults this ONCE per row component construction, keyed on tool name alone, and
|
|
6
7
|
* `next()` exists only at that moment; the base renderers it hands back are captured here and reused for
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
ToolResult,
|
|
27
28
|
UiComponent,
|
|
28
29
|
} from "../../core/types.ts";
|
|
30
|
+
import { createFallbackRenderers, FALLBACK_MODULE } from "./fallback.ts";
|
|
29
31
|
import { createRowRenderers } from "./runtime.ts";
|
|
30
32
|
import type { RowSpec } from "./types.ts";
|
|
31
33
|
|
|
@@ -61,6 +63,11 @@ function withoutComponent(ctx: RenderContext): RenderContext {
|
|
|
61
63
|
return { ...ctx, lastComponent: undefined };
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** A chain answer the host can draw: either slot suffices; a definition with neither is a peer shape the host defaults. */
|
|
67
|
+
function hasUsableSlot(base: ToolRenderers | undefined): boolean {
|
|
68
|
+
return base !== undefined && (base.renderCall !== undefined || base.renderResult !== undefined);
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
// shape: closure returning a resolver function — trigger #4: one runtime binding per spec is captured by
|
|
65
72
|
// the factory; the resolver allocates only what a single row construction needs.
|
|
66
73
|
export function createMasterResolver(
|
|
@@ -85,7 +92,16 @@ export function createMasterResolver(
|
|
|
85
92
|
|
|
86
93
|
return (toolName, next) => {
|
|
87
94
|
const entry = runtimes.get(toolName);
|
|
88
|
-
if (entry === undefined)
|
|
95
|
+
if (entry === undefined) {
|
|
96
|
+
// Delegation is load-bearing: the chain answers FIRST, so a peer or the host still wins a
|
|
97
|
+
// name it owns; the gate is consulted only at the point the fallback would answer.
|
|
98
|
+
const base = next();
|
|
99
|
+
// Two-shape trigger: the host draws its own default both for a name with no definition and
|
|
100
|
+
// for a definition shipping neither slot — the second is the shape an installed peer's tool
|
|
101
|
+
// takes, which an emptiness-only test would miss and leave the reported symptom in place.
|
|
102
|
+
if (hasUsableSlot(base) || !registry.isEnabled(FALLBACK_MODULE.key)) return base;
|
|
103
|
+
return { renderShell: SHELL, ...createFallbackRenderers(toolName, deps) };
|
|
104
|
+
}
|
|
89
105
|
const base = next(); // captured here and only here — next() is gone once this resolver returns
|
|
90
106
|
const key = entry.key; // explicit: search's four names share "row.search" (T-20 ruling)
|
|
91
107
|
const runtime = entry.renderers;
|
|
@@ -265,7 +265,10 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
|
|
|
265
265
|
const paint = deps.paint(theme);
|
|
266
266
|
// A partial argument set is not a title: buildCall sees {} until the host says argsComplete.
|
|
267
267
|
const model = spec.buildCall(ctx.argsComplete === true ? args : {}, ctx);
|
|
268
|
-
|
|
268
|
+
// The name carries the call's three-state ink, and the order is the mapping's point: a failed call
|
|
269
|
+
// is never merely pending, so isError outranks isPartial; with neither flag the call is settled.
|
|
270
|
+
const ink = ctx.isError === true ? paint.error : ctx.isPartial === true ? paint.dim : paint.success;
|
|
271
|
+
const title = ink(model.title);
|
|
269
272
|
// Collapsed rows are one line: the summary the result slot settled rides this title line.
|
|
270
273
|
const summary = ctx.expanded === true ? undefined : readSummary(ctx);
|
|
271
274
|
return textFor(ctx, summary === undefined ? title : `${title} · ${summary}`);
|
|
@@ -53,13 +53,22 @@ const MAX_ERROR_SUMMARY = 80;
|
|
|
53
53
|
/** The host writes the header as its own text block, ending at "Output:" — dist/extensions/codemode/renderer.js:14. */
|
|
54
54
|
const SCRIPT_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n/;
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
/** One status's two marks: the icon glyph, the ink the glyph takes, and the state role the name takes. */
|
|
57
|
+
type StatusMark = {
|
|
58
|
+
readonly glyph: string;
|
|
59
|
+
/** Brighter than the state role, because a glyph reads as a mark, not as text. */
|
|
60
|
+
readonly glyphInk: (paint: RowPaint) => (text: string) => string;
|
|
61
|
+
/** The line's name ink: the state vocabulary's role for this status (issue #41). */
|
|
62
|
+
readonly nameInk: (paint: RowPaint) => (text: string) => string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// shape: dispatch object — trigger #1, four stateless mark handlers on one status discriminator.
|
|
66
|
+
// One table holds both marks, so a line cannot show a finished call beside a pending name (issue #41).
|
|
67
|
+
const STATUS_MARK: Record<CallStatus, StatusMark> = {
|
|
68
|
+
running: { glyph: "…", glyphInk: (paint) => paint.warning, nameInk: (paint) => paint.dim },
|
|
69
|
+
ok: { glyph: "✓", glyphInk: (paint) => paint.accent, nameInk: (paint) => paint.success },
|
|
70
|
+
error: { glyph: "✗", glyphInk: (paint) => paint.error, nameInk: (paint) => paint.error },
|
|
71
|
+
cancelled: { glyph: "⊘", glyphInk: (paint) => paint.muted, nameInk: (paint) => paint.muted },
|
|
63
72
|
};
|
|
64
73
|
|
|
65
74
|
// shape: fixed-shape record — trigger #7, three pure stages over the model above, no state.
|
|
@@ -156,6 +165,8 @@ function errorRow(model: CodemodeModel, view: RowView, paint: RowPaint): string
|
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
function expandedBody(model: CodemodeModel, view: RowView, paint: RowPaint): string {
|
|
168
|
+
// R3-07 + issue #41: each nested line states its own call's status, so a finished call never takes
|
|
169
|
+
// the row's pending ink beside its own "✓".
|
|
159
170
|
const rows: string[] = [];
|
|
160
171
|
for (const call of model.calls) rows.push(...callLines(call, paint));
|
|
161
172
|
// A streaming script's output is not final: its calls stream, its body waits (the host's contract).
|
|
@@ -170,7 +181,7 @@ function expandedBody(model: CodemodeModel, view: RowView, paint: RowPaint): str
|
|
|
170
181
|
}
|
|
171
182
|
|
|
172
183
|
function callLines(call: NestedCall, paint: RowPaint): string[] {
|
|
173
|
-
let line = `${statusIcon(call.status, paint)} ${paint
|
|
184
|
+
let line = `${statusIcon(call.status, paint)} ${callInk(call.status, paint)(call.name)}`;
|
|
174
185
|
if (call.args !== "") line += ` ${paint.muted(clip(call.args, MAX_ARGS_PREVIEW))}`;
|
|
175
186
|
const duration = formatDuration(call.durationMs);
|
|
176
187
|
if (duration !== "") line += ` ${paint.muted(duration)}`;
|
|
@@ -198,8 +209,20 @@ function aggregateStatus(model: CodemodeModel, isPartial: boolean): CallStatus {
|
|
|
198
209
|
return isPartial ? "running" : "ok";
|
|
199
210
|
}
|
|
200
211
|
|
|
212
|
+
/** A status's mark, or undefined for a status the host did not declare. */
|
|
213
|
+
function statusMark(status: string): StatusMark | undefined {
|
|
214
|
+
return isCallStatus(status) ? STATUS_MARK[status] : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The status icon, its glyph inked. An unknown status reads as the muted "?". */
|
|
201
218
|
function statusIcon(status: string, paint: RowPaint): string {
|
|
202
|
-
|
|
219
|
+
const mark = statusMark(status);
|
|
220
|
+
return mark === undefined ? paint.muted("?") : mark.glyphInk(paint)(mark.glyph);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The name ink on a nested line: the call's own status, not the row's (issue #41). */
|
|
224
|
+
function callInk(status: string, paint: RowPaint): (text: string) => string {
|
|
225
|
+
return statusMark(status)?.nameInk(paint) ?? paint.muted;
|
|
203
226
|
}
|
|
204
227
|
|
|
205
228
|
/** A switch, not a table lookup: an inherited member such as "toString" must not read as a status. */
|
|
@@ -61,11 +61,11 @@ const SUMMARY_MAX = 120;
|
|
|
61
61
|
/** The host's user-limit notice — the one trailer it appends with no details.truncation block (read.js:132-137). */
|
|
62
62
|
const MORE_LINES_NOTICE = /^\[\d+ more lines in file\. Use offset=\d+ to continue\.\]$/;
|
|
63
63
|
|
|
64
|
-
/** The lead text per classification,
|
|
64
|
+
/** The lead text per classification: a name-like lead takes the state ink, the marker keeps accent. */
|
|
65
65
|
// shape: dispatch object — trigger #1, three branches on `header`, handlers stateless.
|
|
66
|
-
const HEADER: Record<HeaderKind, (paint: RowPaint) => string> = {
|
|
67
|
-
path: (
|
|
68
|
-
docs: (
|
|
66
|
+
const HEADER: Record<HeaderKind, (paint: RowPaint, ink: (text: string) => string) => string> = {
|
|
67
|
+
path: (_paint, ink) => ink("→ read"),
|
|
68
|
+
docs: (_paint, ink) => ink("→ read docs"),
|
|
69
69
|
skill: (paint) => paint.accent("[skill]"),
|
|
70
70
|
};
|
|
71
71
|
|
|
@@ -104,23 +104,25 @@ function build(result: ToolResult, ctx: RenderContext): ReadModel | "unknown" {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
function project(m: ReadModel, view: RowView, paint: RowPaint): string {
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
// R3-07: a name-like label takes the row name's state ink, so one line never shows two inks for it.
|
|
108
|
+
const ink = view.isError ? paint.error : view.isPartial ? paint.dim : paint.success;
|
|
109
|
+
if (m.kind === "error") return view.expanded ? errorBody(m, paint) : errorSummary(m, paint, ink);
|
|
110
|
+
return view.expanded ? fileBody(m, paint, ink) : fileSummary(m, paint, ink);
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
/** `→ read <label>` / `→ read docs <label>` / `[skill] <name>`, with the range when the read starts late. */
|
|
112
|
-
function header(m: FileModel, paint: RowPaint): string {
|
|
114
|
+
function header(m: FileModel, paint: RowPaint, ink: (text: string) => string): string {
|
|
113
115
|
const range = m.offset > 1 ? paint.muted(`:${m.offset}`) : "";
|
|
114
|
-
const label = m.label === "" ? "" : ` ${
|
|
115
|
-
return `${HEADER[m.header](paint)}${label}${range}`;
|
|
116
|
+
const label = m.label === "" ? "" : ` ${ink(m.label)}`;
|
|
117
|
+
return `${HEADER[m.header](paint, ink)}${label}${range}`;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
function fileSummary(m: FileModel, paint: RowPaint): string {
|
|
120
|
+
function fileSummary(m: FileModel, paint: RowPaint, ink: (text: string) => string): string {
|
|
119
121
|
const lines = m.lines.length;
|
|
120
|
-
return `${header(m, paint)} ${paint.muted(`${lines} ${lines === 1 ? "line" : "lines"} — ctrl+o to expand`)}`;
|
|
122
|
+
return `${header(m, paint, ink)} ${paint.muted(`${lines} ${lines === 1 ? "line" : "lines"} — ctrl+o to expand`)}`;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
function fileBody(m: FileModel, paint: RowPaint): string {
|
|
125
|
+
function fileBody(m: FileModel, paint: RowPaint, ink: (text: string) => string): string {
|
|
124
126
|
// A fixed gutter width keeps the numbers aligned across frames; 3 is the predecessor's floor.
|
|
125
127
|
const width = Math.max(3, String(m.offset + m.lines.length - 1).length);
|
|
126
128
|
const numbered = m.lines.map(
|
|
@@ -129,13 +131,13 @@ function fileBody(m: FileModel, paint: RowPaint): string {
|
|
|
129
131
|
);
|
|
130
132
|
const hint = m.header === "skill" ? ` ${paint.muted("ctrl+o to collapse")}` : "";
|
|
131
133
|
const notice = m.notice === undefined ? [] : [paint.warning(m.notice)];
|
|
132
|
-
return [header(m, paint) + hint, ...numbered, ...notice].join("\n");
|
|
134
|
+
return [header(m, paint, ink) + hint, ...numbered, ...notice].join("\n");
|
|
133
135
|
}
|
|
134
136
|
|
|
135
|
-
function errorSummary(m: ErrorModel, paint: RowPaint): string {
|
|
137
|
+
function errorSummary(m: ErrorModel, paint: RowPaint, ink: (text: string) => string): string {
|
|
136
138
|
const first = m.lines.find((line) => line.trim() !== "") ?? "failed";
|
|
137
139
|
const message = first.length > SUMMARY_MAX ? `${first.slice(0, SUMMARY_MAX - 1)}…` : first;
|
|
138
|
-
const label = m.label === "" ? "" : ` ${
|
|
140
|
+
const label = m.label === "" ? "" : ` ${ink(m.label)}`;
|
|
139
141
|
return `${paint.error("→ read")}${label} ${paint.error(message)}`;
|
|
140
142
|
}
|
|
141
143
|
|
|
@@ -76,8 +76,12 @@ function firstLine(text: string): string {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** shape: none — a two-value expansion branch with one string body each. */
|
|
79
|
-
function writtenLine(
|
|
80
|
-
|
|
79
|
+
function writtenLine(
|
|
80
|
+
m: Extract<WriteModel, { kind: "written" }>,
|
|
81
|
+
paint: RowPaint,
|
|
82
|
+
ink: (text: string) => string,
|
|
83
|
+
): string {
|
|
84
|
+
const head = m.display ? `${ICON} ${ink(m.display)}` : ICON;
|
|
81
85
|
// No content yet (args still streaming) is the pinned special case: no fabricated counts.
|
|
82
86
|
if (m.content === undefined) return `${head} ${paint.muted("· writing…")}`;
|
|
83
87
|
return `${head} ${paint.muted(`(${m.lines} lines · ${m.bytes} bytes)`)}`;
|
|
@@ -94,8 +98,8 @@ function writtenBody(m: Extract<WriteModel, { kind: "written" }>, paint: RowPain
|
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
/** shape: none — a single collapsed line; error text is truncated, never re-wrapped. */
|
|
97
|
-
function errorLine(m: Extract<WriteModel, { kind: "error" }>, paint: RowPaint): string {
|
|
98
|
-
const head = m.display ? `${ICON} ${
|
|
101
|
+
function errorLine(m: Extract<WriteModel, { kind: "error" }>, paint: RowPaint, ink: (text: string) => string): string {
|
|
102
|
+
const head = m.display ? `${ICON} ${ink(m.display)} ` : "";
|
|
99
103
|
return `${head}${paint.error(`${ERROR_MARK} ${firstLine(m.error)}`)}`;
|
|
100
104
|
}
|
|
101
105
|
|
|
@@ -127,8 +131,10 @@ function build(result: ToolResult, ctx: RenderContext): WriteModel | "unknown" {
|
|
|
127
131
|
|
|
128
132
|
/** shape: none — two straight-line branches (state, then model kind); no dispatch object. */
|
|
129
133
|
function project(m: WriteModel, view: RowView, paint: RowPaint): string {
|
|
130
|
-
|
|
131
|
-
|
|
134
|
+
// R3-07: the path is name-like text, so it takes the row name's state ink (error / dim / success).
|
|
135
|
+
const ink = view.isError ? paint.error : view.isPartial ? paint.dim : paint.success;
|
|
136
|
+
if (m.kind === "error") return view.expanded ? errorBody(m, paint) : errorLine(m, paint, ink);
|
|
137
|
+
return view.expanded ? writtenBody(m, paint) : writtenLine(m, paint, ink);
|
|
132
138
|
}
|
|
133
139
|
|
|
134
140
|
export const writeRow: {
|
package/themes/dracula-soft.json
CHANGED
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"customMessageBg": "#403d53",
|
|
38
38
|
"customMessageText": "muted",
|
|
39
39
|
"customMessageLabel": "purple",
|
|
40
|
-
"toolPendingBg": "
|
|
41
|
-
"toolSuccessBg": "
|
|
42
|
-
"toolErrorBg": "
|
|
40
|
+
"toolPendingBg": "",
|
|
41
|
+
"toolSuccessBg": "",
|
|
42
|
+
"toolErrorBg": "",
|
|
43
43
|
"toolTitle": "text",
|
|
44
44
|
"toolOutput": "muted",
|
|
45
45
|
"mdHeading": "purple",
|
package/themes/one-dark.json
CHANGED
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"customMessageBg": "#41384f",
|
|
37
37
|
"customMessageText": "muted",
|
|
38
38
|
"customMessageLabel": "purple",
|
|
39
|
-
"toolPendingBg": "
|
|
40
|
-
"toolSuccessBg": "
|
|
41
|
-
"toolErrorBg": "
|
|
39
|
+
"toolPendingBg": "",
|
|
40
|
+
"toolSuccessBg": "",
|
|
41
|
+
"toolErrorBg": "",
|
|
42
42
|
"toolTitle": "text",
|
|
43
43
|
"toolOutput": "muted",
|
|
44
44
|
"mdHeading": "red",
|