@zenodinh/pi-render 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +110 -2
- package/index.ts +284 -0
- package/package.json +70 -5
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +169 -0
- package/src/core/types/log.ts +28 -0
- package/src/core/types/paint.ts +54 -0
- package/src/core/types/registry.ts +44 -0
- package/src/core/types/settings.ts +31 -0
- package/src/core/types.ts +34 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.ts +80 -0
- package/src/renderers/content/artifacts/prereqs.ts +128 -0
- package/src/renderers/content/artifacts/server.ts +181 -0
- package/src/renderers/content/code-panel.ts +161 -0
- package/src/renderers/content/image-card.ts +252 -0
- package/src/renderers/content/index.ts +79 -0
- package/src/renderers/content/json-panel.ts +116 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.ts +142 -0
- package/src/renderers/tool/types.ts +45 -0
- package/themes/dracula-soft.json +81 -0
- package/themes/one-dark.json +80 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/core/render.ts:48-140 — survives because: the theme fallback with one log
|
|
2
|
+
// line, the LRU keyed theme+lang+code, the 80k size gate and the low-contrast fg normalization are the one
|
|
3
|
+
// place a highlight decision is made.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* code-theme.ts — syntax highlighting behind two calls: rows await `highlight`, panels call `highlightSync`;
|
|
7
|
+
* one cache serves both, so a block is highlighted once per theme.
|
|
8
|
+
*
|
|
9
|
+
* Boundary: `codeTheme` arrives from the injected registry as persisted JSON and is narrowed here before it
|
|
10
|
+
* is read; diagnostics go to the injected logOnce sink. The shiki adapter below ports
|
|
11
|
+
* pi-pretty-tui/src/features/canvas/code-panel.ts:16-140 (warm sync core, JavaScript regex engine, the
|
|
12
|
+
* predecessor's grammar set) and returns shiki's own token colours — that is the highlighting; the package
|
|
13
|
+
* palette stays in paint.ts.
|
|
14
|
+
*
|
|
15
|
+
* shape: none — dispatch object / Strategy do not apply: two factories over one cache and one warm core,
|
|
16
|
+
* with no discriminator to dispatch on. Each factory declares its own shape below.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { BundledLanguage } from "shiki";
|
|
20
|
+
import type { Logger } from "./types/log.ts";
|
|
21
|
+
import type { ModuleDescriptor, Registry } from "./types/registry.ts";
|
|
22
|
+
import type { CodeTheme, HighlightEngine } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
/** Bundled theme set: the predecessor's five trimmed to the two the host themes ship (PRD EB-1). */
|
|
25
|
+
const THEMES = ["dracula-soft", "one-dark-pro"] as const;
|
|
26
|
+
|
|
27
|
+
/** A bundled theme name — the closed set a `codeTheme` setting is allowed to name. */
|
|
28
|
+
export type CodeThemeName = (typeof THEMES)[number];
|
|
29
|
+
|
|
30
|
+
const MODULE_KEY = "codetheme";
|
|
31
|
+
const SETTING = "codeTheme";
|
|
32
|
+
const SCOPE = "code-theme";
|
|
33
|
+
const DEFAULT_THEME: CodeThemeName = "dracula-soft";
|
|
34
|
+
|
|
35
|
+
/** The descriptor T-28 registers for the /render panel; exported here, never registered here. */
|
|
36
|
+
export const CODE_THEME_MODULE: ModuleDescriptor = {
|
|
37
|
+
key: MODULE_KEY,
|
|
38
|
+
name: "Code theme",
|
|
39
|
+
defaultEnabled: true,
|
|
40
|
+
settings: [{ name: SETTING, kind: "enum", values: [...THEMES], default: DEFAULT_THEME }],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Bounded by entries, not bytes: one entry is at most MAX_HL_CHARS, and the cap is the predecessor's. */
|
|
44
|
+
const CACHE_LIMIT = 128;
|
|
45
|
+
/** Past this size highlighting costs more than it is worth (P render.ts:86). */
|
|
46
|
+
const MAX_HL_CHARS = 80_000;
|
|
47
|
+
/** Readability floor for a token colour over the host background (P render.ts:124). */
|
|
48
|
+
const MIN_FG_LUMINANCE = 72;
|
|
49
|
+
|
|
50
|
+
/** Settings read plus the diagnostics sink — the whole injected surface, so a test double is three lines. */
|
|
51
|
+
export interface CodeThemeOptions {
|
|
52
|
+
/** Effective settings by module key. Optional; absent reads the default theme. */
|
|
53
|
+
registry?: Pick<Registry, "getSettings">;
|
|
54
|
+
/** Once-per-key diagnostics. Optional; absent is silent. */
|
|
55
|
+
log?: Pick<Logger, "logOnce">;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Binds the engine to one theme: production memoizes per theme over one warm core, tests count the calls. */
|
|
59
|
+
export type BindHighlightEngine = (theme: CodeThemeName) => HighlightEngine;
|
|
60
|
+
|
|
61
|
+
/** boundary: `codeTheme` is persisted JSON, so only a bundled name is trusted; anything else falls back. */
|
|
62
|
+
function isCodeThemeName(value: unknown): value is CodeThemeName {
|
|
63
|
+
return typeof value === "string" && THEMES.some((theme) => theme === value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// shape: closure returning an object literal — trigger #4, two methods over one Map, no subclassing.
|
|
67
|
+
export function createCodeTheme(bindEngine: BindHighlightEngine, options: CodeThemeOptions = {}): CodeTheme {
|
|
68
|
+
const cache = new Map<string, string[]>();
|
|
69
|
+
|
|
70
|
+
/** LRU: a hit moves to the tail, the head goes once the cap is passed. */
|
|
71
|
+
const remember = (key: string, lines: string[]): string[] => {
|
|
72
|
+
cache.delete(key);
|
|
73
|
+
cache.set(key, lines);
|
|
74
|
+
if (cache.size > CACHE_LIMIT) {
|
|
75
|
+
const oldest = cache.keys().next().value;
|
|
76
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Read per call: a settings write lands on the next render, and an unknown name falls back exactly once. */
|
|
82
|
+
const resolveTheme = (): CodeThemeName => {
|
|
83
|
+
const stored: unknown = options.registry?.getSettings(MODULE_KEY)[SETTING];
|
|
84
|
+
if (isCodeThemeName(stored)) return stored;
|
|
85
|
+
if (stored !== undefined) {
|
|
86
|
+
options.log?.logOnce(
|
|
87
|
+
`theme:${String(stored)}`,
|
|
88
|
+
SCOPE,
|
|
89
|
+
`unknown code theme "${String(stored)}" — rendering "${DEFAULT_THEME}"`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return DEFAULT_THEME;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Engine output behind the cache; the size gate and any engine failure fall through to plain source lines. */
|
|
96
|
+
const lines = (code: string, lang: string): string[] => {
|
|
97
|
+
if (!code) return [""];
|
|
98
|
+
if (code.length > MAX_HL_CHARS) return code.split("\n");
|
|
99
|
+
const theme = resolveTheme();
|
|
100
|
+
const key = `${theme}\0${lang}\0${code}`;
|
|
101
|
+
const hit = cache.get(key);
|
|
102
|
+
if (hit !== undefined) return remember(key, hit);
|
|
103
|
+
try {
|
|
104
|
+
return remember(key, bindEngine(theme)(code, lang).map(dropLowContrastFg));
|
|
105
|
+
} catch {
|
|
106
|
+
// Shiki fails on a grammar, a theme or a broken engine; a row degrades, it never dies.
|
|
107
|
+
options.log?.logOnce(`fail:${lang}`, SCOPE, `highlight failed for "${lang}" — rendering plain`);
|
|
108
|
+
return code.split("\n");
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
/** Async for the row seam; the engine itself is synchronous. */
|
|
114
|
+
highlight: async (code, lang) => lines(code, lang),
|
|
115
|
+
/** The panel seam: the same cache and engine, joined into one block. */
|
|
116
|
+
highlightSync: (code, lang) => lines(code, lang).join("\n"),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Shiki adapter
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The ESC byte, built rather than written: a colour literal belongs to paint.ts, and the T-29 scan keeps
|
|
126
|
+
* escape literals on that one owner.
|
|
127
|
+
*/
|
|
128
|
+
const ESC = String.fromCharCode(27);
|
|
129
|
+
const RESET = `${ESC}[0m`;
|
|
130
|
+
const SGR = new RegExp(`${ESC}\\[([0-9;]*)m`, "g");
|
|
131
|
+
|
|
132
|
+
/** A fence spelling the grammars below register under a different id (P code-panel.ts:73-88). */
|
|
133
|
+
const LANG_ALIASES = new Map<string, string>([
|
|
134
|
+
["ts", "typescript"],
|
|
135
|
+
["js", "javascript"],
|
|
136
|
+
["py", "python"],
|
|
137
|
+
["sh", "bash"],
|
|
138
|
+
["shell", "bash"],
|
|
139
|
+
["zsh", "bash"],
|
|
140
|
+
["yml", "yaml"],
|
|
141
|
+
["md", "markdown"],
|
|
142
|
+
["golang", "go"],
|
|
143
|
+
["c++", "cpp"],
|
|
144
|
+
["c#", "csharp"],
|
|
145
|
+
["rs", "rust"],
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The grammars worth carrying — the predecessor's set (P code-panel.ts:32-62), by shiki id. Typed against
|
|
150
|
+
* shiki's own union, so an id the bundle does not carry fails the type-check instead of at first render.
|
|
151
|
+
*/
|
|
152
|
+
const LANG_IDS: readonly BundledLanguage[] = [
|
|
153
|
+
"typescript",
|
|
154
|
+
"tsx",
|
|
155
|
+
"javascript",
|
|
156
|
+
"jsx",
|
|
157
|
+
"python",
|
|
158
|
+
"java",
|
|
159
|
+
"json",
|
|
160
|
+
"yaml",
|
|
161
|
+
"markdown",
|
|
162
|
+
"bash",
|
|
163
|
+
"sql",
|
|
164
|
+
"xml",
|
|
165
|
+
"html",
|
|
166
|
+
"css",
|
|
167
|
+
"go",
|
|
168
|
+
"rust",
|
|
169
|
+
"diff",
|
|
170
|
+
"toml",
|
|
171
|
+
"ini",
|
|
172
|
+
"dockerfile",
|
|
173
|
+
"csharp",
|
|
174
|
+
"cpp",
|
|
175
|
+
"kotlin",
|
|
176
|
+
"php",
|
|
177
|
+
"ruby",
|
|
178
|
+
"swift",
|
|
179
|
+
"lua",
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
/** One foreground SGR too dark to read over the host background (P render.ts:112-125). */
|
|
183
|
+
function isLowContrastFg(params: string): boolean {
|
|
184
|
+
if (params === "30" || params === "90" || params === "38;5;0" || params === "38;5;8") return true;
|
|
185
|
+
if (!params.startsWith("38;2;")) return false;
|
|
186
|
+
const channels = params.slice("38;2;".length).split(";").map(Number);
|
|
187
|
+
if (channels.length !== 3 || channels.some((n) => !Number.isFinite(n))) return false;
|
|
188
|
+
const [r, g, b] = channels;
|
|
189
|
+
if (r === undefined || g === undefined || b === undefined) return false;
|
|
190
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b < MIN_FG_LUMINANCE;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Drops such a foreground instead of substituting one: the row then renders in the theme's own foreground,
|
|
195
|
+
* which is the readable default, and no second palette appears outside paint.ts.
|
|
196
|
+
*/
|
|
197
|
+
function dropLowContrastFg(line: string): string {
|
|
198
|
+
return line.replace(SGR, (sequence, params: string) => (isLowContrastFg(params) ? "" : sequence));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The SGR for one token: its colour plus the font styles shiki reports as bit flags (P code-panel.ts:106-118). */
|
|
202
|
+
function tokenSgr(token: { color?: string; fontStyle?: number }): string {
|
|
203
|
+
const parts: string[] = [];
|
|
204
|
+
if (typeof token.color === "string" && /^#[0-9a-f]{6}$/i.test(token.color)) {
|
|
205
|
+
const value = Number.parseInt(token.color.slice(1), 16);
|
|
206
|
+
parts.push(`38;2;${(value >> 16) & 0xff};${(value >> 8) & 0xff};${value & 0xff}`);
|
|
207
|
+
}
|
|
208
|
+
const style = token.fontStyle ?? 0;
|
|
209
|
+
if (style & 1) parts.push("3");
|
|
210
|
+
if (style & 2) parts.push("1");
|
|
211
|
+
if (style & 4) parts.push("4");
|
|
212
|
+
return parts.length > 0 ? `${ESC}[${parts.join(";")}m` : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One line of tokens to one SGR string; the reset after non-blank content stops a colour bleeding onward. */
|
|
216
|
+
function tokensToAnsi(tokens: readonly { content: string; color?: string; fontStyle?: number }[]): string {
|
|
217
|
+
let out = RESET;
|
|
218
|
+
for (const token of tokens) {
|
|
219
|
+
out += tokenSgr(token) + token.content + (token.content.trim() === "" ? "" : RESET);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// shape: closure returning a function — trigger #4 (one warm core plus a per-theme memo, no subclassing);
|
|
225
|
+
// the core itself is built lazily, so a process that never highlights never pays for shiki.
|
|
226
|
+
export async function createShikiEngine(log?: Pick<Logger, "logOnce">): Promise<BindHighlightEngine> {
|
|
227
|
+
// The bundled registries come from shiki's own entry points, not from @shikijs/langs: only `shiki` is a
|
|
228
|
+
// declared dependency of this package (package.json).
|
|
229
|
+
const [core, regex, bundledLangs, bundledThemes] = await Promise.all([
|
|
230
|
+
import("shiki/core"),
|
|
231
|
+
import("shiki/engine/javascript"),
|
|
232
|
+
import("shiki/langs"),
|
|
233
|
+
import("shiki/themes"),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
// A grammar that failed to load costs that language only; the rest of the set still highlights.
|
|
237
|
+
const registrations = await Promise.all(
|
|
238
|
+
LANG_IDS.map(async (id) => (await bundledLangs.bundledLanguages[id]().catch(() => undefined))?.default),
|
|
239
|
+
);
|
|
240
|
+
const themes = await Promise.all(THEMES.map(async (name) => (await bundledThemes.bundledThemes[name]()).default));
|
|
241
|
+
const highlighter = core.createHighlighterCoreSync({
|
|
242
|
+
themes,
|
|
243
|
+
langs: registrations.filter((registration) => registration !== undefined),
|
|
244
|
+
engine: regex.createJavaScriptRegexEngine(),
|
|
245
|
+
});
|
|
246
|
+
const known = new Set(highlighter.getLoadedLanguages());
|
|
247
|
+
|
|
248
|
+
const plain = (code: string): string[] => code.split("\n");
|
|
249
|
+
|
|
250
|
+
const highlight = (code: string, lang: string, theme: CodeThemeName): string[] => {
|
|
251
|
+
// A Map lookup, not a bare object index: `lang` is caller input, so a prototype key must not resolve.
|
|
252
|
+
const id = LANG_ALIASES.get(lang) ?? lang;
|
|
253
|
+
if (!known.has(id)) {
|
|
254
|
+
log?.logOnce(`lang:${id}`, SCOPE, `unknown language "${id}" — rendering plain`);
|
|
255
|
+
return plain(code);
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
return highlighter.codeToTokensBase(code, { lang: id, theme }).map(tokensToAnsi);
|
|
259
|
+
} catch {
|
|
260
|
+
// A grammar the engine rejects degrades that block, exactly as the predecessor's panel did.
|
|
261
|
+
log?.logOnce(`lang:${id}`, SCOPE, `highlight failed for "${id}" — rendering plain`);
|
|
262
|
+
return plain(code);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
// One bound engine per theme: the low-level call takes the theme, the seam's type does not.
|
|
267
|
+
const bound = new Map<CodeThemeName, HighlightEngine>();
|
|
268
|
+
return (theme) => {
|
|
269
|
+
const engine = bound.get(theme);
|
|
270
|
+
if (engine !== undefined) return engine;
|
|
271
|
+
const boundEngine: HighlightEngine = (code, lang) => highlight(code, lang, theme);
|
|
272
|
+
bound.set(theme, boundEngine);
|
|
273
|
+
return boundEngine;
|
|
274
|
+
};
|
|
275
|
+
}
|
package/src/core/log.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* log.ts — keyed in-memory diagnostics: logLine for scoped lines, logOnce for once-per-key lines.
|
|
3
|
+
*
|
|
4
|
+
* Memory, not a file. The predecessor resolved an agent directory and appended to disk on every line —
|
|
5
|
+
* the owner-confirmed cause of its slow start. A bounded ring costs no boot work, no fs call, and no
|
|
6
|
+
* permission that can fail; drain() is the readout tests and the future /render panel consume.
|
|
7
|
+
*
|
|
8
|
+
* Boundary: called from the render path, so nothing here throws or writes stdout/stderr — pi owns the
|
|
9
|
+
* terminal. Both hold structurally: there is no fallible operation here to catch.
|
|
10
|
+
*
|
|
11
|
+
* shape: closure returning an object literal — trigger #4, one ring buffer plus dedup marks per logger,
|
|
12
|
+
* no subclassing or instanceof.
|
|
13
|
+
*/
|
|
14
|
+
import type { LogEntry, Logger } from "./types/log.ts";
|
|
15
|
+
|
|
16
|
+
/** A session logs thousands; the panel reads a window, so the buffer stays bounded and the rest drops. */
|
|
17
|
+
const LOG_CAP = 256;
|
|
18
|
+
|
|
19
|
+
export function createLogger(): Logger {
|
|
20
|
+
// Empty until the first entry: nothing is preallocated at import or construction, and pushing below
|
|
21
|
+
// the cap keeps the index math out of the common case.
|
|
22
|
+
let ring: Array<LogEntry | undefined> = [];
|
|
23
|
+
let head = 0;
|
|
24
|
+
// Marks, not entries: a panel may drain repeatedly, and a drained key must still count as logged.
|
|
25
|
+
const seen = new Set<string>();
|
|
26
|
+
|
|
27
|
+
const record = (entry: LogEntry): void => {
|
|
28
|
+
if (ring.length < LOG_CAP) {
|
|
29
|
+
ring.push(entry);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
ring[head] = entry;
|
|
33
|
+
head = (head + 1) % LOG_CAP;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
logLine(scope: string, message: string): void {
|
|
38
|
+
record({ scope, message, time: Date.now() });
|
|
39
|
+
},
|
|
40
|
+
logOnce(key: string, scope: string, message: string): void {
|
|
41
|
+
if (seen.has(key)) return;
|
|
42
|
+
seen.add(key);
|
|
43
|
+
record({ scope, message, time: Date.now() });
|
|
44
|
+
},
|
|
45
|
+
drain(): LogEntry[] {
|
|
46
|
+
const out: LogEntry[] = [];
|
|
47
|
+
// Once full, head is the oldest slot, so read from it and wrap.
|
|
48
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
49
|
+
const entry = ring[(head + i) % ring.length];
|
|
50
|
+
if (entry !== undefined) out.push(entry);
|
|
51
|
+
}
|
|
52
|
+
ring = [];
|
|
53
|
+
head = 0;
|
|
54
|
+
return out;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paint.ts — the only color source: our role vocabulary mapped onto host theme tokens.
|
|
3
|
+
*
|
|
4
|
+
* Boundary: renderers call role methods and never emit an escape (AGENTS: every escape originates
|
|
5
|
+
* here); both theme inputs are structural, so a plain-object fixture drives either factory with no
|
|
6
|
+
* host import.
|
|
7
|
+
*
|
|
8
|
+
* shape: none — dispatch object does not apply: no discriminator, each role is one token lookup. The
|
|
9
|
+
* two closure factories below declare their own shape.
|
|
10
|
+
*
|
|
11
|
+
* ported from pi-pretty-tui/src/config.ts:71-100 — survives because: "read the theme token, degrade to
|
|
12
|
+
* default when it is absent" is what resolveBaseBackground did; its dead links (`toolBg`,
|
|
13
|
+
* `background`) are dropped — neither token exists at host 1.0.4.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { HostTheme, MarkdownTheme } from "./types/host.ts";
|
|
17
|
+
import type { ContentPaint, RowPaint } from "./types/paint.ts";
|
|
18
|
+
|
|
19
|
+
/** Host `fg` throws on a token the active theme lacks (`Theme.tokenAnsi`); a row degrades, never dies. */
|
|
20
|
+
function fgOrPlain(theme: HostTheme, key: string, text: string): string {
|
|
21
|
+
try {
|
|
22
|
+
return theme.fg(key, text);
|
|
23
|
+
} catch {
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Region-2 closures read the live theme proxy; a missing or throwing token falls to the next link. */
|
|
29
|
+
function tryStyle(fn: ((text: string) => string) | undefined, text: string): string | undefined {
|
|
30
|
+
try {
|
|
31
|
+
return fn?.(text);
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Region 1 — the tool-row factory. Reads `theme` live, so the next call paints the current theme. */
|
|
38
|
+
// shape: closure returning an object literal — trigger #4, eight stateless row roles over the captured theme.
|
|
39
|
+
export function createRowPaint(theme: HostTheme): RowPaint {
|
|
40
|
+
return {
|
|
41
|
+
title: (text) => fgOrPlain(theme, "toolTitle", text),
|
|
42
|
+
output: (text) => fgOrPlain(theme, "toolOutput", text),
|
|
43
|
+
muted: (text) => fgOrPlain(theme, "muted", text),
|
|
44
|
+
accent: (text) => fgOrPlain(theme, "accent", text),
|
|
45
|
+
error: (text) => fgOrPlain(theme, "error", text),
|
|
46
|
+
warning: (text) => fgOrPlain(theme, "warning", text),
|
|
47
|
+
gutter: (text) => fgOrPlain(theme, "muted", text),
|
|
48
|
+
match: (text) => fgOrPlain(theme, "searchMatchText", text),
|
|
49
|
+
diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
|
|
50
|
+
diffRemoved: (text) => fgOrPlain(theme, "toolDiffRemoved", text),
|
|
51
|
+
diffContext: (text) => fgOrPlain(theme, "toolDiffContext", text),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain. */
|
|
56
|
+
// shape: closure returning an object literal — trigger #4, four stateless content roles over the captured markdown theme.
|
|
57
|
+
export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
|
|
58
|
+
return {
|
|
59
|
+
rule: (text) => tryStyle(mdTheme.hr, text) ?? tryStyle(mdTheme.quoteBorder, text) ?? text,
|
|
60
|
+
quote: (text) => tryStyle(mdTheme.quote, text) ?? text,
|
|
61
|
+
code: (text) => tryStyle(mdTheme.code, text) ?? text,
|
|
62
|
+
codeBlockBorder: (text) => tryStyle(mdTheme.codeBlockBorder, text) ?? text,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.ts — the pluggable module registry: descriptors in, enabled flags and settings out.
|
|
3
|
+
*
|
|
4
|
+
* Boundary: the /render panel writes through setSettings; renderers poll isEnabled/getSettings per
|
|
5
|
+
* invocation and never write. A write lands on the next read — the registry pushes no redraw, and the
|
|
6
|
+
* injected store plus logger are the only collaborators it touches. The stored document and the patch
|
|
7
|
+
* both arrive as untrusted JSON, so every field is narrowed here before it is trusted.
|
|
8
|
+
*
|
|
9
|
+
* shape: closure returning an object literal — trigger #4, one Map of state plus methods, no subclassing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Logger } from "./types/log.ts";
|
|
13
|
+
import type { ModuleDescriptor, Registry, SettingSpec } from "./types/registry.ts";
|
|
14
|
+
import type { ModuleSettings, SettingsDoc, SettingsStore } from "./types/settings.ts";
|
|
15
|
+
|
|
16
|
+
const SCOPE = "registry";
|
|
17
|
+
|
|
18
|
+
/** One registered module with its resolved state — the Map value the isEnabled hot path reads. */
|
|
19
|
+
interface ModuleEntry {
|
|
20
|
+
readonly descriptor: ModuleDescriptor;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
settings: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** boundary: a persisted slice and a settings patch both cross as untrusted JSON and narrow here. */
|
|
26
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// shape: dispatch object — trigger #1, 3 setting kinds, validators stateless.
|
|
31
|
+
const FITS: Record<SettingSpec["kind"], (value: unknown, values: string[] | undefined) => boolean> = {
|
|
32
|
+
toggle: (value) => typeof value === "boolean",
|
|
33
|
+
number: (value) => typeof value === "number" && Number.isFinite(value),
|
|
34
|
+
enum: (value, values) => typeof value === "string" && (values ?? []).includes(value),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function fitsSpec(spec: SettingSpec, value: unknown): boolean {
|
|
38
|
+
return FITS[spec.kind](value, spec.values);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function logRejected(log: Logger, key: string, name: string): void {
|
|
42
|
+
log.logOnce(`invalid:${key}:${name}`, SCOPE, `ignored invalid value for ${key}.${name}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Reads one module's persisted slice into resolved state; anything malformed falls back, logged once. */
|
|
46
|
+
function readEntry(doc: SettingsDoc, descriptor: ModuleDescriptor, log: Logger): ModuleEntry {
|
|
47
|
+
const stored: unknown = doc.modules[descriptor.key];
|
|
48
|
+
const slice = isRecord(stored) ? stored : {};
|
|
49
|
+
const enabled = slice.enabled;
|
|
50
|
+
const bag = slice.settings;
|
|
51
|
+
const settings: Record<string, unknown> = {};
|
|
52
|
+
let malformed = stored !== undefined && !isRecord(stored);
|
|
53
|
+
malformed ||= enabled !== undefined && typeof enabled !== "boolean";
|
|
54
|
+
malformed ||= bag !== undefined && !isRecord(bag);
|
|
55
|
+
|
|
56
|
+
for (const spec of descriptor.settings) {
|
|
57
|
+
// Object.hasOwn, not a bare lookup: a declared name must not read an inherited member.
|
|
58
|
+
const value = isRecord(bag) && Object.hasOwn(bag, spec.name) ? bag[spec.name] : undefined;
|
|
59
|
+
if (value === undefined) continue;
|
|
60
|
+
if (fitsSpec(spec, value)) settings[spec.name] = value;
|
|
61
|
+
else malformed = true;
|
|
62
|
+
}
|
|
63
|
+
if (malformed)
|
|
64
|
+
log.logOnce(`stored:${descriptor.key}`, SCOPE, `malformed stored entry for ${descriptor.key} — using defaults`);
|
|
65
|
+
|
|
66
|
+
return { descriptor, enabled: typeof enabled === "boolean" ? enabled : descriptor.defaultEnabled, settings };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createRegistry(store: SettingsStore, log: Logger): Registry {
|
|
70
|
+
const modules = new Map<string, ModuleEntry>();
|
|
71
|
+
// One read at boot: no render path may touch the disk, so a later write updates this cache directly.
|
|
72
|
+
const doc = store.load();
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
defineModule(descriptor: ModuleDescriptor): void {
|
|
76
|
+
if (modules.has(descriptor.key)) {
|
|
77
|
+
log.logOnce(
|
|
78
|
+
`duplicate:${descriptor.key}`,
|
|
79
|
+
SCOPE,
|
|
80
|
+
`duplicate module ${descriptor.key} ignored — first definition wins`,
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
modules.set(descriptor.key, readEntry(doc, descriptor, log));
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
isEnabled(key: string): boolean {
|
|
88
|
+
return modules.get(key)?.enabled ?? false;
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
getSettings(key: string): Record<string, unknown> {
|
|
92
|
+
const entry = modules.get(key);
|
|
93
|
+
if (entry === undefined) return {};
|
|
94
|
+
const out: Record<string, unknown> = {};
|
|
95
|
+
for (const spec of entry.descriptor.settings) {
|
|
96
|
+
out[spec.name] = Object.hasOwn(entry.settings, spec.name) ? entry.settings[spec.name] : spec.default;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
setSettings(key: string, patch: object): void {
|
|
102
|
+
const entry = modules.get(key);
|
|
103
|
+
if (entry === undefined) {
|
|
104
|
+
log.logOnce(`unknown:${key}`, SCOPE, `ignored settings write for unknown module ${key}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const raw: unknown = patch;
|
|
108
|
+
if (!isRecord(raw)) {
|
|
109
|
+
log.logOnce(`patch:${key}`, SCOPE, `ignored non-object settings patch for ${key}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let enabled = entry.enabled;
|
|
114
|
+
const bag: Record<string, unknown> = { ...entry.settings };
|
|
115
|
+
const enabledPatch = raw.enabled;
|
|
116
|
+
if (enabledPatch !== undefined) {
|
|
117
|
+
if (typeof enabledPatch === "boolean") enabled = enabledPatch;
|
|
118
|
+
else logRejected(log, key, "enabled");
|
|
119
|
+
}
|
|
120
|
+
for (const name of Object.keys(raw)) {
|
|
121
|
+
if (name === "enabled") continue;
|
|
122
|
+
const spec = entry.descriptor.settings.find((s) => s.name === name);
|
|
123
|
+
const value = raw[name];
|
|
124
|
+
if (spec !== undefined && fitsSpec(spec, value)) bag[name] = value;
|
|
125
|
+
else logRejected(log, key, name);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const next: ModuleSettings = { enabled, settings: bag };
|
|
129
|
+
doc.modules[key] = next;
|
|
130
|
+
entry.enabled = enabled;
|
|
131
|
+
entry.settings = bag;
|
|
132
|
+
store.save(doc);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/core/state.ts — survives because: its tmp+rename write and its
|
|
2
|
+
// fill-defaults read are exactly the mechanics that keep one document uncorrupted and cheap to read.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one persisted settings document (SA §3), at `~/.pi/agent/pi-render/settings.json`.
|
|
6
|
+
*
|
|
7
|
+
* Why cached: the predecessor re-read its document inside the render path (P render.ts:62), so every
|
|
8
|
+
* row paid a disk read. Here only the first load reads; save() refreshes the cache.
|
|
9
|
+
*
|
|
10
|
+
* Why tmp+rename: a reader opening the final path mid-write must see the old document or the new one
|
|
11
|
+
* complete — never a torn file.
|
|
12
|
+
*
|
|
13
|
+
* shape: closure returning an object literal — trigger #4, this module's one runtime unit is
|
|
14
|
+
* createSettingsStore(): a cached document plus two methods, so no class, subclassing, or instanceof.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import type { Logger, ModuleSettings, SettingsDoc, SettingsStore } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
const DOC_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
/** Stands in when no logger is injected, so every factory call needs no logger guard. */
|
|
24
|
+
const NOOP_LOGGER: Logger = {
|
|
25
|
+
logLine: () => {},
|
|
26
|
+
logOnce: () => {},
|
|
27
|
+
drain: () => [],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** shape: none — one env lookup; PI_CODING_AGENT_DIR mirrors how the host resolves its agent dir. */
|
|
31
|
+
function defaultSettingsPath(): string {
|
|
32
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || join(process.env.HOME ?? "", ".pi/agent");
|
|
33
|
+
return join(agentDir, "pi-render", "settings.json");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** shape: none — one type predicate over untrusted JSON. */
|
|
37
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** shape: none — one code check on an unknown catch value. */
|
|
42
|
+
function isEnoent(error: unknown): boolean {
|
|
43
|
+
return isRecord(error) && error.code === "ENOENT";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** shape: none — one field shape per key; a bad entry reads as the module's defaults. */
|
|
47
|
+
function normalizeModule(entry: unknown): ModuleSettings {
|
|
48
|
+
if (!isRecord(entry)) return { enabled: true };
|
|
49
|
+
const enabled = typeof entry.enabled === "boolean" ? entry.enabled : true;
|
|
50
|
+
return isRecord(entry.settings) ? { enabled, settings: entry.settings } : { enabled };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** shape: none — one field shape per key; a schema library does not earn a dependency for two keys. */
|
|
54
|
+
function normalize(raw: Record<string, unknown>): SettingsDoc {
|
|
55
|
+
const version = typeof raw.version === "number" ? raw.version : DOC_VERSION;
|
|
56
|
+
const source = isRecord(raw.modules) ? raw.modules : {};
|
|
57
|
+
// Object.fromEntries writes own data properties, so a "__proto__" key cannot set the prototype.
|
|
58
|
+
const modules = Object.fromEntries(
|
|
59
|
+
Object.entries(source).map(([key, entry]): [string, ModuleSettings] => [key, normalizeModule(entry)]),
|
|
60
|
+
);
|
|
61
|
+
return { version, modules };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Cached load plus atomic save. `path` and `logger` are injectable so tests never touch the real
|
|
66
|
+
* agent directory and can read the malformed-doc line back.
|
|
67
|
+
*/
|
|
68
|
+
export function createSettingsStore(opts?: { path?: string; logger?: Logger }): SettingsStore {
|
|
69
|
+
const path = opts?.path ?? defaultSettingsPath();
|
|
70
|
+
const logger = opts?.logger ?? NOOP_LOGGER;
|
|
71
|
+
let cache: SettingsDoc | undefined;
|
|
72
|
+
|
|
73
|
+
const defaults = (): SettingsDoc => ({ version: DOC_VERSION, modules: {} });
|
|
74
|
+
|
|
75
|
+
const malformed = (why: string): SettingsDoc => {
|
|
76
|
+
logger.logOnce(`settings:${path}`, "settings", `${why} at ${path} — using defaults`);
|
|
77
|
+
return defaults();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const readFromDisk = (): SettingsDoc => {
|
|
81
|
+
let text: string;
|
|
82
|
+
try {
|
|
83
|
+
text = readFileSync(path, "utf8");
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// A missing file is the normal first run, not a fault worth a line.
|
|
86
|
+
return isEnoent(error) ? defaults() : malformed("settings unreadable");
|
|
87
|
+
}
|
|
88
|
+
let raw: unknown;
|
|
89
|
+
try {
|
|
90
|
+
raw = JSON.parse(text);
|
|
91
|
+
} catch {
|
|
92
|
+
return malformed("settings malformed");
|
|
93
|
+
}
|
|
94
|
+
if (!isRecord(raw)) return malformed("settings malformed");
|
|
95
|
+
if (raw.modules !== undefined && !isRecord(raw.modules)) return malformed("settings malformed");
|
|
96
|
+
return normalize(raw);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
load(): SettingsDoc {
|
|
101
|
+
const current = cache ?? readFromDisk();
|
|
102
|
+
cache = current;
|
|
103
|
+
return current;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
save(doc: SettingsDoc): void {
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
109
|
+
const temporary = `${path}.tmp`;
|
|
110
|
+
writeFileSync(temporary, `${JSON.stringify(doc, null, 2)}\n`);
|
|
111
|
+
renameSync(temporary, path);
|
|
112
|
+
} catch {
|
|
113
|
+
// A write failure degrades to in-memory: the panel keeps working, the disk stays old.
|
|
114
|
+
logger.logOnce(`settings:save:${path}`, "settings", `settings write failed at ${path} — kept in memory`);
|
|
115
|
+
}
|
|
116
|
+
cache = doc;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* code-theme.ts — the highlighting contract core/code-theme (T-10) implements.
|
|
3
|
+
*
|
|
4
|
+
* Boundary: the highlighter is injected — the shiki adapter in production, a counting double in
|
|
5
|
+
* tests — so no renderer imports shiki and no test needs the wasm engine.
|
|
6
|
+
*
|
|
7
|
+
* shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The injectable highlighter: code plus a language id in, one escape-wrapped line per array entry out. */
|
|
11
|
+
export type HighlightEngine = (
|
|
12
|
+
/** Source text to highlight; may be multi-line. Required. */
|
|
13
|
+
code: string,
|
|
14
|
+
/** Language id, e.g. "ts" or "bash". Required. */
|
|
15
|
+
lang: string,
|
|
16
|
+
) => string[];
|
|
17
|
+
|
|
18
|
+
/** Cached highlighting: an oversize input and an engine failure both yield plain, uncoloured lines. */
|
|
19
|
+
export interface CodeTheme {
|
|
20
|
+
/** Highlights one block, waiting for the engine. Required; never rejects — failures degrade. */
|
|
21
|
+
highlight(code: string, lang: string): Promise<string[]>;
|
|
22
|
+
/** Highlights one block from cache or synchronous engine. Required; the render-path entry. */
|
|
23
|
+
highlightSync(code: string, lang: string): string;
|
|
24
|
+
}
|