@zenodinh/pi-render 0.1.0 → 0.1.3
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/README.md +20 -6
- package/index.ts +29 -33
- package/package.json +6 -2
- package/src/core/paint.ts +17 -2
- package/src/core/types/host.ts +15 -18
- package/src/core/types.ts +0 -1
- package/src/renderers/content/artifacts/cards.ts +13 -6
- package/src/renderers/content/code-panel.ts +53 -31
- package/src/renderers/content/index.ts +70 -21
- package/src/renderers/content/json-panel.ts +9 -17
- package/src/renderers/content/link-paragraph.ts +153 -0
- package/src/renderers/content/table.ts +4 -1
- package/src/renderers/tool/runtime.ts +44 -5
- package/src/renderers/tool/specs/search.ts +18 -10
- package/src/renderers/tool/types.ts +6 -1
- package/src/commands/canvas.test.ts +0 -150
- package/src/commands/canvas.ts +0 -57
- package/src/core/code-theme.test.ts +0 -266
- package/src/core/log.test.ts +0 -127
- package/src/core/paint.test.ts +0 -322
- package/src/core/registry.test.ts +0 -307
- package/src/core/settings.test.ts +0 -183
- package/src/renderers/content/artifacts/artifacts.test.ts +0 -199
- package/src/renderers/content/artifacts/cards.test.ts +0 -216
- package/src/renderers/content/artifacts/engines-extra.test.ts +0 -556
- package/src/renderers/content/artifacts/local-binary.test.ts +0 -207
- package/src/renderers/content/image-card.test.ts +0 -170
- package/src/renderers/content/panels.test.ts +0 -188
- package/src/renderers/content/table.test.ts +0 -209
- package/src/renderers/content/transformer.test.ts +0 -254
- package/src/renderers/tool/resolver.test.ts +0 -257
- package/src/renderers/tool/runtime.test.ts +0 -313
- package/src/renderers/tool/specs/bash.test.ts +0 -110
- package/src/renderers/tool/specs/codemode.test.ts +0 -212
- package/src/renderers/tool/specs/edit.test.ts +0 -260
- package/src/renderers/tool/specs/ls.test.ts +0 -173
- package/src/renderers/tool/specs/read.test.ts +0 -340
- package/src/renderers/tool/specs/search.test.ts +0 -197
- package/src/renderers/tool/specs/write.test.ts +0 -145
- package/themes/themes.test.ts +0 -251
package/README.md
CHANGED
|
@@ -4,10 +4,13 @@ A render-only presentation layer for **pi**: styled tool rows and a richer answe
|
|
|
4
4
|
canvas, with zero tool-name registrations. pi-render draws; the host and its peers
|
|
5
5
|
execute.
|
|
6
6
|
|
|
7
|
-
It registers one row renderer resolver and one markdown transformer
|
|
8
|
-
built-in tool rows collapsed to a single line, and
|
|
9
|
-
panels, diagram fences, and images inside assistant
|
|
10
|
-
schema, no prompt text, and no execution wrapper.
|
|
7
|
+
It registers one row renderer resolver and one markdown transformer — and no
|
|
8
|
+
commands at all — draws eight built-in tool rows collapsed to a single line, and
|
|
9
|
+
renders tables, code/JSON panels, diagram fences, and images inside assistant
|
|
10
|
+
answers. It adds no tool schema, no prompt text, and no execution wrapper.
|
|
11
|
+
|
|
12
|
+
It is on by default: install it and styled output appears, with nothing to
|
|
13
|
+
configure and nothing to invoke.
|
|
11
14
|
|
|
12
15
|
Replaces [pi-pretty-tui](https://github.com/zenodinh/pi-pretty-tui), fixing
|
|
13
16
|
[#21](https://github.com/zenodinh/pi-pretty-tui/issues/21) (expand freeze) and
|
|
@@ -52,8 +55,19 @@ Every tool row renders collapsed to one line, with errors summarized on that lin
|
|
|
52
55
|
`ctrl+o` — or a click — expands the row; expanding a call again reuses the cached
|
|
53
56
|
render, so there is no re-parse and no freeze.
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
Nothing to invoke: pi-render registers no commands, so the `/canvas` status
|
|
59
|
+
command is gone. The artifact surfaces it reported on are unchanged — the engines
|
|
60
|
+
and the cache still warm at boot, and diagram fences still render as cards.
|
|
61
|
+
|
|
62
|
+
### Turning a module off
|
|
63
|
+
|
|
64
|
+
Every module is on by default. A module can still be disabled through the one
|
|
65
|
+
settings document, `~/.pi/agent/pi-render/settings.json`; only that module's
|
|
66
|
+
surface falls back to the host's own rendering, and the others are unaffected:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{ "version": 1, "modules": { "content.table": { "enabled": false } } }
|
|
70
|
+
```
|
|
57
71
|
|
|
58
72
|
## What you get, and what changes
|
|
59
73
|
|
package/index.ts
CHANGED
|
@@ -1,43 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* index.ts — the composition root: the package's only file that calls pi.*.
|
|
3
3
|
*
|
|
4
|
-
* Why one file: every lane ends in a plain value (a resolver, a transformer, a
|
|
4
|
+
* Why one file: every lane ends in a plain value (a resolver, a transformer, a surface), so boot is a
|
|
5
5
|
* table of contents — build the registry, compose the two tables, register once per seam, and warm the
|
|
6
6
|
* engines the synchronous seams call. Each lane installs in its own try/catch: a broken lane costs
|
|
7
7
|
* itself, never the session (SA §2). The predecessor's ordering lore does not port — with zero
|
|
8
8
|
* registrations there is no inter-extension race left to sequence around.
|
|
9
9
|
*
|
|
10
|
+
* The command surface is empty on purpose (Req 2): the whole host surface is one tool resolver, one
|
|
11
|
+
* markdown transformer and one `session_start` hook — nothing for the user to invoke.
|
|
12
|
+
*
|
|
10
13
|
* Boundary: the host arrives as the parameter. Nothing here parses markdown, draws a row or decides
|
|
11
|
-
* module state; the one adapter this file owns is the artifacts fence
|
|
14
|
+
* module state; the one adapter this file owns is the artifacts pass — the fence walk and the local
|
|
15
|
+
* `.html` link walk — which no lane supplies.
|
|
12
16
|
*
|
|
13
17
|
* shape: none — wiring only: `piRender` is the runtime unit, and every install below is straight-line.
|
|
14
18
|
*/
|
|
15
19
|
|
|
16
20
|
import { readFileSync } from "node:fs";
|
|
17
|
-
import { isAbsolute, resolve } from "node:path";
|
|
21
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
18
22
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import { installCommands } from "./src/commands/canvas.ts";
|
|
20
23
|
import { CODE_THEME_MODULE, createCodeTheme, createShikiEngine } from "./src/core/code-theme.ts";
|
|
21
24
|
import { createLogger } from "./src/core/log.ts";
|
|
22
25
|
import { createContentPaint, createRowPaint } from "./src/core/paint.ts";
|
|
23
26
|
import { createRegistry } from "./src/core/registry.ts";
|
|
24
27
|
import { createSettingsStore } from "./src/core/settings.ts";
|
|
25
|
-
import type {
|
|
26
|
-
CommandContext,
|
|
27
|
-
ContentPaint,
|
|
28
|
-
ExtensionApi,
|
|
29
|
-
Logger,
|
|
30
|
-
ModuleDescriptor,
|
|
31
|
-
Registry,
|
|
32
|
-
Surface,
|
|
33
|
-
} from "./src/core/types.ts";
|
|
28
|
+
import type { ContentPaint, ExtensionApi, Logger, ModuleDescriptor, Registry, Surface } from "./src/core/types.ts";
|
|
34
29
|
import { type ArtifactCache, createArtifactCache, renderCacheKey } from "./src/renderers/content/artifacts/cache.ts";
|
|
35
30
|
import { artifactTitle, renderArtifactCard } from "./src/renderers/content/artifacts/cards.ts";
|
|
36
31
|
import {
|
|
37
32
|
type DiagramForm,
|
|
38
33
|
type EngineStatus,
|
|
39
34
|
renderDiagram,
|
|
40
|
-
status,
|
|
41
35
|
warmup,
|
|
42
36
|
} from "./src/renderers/content/artifacts/engines.ts";
|
|
43
37
|
import { type ArtifactServer, createArtifactServer } from "./src/renderers/content/artifacts/server.ts";
|
|
@@ -45,6 +39,7 @@ import { createCodePanel, mapFencedBlocks, panelWidth } from "./src/renderers/co
|
|
|
45
39
|
import { createImageCardSurface } from "./src/renderers/content/image-card.ts";
|
|
46
40
|
import { type ContentSurfaces, createContentTransformer } from "./src/renderers/content/index.ts";
|
|
47
41
|
import { createJsonPanel } from "./src/renderers/content/json-panel.ts";
|
|
42
|
+
import { mapHtmlLinkCards } from "./src/renderers/content/link-paragraph.ts";
|
|
48
43
|
import { table } from "./src/renderers/content/table.ts";
|
|
49
44
|
import { createMasterResolver, type SpecEntry } from "./src/renderers/tool/index.ts";
|
|
50
45
|
import { bashRow } from "./src/renderers/tool/specs/bash.ts";
|
|
@@ -107,7 +102,7 @@ function uniqueModules(descriptors: readonly ModuleDescriptor[]): ModuleDescript
|
|
|
107
102
|
}
|
|
108
103
|
|
|
109
104
|
// ---------------------------------------------------------------------------
|
|
110
|
-
// The artifacts
|
|
105
|
+
// The artifacts pass — the one adapter this file owns (SA §6)
|
|
111
106
|
// ---------------------------------------------------------------------------
|
|
112
107
|
|
|
113
108
|
/** Raster width the cache key is computed from; pi-tui's own image width (P transformer.ts:68). */
|
|
@@ -153,10 +148,21 @@ function createArtifactsSurface(cache: ArtifactCache, server: ArtifactServer, lo
|
|
|
153
148
|
return {
|
|
154
149
|
rewrite(markdown, ctx, paint) {
|
|
155
150
|
const width = panelWidth(ctx.availableWidth);
|
|
156
|
-
const
|
|
151
|
+
const fenced = mapFencedBlocks(markdown, (lang, code) =>
|
|
157
152
|
isArtifactForm(lang) ? card(lang, code, width, paint) : undefined,
|
|
158
153
|
);
|
|
159
|
-
|
|
154
|
+
// The fence walk may have replaced a whole block, so the link walk runs on its output and the
|
|
155
|
+
// message itself is what it falls back to when neither pass matched.
|
|
156
|
+
const fencedText = fenced.changed ? fenced.text : markdown;
|
|
157
|
+
// A local `.html` named only by a link needs no inline bytes: the pass gates on existence and
|
|
158
|
+
// the server streams the file on click, so the render path never reads it.
|
|
159
|
+
const links = mapHtmlLinkCards(fencedText, {
|
|
160
|
+
projectDir: PROJECT_DIR,
|
|
161
|
+
log,
|
|
162
|
+
card: ({ path }) =>
|
|
163
|
+
renderArtifactCard(server, { typeLabel: "html", title: basename(path), path, width }, paint),
|
|
164
|
+
});
|
|
165
|
+
return links.changed ? links.text : fencedText;
|
|
160
166
|
},
|
|
161
167
|
};
|
|
162
168
|
}
|
|
@@ -217,20 +223,6 @@ async function installContent(
|
|
|
217
223
|
);
|
|
218
224
|
}
|
|
219
225
|
|
|
220
|
-
/** The /canvas view: what the artifact engines can draw this session. The browser pane is T-32's. */
|
|
221
|
-
function canvasView(ctx: CommandContext): Promise<void> {
|
|
222
|
-
const ready = Object.entries(status())
|
|
223
|
-
.filter(([, on]) => on)
|
|
224
|
-
.map(([id]) => id);
|
|
225
|
-
ctx.ui.notify(`canvas: artifact engines ready — ${ready.join(", ") || "none"}`);
|
|
226
|
-
return Promise.resolve();
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Region 9: the one command, which reaches the rest of the package through the registry alone (SA §7). */
|
|
230
|
-
function installCanvasCommand(pi: ExtensionApi, registry: Registry): void {
|
|
231
|
-
installCommands(pi, { registry, canvasView });
|
|
232
|
-
}
|
|
233
|
-
|
|
234
226
|
/** shape: none — one guarded call; a lane reports itself and boot carries on. */
|
|
235
227
|
async function settle<T>(lane: string, log: Logger, install: () => T | Promise<T>): Promise<T | undefined> {
|
|
236
228
|
try {
|
|
@@ -272,8 +264,12 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
|
|
|
272
264
|
await settle("rows", log, () => installRows(pi, registry, log));
|
|
273
265
|
const artifacts = await settle("artifacts", log, () => installArtifacts(log, deps));
|
|
274
266
|
await settle("content", log, () => installContent(pi, registry, log, artifacts));
|
|
275
|
-
await settle("commands", log, () => installCanvasCommand(pi, registry));
|
|
276
267
|
|
|
277
268
|
// Collapse-first reading model (owner decision): every tool row starts on one line.
|
|
278
|
-
|
|
269
|
+
// The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
|
|
270
|
+
// interactive mode implements it (the runner's non-UI fallback is a no-op stub), so the
|
|
271
|
+
// call rides session_start: real in TUI, harmless in text/rpc.
|
|
272
|
+
pi.on("session_start", (_event, ctx) => {
|
|
273
|
+
ctx.ui?.setToolsExpanded?.(false);
|
|
274
|
+
});
|
|
279
275
|
}
|
package/package.json
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"index.ts",
|
|
12
12
|
"src",
|
|
13
|
-
"themes"
|
|
13
|
+
"themes",
|
|
14
|
+
"!src/**/*.test.ts",
|
|
15
|
+
"!src/**/__snapshots__",
|
|
16
|
+
"!test",
|
|
17
|
+
"!themes/*.test.ts"
|
|
14
18
|
],
|
|
15
19
|
"scripts": {
|
|
16
20
|
"check": "tsc --noEmit",
|
|
@@ -63,5 +67,5 @@
|
|
|
63
67
|
"typescript": "^7.0.2",
|
|
64
68
|
"vitest": "^5.0.3"
|
|
65
69
|
},
|
|
66
|
-
"version": "0.1.
|
|
70
|
+
"version": "0.1.3"
|
|
67
71
|
}
|
package/src/core/paint.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* here); both theme inputs are structural, so a plain-object fixture drives either factory with no
|
|
6
6
|
* host import.
|
|
7
7
|
*
|
|
8
|
-
* shape: none — dispatch object does not apply: no discriminator,
|
|
8
|
+
* shape: none — dispatch object does not apply: no discriminator, every role is a token lookup. The
|
|
9
9
|
* two closure factories below declare their own shape.
|
|
10
10
|
*
|
|
11
11
|
* ported from pi-pretty-tui/src/config.ts:71-100 — survives because: "read the theme token, degrade to
|
|
@@ -25,6 +25,21 @@ function fgOrPlain(theme: HostTheme, key: string, text: string): string {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The match ink: the theme's `searchMatchText`, or `accent` when that ink is the line's own body ink.
|
|
30
|
+
*
|
|
31
|
+
* The `system` theme resolves both tokens into its `neutral` family (its bundled palettes do the same),
|
|
32
|
+
* so a match painted with `searchMatchText` is invisible inside the line it belongs to. Styling the
|
|
33
|
+
* same text with both tokens compares the two inks directly; a collision — including a `searchMatchText`
|
|
34
|
+
* that is absent or throws, where `fgOrPlain` degrades it to plain — falls to `accent`, which every
|
|
35
|
+
* theme keeps distinct from body text. Never throws: each lookup already ends in plain text.
|
|
36
|
+
*/
|
|
37
|
+
function matchOrAccent(theme: HostTheme, text: string): string {
|
|
38
|
+
const own = fgOrPlain(theme, "searchMatchText", text);
|
|
39
|
+
const body = fgOrPlain(theme, "toolOutput", text);
|
|
40
|
+
return own === body ? fgOrPlain(theme, "accent", text) : own;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/** Region-2 closures read the live theme proxy; a missing or throwing token falls to the next link. */
|
|
29
44
|
function tryStyle(fn: ((text: string) => string) | undefined, text: string): string | undefined {
|
|
30
45
|
try {
|
|
@@ -45,7 +60,7 @@ export function createRowPaint(theme: HostTheme): RowPaint {
|
|
|
45
60
|
error: (text) => fgOrPlain(theme, "error", text),
|
|
46
61
|
warning: (text) => fgOrPlain(theme, "warning", text),
|
|
47
62
|
gutter: (text) => fgOrPlain(theme, "muted", text),
|
|
48
|
-
match: (text) =>
|
|
63
|
+
match: (text) => matchOrAccent(theme, text),
|
|
49
64
|
diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
|
|
50
65
|
diffRemoved: (text) => fgOrPlain(theme, "toolDiffRemoved", text),
|
|
51
66
|
diffContext: (text) => fgOrPlain(theme, "toolDiffContext", text),
|
package/src/core/types/host.ts
CHANGED
|
@@ -130,31 +130,28 @@ export interface TransformContext {
|
|
|
130
130
|
availableWidth: number;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
/** The command-handler context slice /canvas reads. Structural host shape — no host import. */
|
|
134
|
-
export interface CommandContext {
|
|
135
|
-
/** Host output mode. Required: "tui" | "rpc" | "json" | "print". */
|
|
136
|
-
mode: "tui" | "rpc" | "json" | "print";
|
|
137
|
-
/** Host notification seam for user-facing feedback. Required. */
|
|
138
|
-
ui: {
|
|
139
|
-
/** Shows one message to the user, e.g. notify("Canvas closed"). Required. */
|
|
140
|
-
notify(message: string): void;
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
133
|
/**
|
|
145
134
|
* The host surface the extension registers through. Structural host shape: the host arrives as the
|
|
146
135
|
* composition root's parameter, never as a module import, so this file stays pi-free.
|
|
136
|
+
*
|
|
137
|
+
* The command seam and its handler context are deliberately absent (Req 2): pi-render registers no
|
|
138
|
+
* command, and a declaration it never calls is a claim it does not make. A future settings panel
|
|
139
|
+
* re-declares both here, in one place (PRD Q1).
|
|
147
140
|
*/
|
|
148
141
|
export interface ExtensionApi {
|
|
149
142
|
/** Adds one resolver to the tool-render chain. Required; called exactly once, in index.ts. */
|
|
150
143
|
registerToolRenderer(resolver: ToolRendererResolver): void;
|
|
151
144
|
/** Adds one markdown rewrite stage. Required; called exactly once, in index.ts. */
|
|
152
145
|
registerMarkdownTransformer(transformer: (markdown: string, ctx: TransformContext) => string): void;
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
146
|
+
/** Subscribes to a host event, e.g. "session_start". Required; the per-event ctx carries ui. */
|
|
147
|
+
on(event: string, handler: (event: unknown, ctx: EventContext) => void): void;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The context a host event handler receives. Structural host shape. */
|
|
151
|
+
export interface EventContext {
|
|
152
|
+
/** The host's UI surface for this event — present in interactive mode, a no-op stub otherwise. Optional. */
|
|
153
|
+
ui?: {
|
|
154
|
+
/** Collapses (false) / expands (true) every tool row. The collapse-first default rides this. Optional. */
|
|
155
|
+
setToolsExpanded?(expanded: boolean): void;
|
|
156
|
+
};
|
|
160
157
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -14,7 +14,6 @@ export type { Surface, SurfaceKey } from "../renderers/content/types.ts";
|
|
|
14
14
|
export type { CallModel, RowSpec, RowView } from "../renderers/tool/types.ts";
|
|
15
15
|
export type { CodeTheme, HighlightEngine } from "./types/code-theme.ts";
|
|
16
16
|
export type {
|
|
17
|
-
CommandContext,
|
|
18
17
|
ExtensionApi,
|
|
19
18
|
HostTheme,
|
|
20
19
|
MarkdownTheme,
|
|
@@ -18,15 +18,13 @@
|
|
|
18
18
|
import { pathToFileURL } from "node:url";
|
|
19
19
|
import { calculateImageRows, getImageDimensions, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
20
20
|
import type { ContentPaint } from "../../../core/types/paint.ts";
|
|
21
|
+
import { blockWidth } from "../code-panel.ts";
|
|
21
22
|
import type { ArtifactServer } from "./server.ts";
|
|
22
23
|
|
|
23
24
|
const OPEN_LABEL = "Open (⌘click)";
|
|
24
25
|
const COPY_LABEL = "Copy path (⌥C)";
|
|
25
26
|
/** The rendered link occupies brackets around the label. */
|
|
26
27
|
const OPEN_WIDTH = visibleWidth(OPEN_LABEL) + 2;
|
|
27
|
-
/** Cards read best narrow; the offered width is clamped into these bounds. */
|
|
28
|
-
const CARD_MIN_WIDTH = 24;
|
|
29
|
-
const CARD_MAX_WIDTH = 64;
|
|
30
28
|
/** pi-tui's default image width; the reference for a raster artifact's transcript row count. */
|
|
31
29
|
const DEFAULT_WIDTH_CELLS = 60;
|
|
32
30
|
|
|
@@ -70,9 +68,15 @@ export function artifactTitle(form: string, source: string): string {
|
|
|
70
68
|
return form;
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* shape: none — one box; every line's DRAWN width is arithmetically bounded.
|
|
73
|
+
*
|
|
74
|
+
* Drawn, not raw: the host paints a markdown link as its label, so the pad filling the line to the
|
|
75
|
+
* right edge charges OPEN_LABEL's cells. `linkFits` and `titleBudget` still measure the RAW link —
|
|
76
|
+
* that is the width the host's wrap sees when it decides whether the token survives.
|
|
77
|
+
*/
|
|
74
78
|
function renderCard(spec: ArtifactCardSpec & { openUrl: string; pathLabel: string }, paint: ContentPaint): string {
|
|
75
|
-
const width =
|
|
79
|
+
const width = blockWidth(spec.width);
|
|
76
80
|
const inner = width - 2;
|
|
77
81
|
const contentWidth = Math.max(OPEN_WIDTH + 2, inner - 2);
|
|
78
82
|
const label = spec.typeLabel.toLowerCase();
|
|
@@ -89,7 +93,10 @@ function renderCard(spec: ArtifactCardSpec & { openUrl: string; pathLabel: strin
|
|
|
89
93
|
const titleBudget = contentWidth - (linkFits ? visibleWidth(link) : visibleWidth(OPEN_LABEL)) - 1;
|
|
90
94
|
const titleFitted = clampWidth(title, Math.max(1, titleBudget));
|
|
91
95
|
const actionText = linkFits ? link : OPEN_LABEL;
|
|
92
|
-
|
|
96
|
+
// Charge the DRAWN affordance, not the markdown: the host draws `[label](url)` as its label, so a
|
|
97
|
+
// pad built on the raw link width stopped (raw link − label) cells short of the frame's right edge
|
|
98
|
+
// — the title row was the one ragged line of the box (issue #8).
|
|
99
|
+
const titlePad = " ".repeat(Math.max(1, contentWidth - visibleWidth(titleFitted) - visibleWidth(OPEN_LABEL)));
|
|
93
100
|
// No escape codes inside the brackets: anything there breaks the link token.
|
|
94
101
|
const titleLine = `${paint.rule("│")} ${paint.quote(titleFitted)}${titlePad}${actionText} ${paint.rule("│")}`;
|
|
95
102
|
const copyPad = " ".repeat(Math.max(0, contentWidth - visibleWidth(COPY_LABEL)));
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// ported from pi-pretty-tui/src/features/canvas/code-panel.ts — survives because: the bordered panel
|
|
2
|
-
// with a language header, the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// with a language header, the ANSI-aware body wrap and the sync highlight path are the owner's reading
|
|
3
|
+
// surface for a fence (the content-hugging width does not survive: FIX-12 replaced it with the shared
|
|
4
|
+
// reading column); only the color source changed (paint roles plus the injected CodeTheme instead of
|
|
5
|
+
// module-level globals and hardcoded escapes).
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* code-panel.ts — a fenced block becomes a bordered, themed panel; the JSON pane reuses the same frame,
|
|
8
|
-
* the same fence scan
|
|
9
|
+
* the same fence scan, the same block-width clamp (24–140, the owner's revised reading column) and the
|
|
10
|
+
* same markdown embedding.
|
|
9
11
|
*
|
|
10
12
|
* Boundary: markdown is untrusted and is scanned line-by-line, never handed to a parser; the CodeTheme
|
|
11
13
|
* is injected, so no renderer imports shiki. Width math is pi-tui's own ANSI-aware visibleWidth /
|
|
12
|
-
*
|
|
14
|
+
* wrapTextWithAnsi — the host utilities the panel needs, and what keeps columns aligned when truecolor
|
|
13
15
|
* escapes sit inside the content (CJK included). A failing highlighter degrades to plain source; the
|
|
14
16
|
* panel never throws.
|
|
15
17
|
*
|
|
@@ -17,30 +19,51 @@
|
|
|
17
19
|
* declares its own shape below.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
|
-
import {
|
|
22
|
+
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
21
23
|
import type { CodeTheme, ContentPaint } from "../../core/types.ts";
|
|
22
24
|
import type { Surface } from "./types.ts";
|
|
23
25
|
|
|
24
|
-
/** The
|
|
25
|
-
const
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/** The floor of the reading column, so a narrow terminal still reads a block as a block. */
|
|
27
|
+
const BLOCK_MIN_WIDTH = 24;
|
|
28
|
+
/** The ceiling of the reading column. Raised 64 → 140 by the owner (2026-10-07): 64 cells were too
|
|
29
|
+
* narrow for a table cell, and 140/190 ≈ 74% keeps the proportion the original reference width set
|
|
30
|
+
* (64/80 ≈ 80%), so a wide terminal or a two-way split still reads as one column. */
|
|
31
|
+
const BLOCK_MAX_WIDTH = 140;
|
|
32
|
+
/** A body row is `│␣␣text␣␣│` — two blank cells each side so the frame never touches the code — so the
|
|
33
|
+
* text budget is width-6 (P code-panel.ts:75). */
|
|
34
|
+
const ROW_CHROME = 6;
|
|
28
35
|
/** Languages left as raw fences: mermaid is the host's own renderer, and the four diagram forms
|
|
29
36
|
* belong to the artifact pass that runs AFTER surfaces (SA §6) — consuming them here would
|
|
30
37
|
* regress every diagram to a plain panel. */
|
|
31
38
|
const RAW_FENCES = new Set(["mermaid", "plantuml", "svg", "dot", "html"]);
|
|
32
39
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The reading column: the one width every framed block shares (GLOSSARY "reading column"). The offered
|
|
42
|
+
* transcript width is clamped into [BLOCK_MIN_WIDTH, BLOCK_MAX_WIDTH], so at 140 columns and above a
|
|
43
|
+
* block holds 140 cells, and below 140 it takes the FULL offered width — a narrow terminal is never
|
|
44
|
+
* under-filled. Zero or a non-numeric offer falls back to 80 columns.
|
|
45
|
+
*
|
|
46
|
+
* shape: none — one clamp, no branch on a discriminator.
|
|
47
|
+
*/
|
|
48
|
+
export function blockWidth(availableWidth: number): number {
|
|
49
|
+
const offered = Math.floor(availableWidth) || 80;
|
|
50
|
+
return Math.min(BLOCK_MAX_WIDTH, Math.max(BLOCK_MIN_WIDTH, offered));
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The composition root's historical name for {@link blockWidth} (index.ts imports it and is outside
|
|
55
|
+
* FIX-12's file set). Same function, same clamp — kept so the root keeps compiling.
|
|
56
|
+
*/
|
|
57
|
+
export const panelWidth = blockWidth;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Highlighted body lines, wrapped to `textWidth` visible cells; a line past the budget continues on the
|
|
61
|
+
* next row (ANSI-aware) rather than being cut. A throwing theme degrades to the plain source.
|
|
62
|
+
*
|
|
63
|
+
* shape: none — one guarded call plus a one-line fallback; no discriminator.
|
|
64
|
+
*/
|
|
65
|
+
function bodyLines(code: string, lang: string, textWidth: number, codeTheme: CodeTheme): string[] {
|
|
42
66
|
const source = code.replace(/\n+$/, "");
|
|
43
|
-
const budget = Math.max(8, width - ROW_CHROME);
|
|
44
67
|
let lines: string[];
|
|
45
68
|
try {
|
|
46
69
|
lines = codeTheme.highlightSync(source, lang).split("\n");
|
|
@@ -48,11 +71,15 @@ function bodyLines(code: string, lang: string, width: number, codeTheme: CodeThe
|
|
|
48
71
|
// A cold core or an unloadable grammar: the panel degrades, the answer render never dies.
|
|
49
72
|
lines = source.split("\n");
|
|
50
73
|
}
|
|
51
|
-
return lines.
|
|
74
|
+
return lines.flatMap((line) => wrapTextWithAnsi(line, textWidth));
|
|
52
75
|
}
|
|
53
76
|
|
|
54
77
|
/**
|
|
55
|
-
* One framed block
|
|
78
|
+
* One framed block at the shared reading column: header label and body rows, all `blockWidth(width)`
|
|
79
|
+
* cells wide. Each body row is `│␣␣text␣␣pad␣␣│`, i.e. the line begins two cells inside the opening
|
|
80
|
+
* border and its own padding fills the rest of a `panelSize - 6` text budget, so the frame never crowds
|
|
81
|
+
* the code. A line wider than that budget was already wrapped by {@link bodyLines}, so the panel shows
|
|
82
|
+
* the whole line over further rows and pads the last. Shared by the code and JSON panels.
|
|
56
83
|
*
|
|
57
84
|
* shape: none — one box; every row's visible width is arithmetically bounded to the panel width.
|
|
58
85
|
*/
|
|
@@ -63,22 +90,17 @@ export function renderPanel(
|
|
|
63
90
|
paint: ContentPaint,
|
|
64
91
|
codeTheme: CodeTheme,
|
|
65
92
|
): string[] {
|
|
66
|
-
const
|
|
93
|
+
const panelSize = blockWidth(width);
|
|
94
|
+
const textWidth = Math.max(8, panelSize - ROW_CHROME);
|
|
67
95
|
const label = lang.toLowerCase() || "code";
|
|
68
|
-
const body = bodyLines(code, lang,
|
|
69
|
-
const contentWidth = Math.max(1, ...body.map((line) => visibleWidth(line)));
|
|
70
|
-
const prefix = `─ ${label} `;
|
|
71
|
-
const panelSize = Math.min(
|
|
72
|
-
maxWidth,
|
|
73
|
-
Math.max(MIN_PANEL_WIDTH, Math.max(contentWidth + ROW_CHROME, visibleWidth(prefix) + ROW_CHROME)),
|
|
74
|
-
);
|
|
96
|
+
const body = bodyLines(code, lang, textWidth, codeTheme);
|
|
75
97
|
const inner = panelSize - 2;
|
|
76
|
-
const
|
|
98
|
+
const prefix = `─ ${label} `;
|
|
77
99
|
const dashes = Math.max(0, inner - visibleWidth(prefix));
|
|
78
100
|
const rows: string[] = [paint.codeBlockBorder(`╭${prefix}${"─".repeat(dashes)}╮`)];
|
|
79
101
|
for (const line of body) {
|
|
80
102
|
const pad = " ".repeat(Math.max(0, textWidth - visibleWidth(line)));
|
|
81
|
-
rows.push(`${paint.codeBlockBorder("│")}
|
|
103
|
+
rows.push(`${paint.codeBlockBorder("│")} ${line}${pad} ${paint.codeBlockBorder("│")}`);
|
|
82
104
|
}
|
|
83
105
|
rows.push(paint.codeBlockBorder(`╰${"─".repeat(inner)}╯`));
|
|
84
106
|
return rows;
|
|
@@ -151,7 +173,7 @@ export function mapFencedBlocks(
|
|
|
151
173
|
export function createCodePanel(codeTheme: CodeTheme): Surface {
|
|
152
174
|
return {
|
|
153
175
|
rewrite(markdown, ctx, paint) {
|
|
154
|
-
const width =
|
|
176
|
+
const width = blockWidth(ctx.availableWidth);
|
|
155
177
|
const { text, changed } = mapFencedBlocks(markdown, (lang, code) =>
|
|
156
178
|
lang && !RAW_FENCES.has(lang) ? toMarkdownRows(renderPanel(code, lang, width, paint, codeTheme)) : undefined,
|
|
157
179
|
);
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* index.ts — the markdown transformer: final-assistant gate, per-surface registry check,
|
|
3
|
-
* identity return.
|
|
2
|
+
* index.ts — the markdown transformer: final-assistant gate, per-surface registry check, per-surface
|
|
3
|
+
* isolation, dispatch, identity return, and one keyed line for every skip.
|
|
4
4
|
*
|
|
5
5
|
* Boundary: the host calls this on the transcript render path, so nothing here throws and nothing
|
|
6
|
-
* allocates per message. A disabled surface is skipped
|
|
7
|
-
*
|
|
6
|
+
* allocates per message. A disabled surface is skipped; a surface that throws is isolated to itself and
|
|
7
|
+
* the message keeps every other rewrite; the input string returns by reference whenever no surface
|
|
8
|
+
* changed it — the host's own caching reads that reference. What the pass cannot do, it names: a closed
|
|
9
|
+
* gate and a table that comes out unboxed each leave one line keyed on the reason, so a live report
|
|
10
|
+
* reads its own cause instead of a screenshot.
|
|
8
11
|
*
|
|
9
12
|
* shape: closure returning a function — trigger #4, the factory captures its injected collaborators
|
|
10
13
|
* (registry, surfaces, paint, log) and holds no state to classify.
|
|
@@ -12,7 +15,8 @@
|
|
|
12
15
|
* ported from pi-pretty-tui/src/features/canvas/transformer.ts — survives because: the final-assistant
|
|
13
16
|
* gate, the degrade-to-the-input-verbatim fallback, and mermaid staying with the host's own transformer
|
|
14
17
|
* are the host-aligned behaviors that file got right. Its parser, settings load, and per-form render
|
|
15
|
-
* code do not port: each surface owns one rewrite here, and the registry owns every toggle.
|
|
18
|
+
* code do not port: each surface owns one rewrite here, and the registry owns every toggle. Its single
|
|
19
|
+
* try/catch around the whole pass does not port either: a failure belongs to the surface that threw.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import type { TransformContext } from "../../core/types/host.ts";
|
|
@@ -28,7 +32,16 @@ const ARTIFACTS_KEY = "artifacts";
|
|
|
28
32
|
const SURFACE_ORDER: readonly SurfaceKey[] = ["table", "codePanel", "jsonPanel", "imageCard"];
|
|
29
33
|
|
|
30
34
|
const SCOPE = "content";
|
|
31
|
-
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What this file calls a table: a row holding a pipe, then a delimiter row of dashes, colons and pipes.
|
|
38
|
+
* It is the shape the table surface parses (T-22), spelled for the raw markdown the transformer receives,
|
|
39
|
+
* so prose that merely quotes a pipe — or an already-boxed frame — never matches.
|
|
40
|
+
*/
|
|
41
|
+
const TABLE_SHAPE = /\|[^\n]*\n\|[ :-]*-[-| :]*\|/;
|
|
42
|
+
|
|
43
|
+
/** The tee in the table box's top rule (T-22): present in an output exactly when a table was boxed. */
|
|
44
|
+
const TABLE_BOX_RULE = "┬";
|
|
32
45
|
|
|
33
46
|
/** The surface table T-28 composes: one entry per SurfaceKey, plus the optional fence pass. */
|
|
34
47
|
export type ContentSurfaces = Record<SurfaceKey, Surface> & {
|
|
@@ -40,7 +53,7 @@ export type ContentSurfaces = Record<SurfaceKey, Surface> & {
|
|
|
40
53
|
export interface ContentTransformerDeps {
|
|
41
54
|
/** Live content paint; surfaces receive it per invocation. Required. */
|
|
42
55
|
paint: ContentPaint;
|
|
43
|
-
/** Keyed diagnostics
|
|
56
|
+
/** Keyed diagnostics: one line per surface failure, gate skip and declined table — a healthy input logs nothing. Required. */
|
|
44
57
|
log: Logger;
|
|
45
58
|
}
|
|
46
59
|
|
|
@@ -54,26 +67,62 @@ export function createContentTransformer(
|
|
|
54
67
|
const { paint, log } = deps;
|
|
55
68
|
const artifacts = surfaces.artifacts;
|
|
56
69
|
|
|
57
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* One dispatch step: an absent or disabled slot is skipped, otherwise it rewrites the current text.
|
|
72
|
+
* Per-surface isolation lives here — a surface that throws logs one line keyed on its own name and
|
|
73
|
+
* returns the text it was handed, so the failure costs that surface alone and never the message.
|
|
74
|
+
*/
|
|
58
75
|
const run = (key: string, surface: Surface | undefined, input: string, ctx: TransformContext): string => {
|
|
59
76
|
if (surface === undefined || !registry.isEnabled(key)) return input;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
try {
|
|
78
|
+
const next = surface.rewrite(input, ctx, paint);
|
|
79
|
+
// A surface signals "nothing matched" by returning its input; comparing by value also discards an
|
|
80
|
+
// equal copy, so a no-op pass keeps the reference the host handed us.
|
|
81
|
+
return next === input ? input : next;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
84
|
+
log.logOnce(`content:fail:${key}`, SCOPE, `${key} failed: ${detail}`);
|
|
85
|
+
return input;
|
|
86
|
+
}
|
|
64
87
|
};
|
|
65
88
|
|
|
66
89
|
return (markdown, ctx) => {
|
|
67
|
-
// The predecessor's gate, unchanged: only a settled assistant message is rewritten.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
90
|
+
// The predecessor's gate, unchanged: only a settled assistant message is rewritten. The key carries
|
|
91
|
+
// the messageType alone (three possible values), so a whole session reports each gate reason once.
|
|
92
|
+
if (ctx.messageType !== "assistant" || ctx.isStreaming) {
|
|
93
|
+
if (TABLE_SHAPE.test(markdown)) {
|
|
94
|
+
log.logOnce(
|
|
95
|
+
`content:gate:${ctx.messageType}`,
|
|
96
|
+
SCOPE,
|
|
97
|
+
`table-shaped content skipped: messageType=${ctx.messageType} isStreaming=${ctx.isStreaming}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
76
100
|
return markdown;
|
|
77
101
|
}
|
|
102
|
+
|
|
103
|
+
let out = markdown;
|
|
104
|
+
// The step that last changed the text: the surface a table should have come from, named by the
|
|
105
|
+
// decline line below. Undefined means every surface passed its input straight through.
|
|
106
|
+
let lastTouched: string | undefined;
|
|
107
|
+
for (const key of SURFACE_ORDER) {
|
|
108
|
+
const step = run(`content.${key}`, surfaces[key], out, ctx);
|
|
109
|
+
if (step !== out) lastTouched = `content.${key}`;
|
|
110
|
+
out = step;
|
|
111
|
+
}
|
|
112
|
+
const fenced = run(`content.${ARTIFACTS_KEY}`, artifacts, out, ctx);
|
|
113
|
+
if (fenced !== out) lastTouched = `content.${ARTIFACTS_KEY}`;
|
|
114
|
+
out = fenced;
|
|
115
|
+
|
|
116
|
+
// A table that reaches the end of the pass without a box rule was declined by every surface that saw
|
|
117
|
+
// it — the one case where the pipeline succeeded and the box still did not draw. The key carries the
|
|
118
|
+
// input length, so a channel that keeps repeating one table declines once.
|
|
119
|
+
if (TABLE_SHAPE.test(markdown) && !out.includes(TABLE_BOX_RULE)) {
|
|
120
|
+
log.logOnce(
|
|
121
|
+
`content:decline:table:${markdown.length}`,
|
|
122
|
+
SCOPE,
|
|
123
|
+
`table-shaped content left unboxed: last surface that touched the text=${lastTouched ?? "none"}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
78
127
|
};
|
|
79
128
|
}
|