@zenodinh/pi-render 0.0.0-stage → 0.1.0
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 +279 -0
- package/package.json +66 -5
- package/src/commands/canvas.test.ts +150 -0
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.test.ts +266 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.test.ts +127 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.test.ts +322 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.test.ts +307 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.test.ts +183 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +160 -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/artifacts.test.ts +199 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.test.ts +216 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.test.ts +207 -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.test.ts +170 -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/panels.test.ts +188 -0
- package/src/renderers/content/table.test.ts +209 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/transformer.test.ts +254 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/resolver.test.ts +257 -0
- package/src/renderers/tool/runtime.test.ts +313 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.test.ts +110 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.test.ts +212 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.test.ts +260 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.test.ts +173 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.test.ts +340 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.test.ts +197 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.test.ts +145 -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
- package/themes/themes.test.ts +251 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/tools/bash.ts:186-200 + src/tools/read.ts:243-262 — survives because:
|
|
2
|
+
// parse-once memoization and the async staleness token are the two protocols worth keeping; the
|
|
3
|
+
// monkey-patched Text.render and the per-tool copies are not.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* runtime.ts — a declarative RowSpec in, host tool renderers out: parse once, project per invocation.
|
|
7
|
+
*
|
|
8
|
+
* Boundary: one runtime per row, created by the master resolver. It owns the protocol every spec would
|
|
9
|
+
* otherwise re-implement — memo, args gate, never-throw degradation, async decorate. Host shapes arrive
|
|
10
|
+
* as parameters (ctx, theme) and stay structural, so a unit test needs no pi runtime.
|
|
11
|
+
*
|
|
12
|
+
* shape: closure returning an object literal — trigger #4: the per-row memo/token live behind the
|
|
13
|
+
* renderCall/renderResult methods, with no subclassing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
HostTheme,
|
|
18
|
+
RenderContext,
|
|
19
|
+
RenderResultOptions,
|
|
20
|
+
ToolRenderers,
|
|
21
|
+
ToolResult,
|
|
22
|
+
UiComponent,
|
|
23
|
+
} from "../../core/types/host.ts";
|
|
24
|
+
import type { Logger } from "../../core/types/log.ts";
|
|
25
|
+
import type { RowPaint, RowPaintFactory } from "../../core/types/paint.ts";
|
|
26
|
+
import type { RowSpec, RowView } from "./types.ts";
|
|
27
|
+
|
|
28
|
+
/** `build`'s "unrecognized"; the runtime answers with rawRow instead of projecting. */
|
|
29
|
+
const UNKNOWN = "unknown";
|
|
30
|
+
const LOG_SCOPE = "tool.row";
|
|
31
|
+
/** ctx.state keys are namespaced: a spec reads the same bag and must not collide with the runtime. */
|
|
32
|
+
const MEMO_KEY = "piRender.memo";
|
|
33
|
+
const TOKEN_KEY = "piRender.token";
|
|
34
|
+
const DECORATED_KEY = "piRender.decorated";
|
|
35
|
+
/** A session reaches 1,400+ rows, so cross-row parsed models are bounded by count and by size. */
|
|
36
|
+
const CACHE_MAX_ENTRIES = 512;
|
|
37
|
+
const CACHE_MAX_CHARS = 16_000_000;
|
|
38
|
+
|
|
39
|
+
type ResultContent = NonNullable<ToolResult["content"]>;
|
|
40
|
+
|
|
41
|
+
/** The per-row memo: the result.content reference and length that produced this model. */
|
|
42
|
+
interface ModelMemo {
|
|
43
|
+
src: ResultContent | undefined;
|
|
44
|
+
len: number;
|
|
45
|
+
model: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One parsed model shared across rows, keyed on the stable result.content reference. */
|
|
49
|
+
interface CachedModel {
|
|
50
|
+
spec: RowSpec;
|
|
51
|
+
chars: number;
|
|
52
|
+
model: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Module scope, not ctx.state: reuse across rows is the point, and only here can the 512-entry /
|
|
56
|
+
// 16M-char bound be enforced. Map insertion order is the recency order.
|
|
57
|
+
const modelCache = new Map<ResultContent, CachedModel>();
|
|
58
|
+
let cachedChars = 0;
|
|
59
|
+
|
|
60
|
+
/** The runtime's own envelope; the model is a spec-defined type, not runtime-checkable. */
|
|
61
|
+
function isModelMemo(value: unknown): value is ModelMemo {
|
|
62
|
+
return typeof value === "object" && value !== null && "src" in value && "len" in value && "model" in value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readMemo(ctx: RenderContext): ModelMemo | undefined {
|
|
66
|
+
const value: unknown = ctx.state[MEMO_KEY];
|
|
67
|
+
return isModelMemo(value) ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeMemo(ctx: RenderContext, src: ResultContent | undefined, len: number, model: unknown): void {
|
|
71
|
+
ctx.state[MEMO_KEY] = { src, len, model };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The engine's text verbatim, collapsed to one line: the degradation contract's body. */
|
|
75
|
+
function textOf(result: ToolResult): string {
|
|
76
|
+
const content = result.content;
|
|
77
|
+
if (!Array.isArray(content)) return "";
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
for (const block of content) {
|
|
80
|
+
if (block !== null && typeof block === "object" && typeof block.text === "string") parts.push(block.text);
|
|
81
|
+
}
|
|
82
|
+
return parts
|
|
83
|
+
.join(" ")
|
|
84
|
+
.replace(/\s*\n\s*/g, " ")
|
|
85
|
+
.trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function charsOf(content: ResultContent): number {
|
|
89
|
+
let chars = 0;
|
|
90
|
+
for (const block of content) {
|
|
91
|
+
if (block !== null && typeof block === "object" && typeof block.text === "string") chars += block.text.length;
|
|
92
|
+
}
|
|
93
|
+
return chars;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cacheGet(spec: RowSpec, content: ResultContent): CachedModel | undefined {
|
|
97
|
+
const hit = modelCache.get(content);
|
|
98
|
+
if (hit === undefined || hit.spec !== spec) return undefined;
|
|
99
|
+
// A hit is the most recent use: re-insert so the head is always the eviction victim.
|
|
100
|
+
modelCache.delete(content);
|
|
101
|
+
modelCache.set(content, hit);
|
|
102
|
+
return hit;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cacheSet(spec: RowSpec, content: ResultContent, model: unknown): void {
|
|
106
|
+
const previous = modelCache.get(content);
|
|
107
|
+
if (previous !== undefined) cachedChars -= previous.chars;
|
|
108
|
+
modelCache.delete(content);
|
|
109
|
+
const entry: CachedModel = { spec, chars: charsOf(content), model };
|
|
110
|
+
modelCache.set(content, entry);
|
|
111
|
+
cachedChars += entry.chars;
|
|
112
|
+
while (modelCache.size > CACHE_MAX_ENTRIES || cachedChars > CACHE_MAX_CHARS) {
|
|
113
|
+
const oldest = modelCache.keys().next().value;
|
|
114
|
+
if (oldest === undefined) break;
|
|
115
|
+
const evicted = modelCache.get(oldest);
|
|
116
|
+
modelCache.delete(oldest);
|
|
117
|
+
if (evicted !== undefined) cachedChars -= evicted.chars;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function viewOf(options: RenderResultOptions, ctx: RenderContext): RowView {
|
|
122
|
+
return { expanded: options.expanded, isError: ctx.isError === true, isPartial: options.isPartial };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function rawBody(result: ToolResult, paint: RowPaint | undefined): string {
|
|
126
|
+
const text = textOf(result);
|
|
127
|
+
if (paint === undefined) return text;
|
|
128
|
+
try {
|
|
129
|
+
return paint.muted(text);
|
|
130
|
+
} catch {
|
|
131
|
+
// A theme token can throw mid-render (host fg); the degraded row must still come back.
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function logFailure(log: Logger, ctx: RenderContext, err: unknown): void {
|
|
137
|
+
const key = typeof ctx.toolCallId === "string" ? ctx.toolCallId : LOG_SCOPE;
|
|
138
|
+
const text = (err instanceof Error ? err.message : String(err)).replace(/\s+/g, " ").slice(0, 200);
|
|
139
|
+
log.logOnce(key, LOG_SCOPE, `row degraded: ${text}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** shape: closure returning an object literal — trigger #4: one stored text behind render/invalidate. */
|
|
143
|
+
function textFor(ctx: RenderContext, body: string): UiComponent {
|
|
144
|
+
const reuse = ctx.lastComponent;
|
|
145
|
+
if (reuse !== undefined) {
|
|
146
|
+
const setText = reuse.setText;
|
|
147
|
+
if (setText !== undefined) {
|
|
148
|
+
setText.call(reuse, body);
|
|
149
|
+
return reuse;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
let current = body;
|
|
153
|
+
return {
|
|
154
|
+
render(_width: number): string[] {
|
|
155
|
+
// One entry per line; an empty body still yields one line, so a degraded row never vanishes.
|
|
156
|
+
// Width is the component's own: renderResult receives none, so nothing here is width-keyed.
|
|
157
|
+
return current.split("\n");
|
|
158
|
+
},
|
|
159
|
+
invalidate(): void {
|
|
160
|
+
// The host tracks staleness; this component caches no frame.
|
|
161
|
+
},
|
|
162
|
+
setText(value: string): void {
|
|
163
|
+
current = value;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function nextToken(ctx: RenderContext): number {
|
|
169
|
+
const current = ctx.state[TOKEN_KEY];
|
|
170
|
+
const next = typeof current === "number" ? current + 1 : 1;
|
|
171
|
+
ctx.state[TOKEN_KEY] = next;
|
|
172
|
+
return next;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function applyDecorate(log: Logger, ctx: RenderContext, token: number, content: ResultContent, partial: object): void {
|
|
176
|
+
try {
|
|
177
|
+
// The content changed while decorate was in flight: the new model must not inherit this result.
|
|
178
|
+
if (ctx.state[TOKEN_KEY] !== token) return;
|
|
179
|
+
const memo = readMemo(ctx);
|
|
180
|
+
if (memo === undefined || memo.src !== content) return;
|
|
181
|
+
writeMemo(ctx, memo.src, memo.len, Object.assign({}, memo.model, partial));
|
|
182
|
+
ctx.invalidate?.();
|
|
183
|
+
} catch (err) {
|
|
184
|
+
logFailure(log, ctx, err);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function scheduleDecorate(
|
|
189
|
+
spec: RowSpec,
|
|
190
|
+
log: Logger,
|
|
191
|
+
ctx: RenderContext,
|
|
192
|
+
content: ResultContent,
|
|
193
|
+
model: unknown,
|
|
194
|
+
): void {
|
|
195
|
+
const decorate = spec.decorate;
|
|
196
|
+
if (decorate === undefined) return;
|
|
197
|
+
if (ctx.state[DECORATED_KEY] === content) return; // one schedule per content reference
|
|
198
|
+
const token = nextToken(ctx);
|
|
199
|
+
ctx.state[DECORATED_KEY] = content;
|
|
200
|
+
// Fire-and-forget: on success ctx.invalidate redraws, on failure the log captures it; nothing awaits.
|
|
201
|
+
void decorate(model, ctx).then(
|
|
202
|
+
(partial) => {
|
|
203
|
+
applyDecorate(log, ctx, token, content, partial);
|
|
204
|
+
},
|
|
205
|
+
(err: unknown) => {
|
|
206
|
+
logFailure(log, ctx, err);
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory; log: Logger }): ToolRenderers {
|
|
212
|
+
const modelFor = (result: ToolResult, ctx: RenderContext): unknown => {
|
|
213
|
+
const content = Array.isArray(result.content) ? result.content : undefined;
|
|
214
|
+
const len = content === undefined ? 0 : content.length;
|
|
215
|
+
const memo = readMemo(ctx);
|
|
216
|
+
if (content !== undefined && memo !== undefined && memo.src === content && memo.len === len) return memo.model;
|
|
217
|
+
|
|
218
|
+
if (content !== undefined) {
|
|
219
|
+
const cached = cacheGet(spec, content);
|
|
220
|
+
if (cached !== undefined) {
|
|
221
|
+
writeMemo(ctx, content, len, cached.model);
|
|
222
|
+
return cached.model;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const built = spec.build(result, ctx);
|
|
227
|
+
if (built === UNKNOWN) return UNKNOWN;
|
|
228
|
+
if (content !== undefined) {
|
|
229
|
+
cacheSet(spec, content, built);
|
|
230
|
+
writeMemo(ctx, content, len, built);
|
|
231
|
+
scheduleDecorate(spec, deps.log, ctx, content, built);
|
|
232
|
+
}
|
|
233
|
+
return built;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const renderCall = (args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent => {
|
|
237
|
+
try {
|
|
238
|
+
const paint = deps.paint(theme);
|
|
239
|
+
// A partial argument set is not a title: buildCall sees {} until the host says argsComplete.
|
|
240
|
+
const model = spec.buildCall(ctx.argsComplete === true ? args : {}, ctx);
|
|
241
|
+
return textFor(ctx, paint.title(model.title));
|
|
242
|
+
} catch (err) {
|
|
243
|
+
logFailure(deps.log, ctx, err);
|
|
244
|
+
return textFor(ctx, "");
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const renderResult = (
|
|
249
|
+
result: ToolResult,
|
|
250
|
+
options: RenderResultOptions,
|
|
251
|
+
theme: HostTheme,
|
|
252
|
+
ctx: RenderContext,
|
|
253
|
+
): UiComponent => {
|
|
254
|
+
try {
|
|
255
|
+
const paint = deps.paint(theme);
|
|
256
|
+
const model = modelFor(result, ctx);
|
|
257
|
+
const body = model === UNKNOWN ? rawBody(result, paint) : spec.project(model, viewOf(options, ctx), paint);
|
|
258
|
+
return textFor(ctx, body);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
// The paint may itself be the thrower, so the degraded row is built without it.
|
|
261
|
+
logFailure(deps.log, ctx, err);
|
|
262
|
+
return textFor(ctx, rawBody(result, undefined));
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return { renderCall, renderResult };
|
|
267
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash.test.ts — the bash row spec as a specification.
|
|
3
|
+
*
|
|
4
|
+
* Behavior spec (from the T-14 port source, pi-pretty-tui/src/tools/bash.ts):
|
|
5
|
+
* buildCall({command}, argsComplete) -> title "bash: <command>"; gate closed -> "bash" only.
|
|
6
|
+
* build(result, ctx) -> { output, exitCode, durationMs, isError } with the exit
|
|
7
|
+
* status inferred from the output and the time read from ctx.state.
|
|
8
|
+
* project(model, collapsed) -> one summary line: "<n> lines · exit <code> · <duration> · hint";
|
|
9
|
+
* an error shows its message on that line instead.
|
|
10
|
+
* project(model, expanded) -> summary header + blank + indented output body.
|
|
11
|
+
*
|
|
12
|
+
* Enters only at the exported record's seam with the marker fake paint and testRenderContext; no
|
|
13
|
+
* module internals are imported.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { describe, expect, it } from "vitest";
|
|
19
|
+
import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
|
|
20
|
+
import type { RenderContext, RowView, ToolResult } from "../../../core/types.ts";
|
|
21
|
+
import { bashRow } from "./bash.ts";
|
|
22
|
+
|
|
23
|
+
// Marker escapes pinned from test/fakes/paint.ts:22-35 (muted 202/222, output 201/221, error 204/224);
|
|
24
|
+
// a real escape cannot read as a marker because the marker codes sit off the host palette.
|
|
25
|
+
const MUTED = (text: string): string => `\u001b[38;5;202m${text}\u001b[38;5;222m`;
|
|
26
|
+
const OUTPUT = (text: string): string => `\u001b[38;5;201m${text}\u001b[38;5;221m`;
|
|
27
|
+
const ERROR = (text: string): string => `\u001b[38;5;204m${text}\u001b[38;5;224m`;
|
|
28
|
+
|
|
29
|
+
const MARKER = markerPaint();
|
|
30
|
+
|
|
31
|
+
// recorded-from: pi host bash result — content is the engine's stdout verbatim, trailing newline
|
|
32
|
+
// included; the single-line shape is pi-pretty-tui/src/tools/bash-collapse.test.ts:46, 2026-10-03.
|
|
33
|
+
const CANONICAL: ToolResult = { content: [{ type: "text", text: "42\n" }] };
|
|
34
|
+
// invented: a real `bash: <cmd>: command not found` stderr line — the literal text only has to match
|
|
35
|
+
// inferExit's recorded pattern (pi-pretty-tui/src/core/helpers.ts:44-49).
|
|
36
|
+
const FAILED: ToolResult = { content: [{ type: "text", text: "bash: nope: command not found\n" }] };
|
|
37
|
+
|
|
38
|
+
// Assembled so a future edit cannot satisfy the scan by quoting its own needle.
|
|
39
|
+
const FORBIDDEN = ["registerTool", "promptSnippet", "promptGuidelines", "sdkTool", ".execute", "as unknown as"];
|
|
40
|
+
|
|
41
|
+
type BashModel = Exclude<ReturnType<typeof bashRow.spec.build>, "unknown">;
|
|
42
|
+
|
|
43
|
+
function view(expanded: boolean): RowView {
|
|
44
|
+
return { expanded, isError: false, isPartial: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parse(result: ToolResult, ctx: RenderContext): BashModel {
|
|
48
|
+
const model = bashRow.spec.build(result, ctx);
|
|
49
|
+
if (model === "unknown") throw new Error("a bash result must always parse");
|
|
50
|
+
return model;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("bash row spec", () => {
|
|
54
|
+
it("AC-1 (golden row): canonical collapsed and expanded output is byte-pinned", () => {
|
|
55
|
+
// spec: project(build(CANONICAL), collapsed) -> "1 line · exit 0 · 1.2s · ctrl+o to expand"
|
|
56
|
+
const ctx = testRenderContext({ args: { command: "npm test" }, state: { startedAt: 1_000, endedAt: 2_200 } });
|
|
57
|
+
expect(bashRow.spec.buildCall({ command: "npm test" }, ctx).title).toBe("bash: npm test");
|
|
58
|
+
const model = parse(CANONICAL, ctx);
|
|
59
|
+
// fails_when: a plausible-looking row renders wrong data (count, status, duration)
|
|
60
|
+
expect(bashRow.spec.project(model, view(false), MARKER)).toBe(MUTED("1 line · exit 0 · 1.2s · ctrl+o to expand"));
|
|
61
|
+
expect(bashRow.spec.project(model, view(true), MARKER)).toBe(
|
|
62
|
+
[MUTED("1 line · exit 0 · 1.2s"), "", ` ${OUTPUT("42")}`].join("\n"),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("AC-2 (error row): the error summarizes collapsed and opens to its body", () => {
|
|
67
|
+
// spec: build(FAILED, isError) -> exit 1; project collapsed -> error on the summary line
|
|
68
|
+
const ctx = testRenderContext({
|
|
69
|
+
args: { command: "nope" },
|
|
70
|
+
isError: true,
|
|
71
|
+
state: { startedAt: 1_000, endedAt: 1_500 },
|
|
72
|
+
});
|
|
73
|
+
const model = parse(FAILED, ctx);
|
|
74
|
+
expect(model.exitCode).toBe(1);
|
|
75
|
+
// fails_when: errors render blank or crash
|
|
76
|
+
expect(bashRow.spec.project(model, view(false), MARKER)).toBe(
|
|
77
|
+
ERROR("bash: nope: command not found") + MUTED(" · 1 line · exit 1 · 500ms · ctrl+o to expand"),
|
|
78
|
+
);
|
|
79
|
+
expect(bashRow.spec.project(model, view(true), MARKER)).toBe(
|
|
80
|
+
[MUTED("1 line · exit 1 · 500ms"), "", ` ${ERROR("bash: nope: command not found")}`].join("\n"),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("AC-3 (no execution ported): the source carries no registration or prompt text", () => {
|
|
85
|
+
const source = readFileSync(fileURLToPath(new URL("./bash.ts", import.meta.url)), "utf8");
|
|
86
|
+
// fails_when: the port smuggles execution back in
|
|
87
|
+
for (const needle of FORBIDDEN) expect(source, needle).not.toContain(needle);
|
|
88
|
+
expect(bashRow.name).toBe("bash");
|
|
89
|
+
expect(bashRow.descriptor).toEqual({ key: "row.bash", name: "Bash row", defaultEnabled: true, settings: [] });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("AC-4 (args gate): the call line is the tool name only until argsComplete", () => {
|
|
93
|
+
const gated = testRenderContext({ args: {}, argsComplete: false });
|
|
94
|
+
// fails_when: fabricated argument values reach the title
|
|
95
|
+
expect(bashRow.spec.buildCall({}, gated).title).toBe("bash");
|
|
96
|
+
expect(bashRow.spec.buildCall({ command: "rm -rf /" }, gated).title).toBe("bash");
|
|
97
|
+
// the gate open with no command is still name-only, never an empty title
|
|
98
|
+
expect(bashRow.spec.buildCall({}, testRenderContext({ args: {} })).title).toBe("bash");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("AC-5 (paint-only color): every escape in a projected row is a paint marker", () => {
|
|
102
|
+
const ctx = testRenderContext({ args: { command: "npm test" }, state: { startedAt: 1_000, endedAt: 2_200 } });
|
|
103
|
+
const model = parse(CANONICAL, ctx);
|
|
104
|
+
const collapsed = bashRow.spec.project(model, view(false), MARKER);
|
|
105
|
+
const expanded = bashRow.spec.project(model, view(true), MARKER);
|
|
106
|
+
// fails_when: a hardcoded color appears outside core/paint (marker + scan both catch it)
|
|
107
|
+
for (const output of [collapsed, expanded]) assertNoRealEscapes(output);
|
|
108
|
+
expect(collapsed).toContain("\u001b[38;5;202m");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bash.ts — the bash row spec: a shell call shown as one scannable summary line, expandable to output.
|
|
3
|
+
*
|
|
4
|
+
* Boundary: lane-local. Pure parse + project — the spec registers nothing and runs nothing; colors
|
|
5
|
+
* come only from the paint handed to project. Duration is read from the row's own state bag, never
|
|
6
|
+
* injected by a wrapper (the predecessor's metrics wrapper dies with registration).
|
|
7
|
+
*
|
|
8
|
+
* shape: object literal implementing RowSpec — trigger #7, the three contract fields are fixed-shape
|
|
9
|
+
* functions with no per-row state of their own, so a closure or class would be ceremony.
|
|
10
|
+
*
|
|
11
|
+
* ported from pi-pretty-tui/src/tools/bash.ts — survives because: exit-code inference, exit-line
|
|
12
|
+
* stripping, error compaction and duration formatting are pure view logic; its registration, run
|
|
13
|
+
* wrapper, metrics wrapper and render monkey-patch all die (see T-14).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
CallModel,
|
|
18
|
+
ModuleDescriptor,
|
|
19
|
+
RenderContext,
|
|
20
|
+
RowPaint,
|
|
21
|
+
RowSpec,
|
|
22
|
+
RowView,
|
|
23
|
+
ToolResult,
|
|
24
|
+
} from "../../../core/types.ts";
|
|
25
|
+
|
|
26
|
+
const INDENT = " ";
|
|
27
|
+
const HINT = "ctrl+o to expand";
|
|
28
|
+
|
|
29
|
+
// The row's clock lives in its state bag — the host's own bash renderer keeps it there too (pi 1.0.4
|
|
30
|
+
// dist/core/tools/renderers/bash.js), so no result wrapper may carry the duration.
|
|
31
|
+
const STARTED_KEY = "startedAt";
|
|
32
|
+
const ENDED_KEY = "endedAt";
|
|
33
|
+
|
|
34
|
+
/** Parsed bash result: cleaned output plus the status, timing and failure flag it renders from. */
|
|
35
|
+
interface BashModel {
|
|
36
|
+
output: string;
|
|
37
|
+
exitCode: number | null;
|
|
38
|
+
durationMs: number | null;
|
|
39
|
+
isError: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Joins the result's text blocks exactly as the host delivers them; only text blocks are rendered. */
|
|
43
|
+
function textOf(result: ToolResult): string {
|
|
44
|
+
const parts: string[] = [];
|
|
45
|
+
for (const block of result.content ?? []) {
|
|
46
|
+
if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
|
|
47
|
+
}
|
|
48
|
+
return parts.join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Folds a multi-line command onto one call line, so a script command never paints one row per line. */
|
|
52
|
+
function flatten(command: string): string {
|
|
53
|
+
return command.replace(/[ \t]*[\r\n]+[ \t]*/g, " ").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalize(text: string): string {
|
|
57
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Drops the host's own "Command exited with code N" trailer — the status renders in the summary. */
|
|
61
|
+
function stripExitLine(text: string): string {
|
|
62
|
+
return normalize(text)
|
|
63
|
+
.split("\n")
|
|
64
|
+
.filter((line) => !/^Command exited with code \d+$/i.test(line.trim()))
|
|
65
|
+
.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function trimEnd(text: string): string {
|
|
69
|
+
return text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Collapses runs of blank lines so a stack trace summarizes to a bounded body. */
|
|
73
|
+
function compactError(text: string): string[] {
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
let wasBlank = false;
|
|
76
|
+
for (const line of normalize(text).trim().split("\n")) {
|
|
77
|
+
const isBlank = line.trim() === "";
|
|
78
|
+
if (isBlank && wasBlank) continue;
|
|
79
|
+
lines.push(line);
|
|
80
|
+
wasBlank = isBlank;
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Infers the shell's exit status from the output when the host reports none. */
|
|
86
|
+
function inferExit(text: string, fallback: number | null): number | null {
|
|
87
|
+
const match = text.match(/(?:exit code|exited with(?: code)?|exit status)[:\s]*(\d+)/i);
|
|
88
|
+
const digit = match?.[1];
|
|
89
|
+
if (digit !== undefined) return Number(digit);
|
|
90
|
+
if (text.includes("command not found") || text.includes("No such file")) return 1;
|
|
91
|
+
return fallback;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Sub-second as ms, then compact whole seconds — ports formatElapsedMs. */
|
|
95
|
+
function formatDuration(ms: number): string {
|
|
96
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
97
|
+
const seconds = ms / 1000;
|
|
98
|
+
return seconds < 10 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function recordStart(ctx: RenderContext): void {
|
|
102
|
+
if (typeof ctx.state[STARTED_KEY] !== "number") ctx.state[STARTED_KEY] = Date.now();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function settleEnd(ctx: RenderContext): void {
|
|
106
|
+
// A streaming row has no final time yet; isPartial gates the write instead of a remove wrapper.
|
|
107
|
+
if (ctx.isPartial === true || typeof ctx.state[ENDED_KEY] === "number") return;
|
|
108
|
+
ctx.state[ENDED_KEY] = Date.now();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function readDuration(ctx: RenderContext): number | null {
|
|
112
|
+
const started = ctx.state[STARTED_KEY];
|
|
113
|
+
const ended = ctx.state[ENDED_KEY];
|
|
114
|
+
if (typeof started !== "number" || typeof ended !== "number") return null;
|
|
115
|
+
return Math.max(0, ended - started);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// shape: object literal implementing RowSpec — trigger #7, a fixed record of three stateless functions.
|
|
119
|
+
const bashSpec: RowSpec<BashModel> = {
|
|
120
|
+
buildCall(args, ctx): CallModel {
|
|
121
|
+
recordStart(ctx);
|
|
122
|
+
if (ctx.argsComplete !== true) return { title: "bash" };
|
|
123
|
+
const value = args.command;
|
|
124
|
+
if (typeof value !== "string") return { title: "bash" };
|
|
125
|
+
const command = flatten(value);
|
|
126
|
+
return { title: command === "" ? "bash" : `bash: ${command}` };
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
build(result, ctx): BashModel | "unknown" {
|
|
130
|
+
settleEnd(ctx);
|
|
131
|
+
const raw = textOf(result);
|
|
132
|
+
const failed = ctx.isError === true;
|
|
133
|
+
const exitCode = inferExit(raw, failed ? 1 : 0);
|
|
134
|
+
return {
|
|
135
|
+
output: stripExitLine(raw),
|
|
136
|
+
exitCode,
|
|
137
|
+
durationMs: readDuration(ctx),
|
|
138
|
+
isError: failed || (exitCode !== null && exitCode !== 0),
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
project(m: BashModel, view: RowView, paint: RowPaint): string {
|
|
143
|
+
const body = m.isError ? compactError(m.output).join("\n") : trimEnd(m.output);
|
|
144
|
+
const count = body === "" ? 0 : body.split("\n").length;
|
|
145
|
+
const stats = [`${count} line${count === 1 ? "" : "s"}`];
|
|
146
|
+
if (m.exitCode !== null) stats.push(`exit ${m.exitCode}`);
|
|
147
|
+
if (m.durationMs !== null) stats.push(formatDuration(m.durationMs));
|
|
148
|
+
|
|
149
|
+
if (!view.expanded) {
|
|
150
|
+
const tail = `${stats.join(" · ")} · ${HINT}`;
|
|
151
|
+
if (!m.isError) return paint.muted(tail);
|
|
152
|
+
const head = body === "" ? "Error" : (body.split("\n")[0] ?? "Error");
|
|
153
|
+
return `${paint.error(head)}${paint.muted(` · ${tail}`)}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const header = paint.muted(stats.join(" · "));
|
|
157
|
+
if (body === "") return header;
|
|
158
|
+
const lines = body.split("\n").map((line) => `${INDENT}${m.isError ? paint.error(line) : paint.output(line)}`);
|
|
159
|
+
return [header, "", ...lines].join("\n");
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// shape: object literal — trigger #7, the spec module record T-28 composes; fields are known statically.
|
|
164
|
+
export const bashRow: { name: string; spec: RowSpec<BashModel>; descriptor: ModuleDescriptor } = {
|
|
165
|
+
name: "bash",
|
|
166
|
+
spec: bashSpec,
|
|
167
|
+
descriptor: { key: "row.bash", name: "Bash row", defaultEnabled: true, settings: [] },
|
|
168
|
+
};
|