@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,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — the master resolver: the tool-name table, the captured host base, and one registry check
|
|
3
|
+
* per invocation — the pluggable toggle for every tool row.
|
|
4
|
+
*
|
|
5
|
+
* Boundary: the host consults this ONCE per row component construction, keyed on tool name alone, and
|
|
6
|
+
* `next()` exists only at that moment; the base renderers it hands back are captured here and reused for
|
|
7
|
+
* the component's lifetime. Both the table and the collaborators arrive as parameters (T-28 composes
|
|
8
|
+
* them), so a unit test drives the whole toggle with fake specs, a fake registry and a fake base.
|
|
9
|
+
*
|
|
10
|
+
* Replaces pi-pretty-tui's three attachment mechanisms (shadow registration, Proxy shim, orphan
|
|
11
|
+
* factories) with one resolver — none of them ported.
|
|
12
|
+
*
|
|
13
|
+
* shape: closure returning a resolver function — trigger #4: the factory builds and captures one runtime
|
|
14
|
+
* per spec, and the resolver it returns keeps no per-row state of its own.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
HostTheme,
|
|
19
|
+
Logger,
|
|
20
|
+
Registry,
|
|
21
|
+
RenderContext,
|
|
22
|
+
RenderResultOptions,
|
|
23
|
+
RowPaintFactory,
|
|
24
|
+
ToolRendererResolver,
|
|
25
|
+
ToolRenderers,
|
|
26
|
+
ToolResult,
|
|
27
|
+
UiComponent,
|
|
28
|
+
} from "../../core/types.ts";
|
|
29
|
+
import { createRowRenderers } from "./runtime.ts";
|
|
30
|
+
import type { RowSpec } from "./types.ts";
|
|
31
|
+
|
|
32
|
+
/** The host's own shell frame: chrome belongs to pi-zentui, so a row never asks for a custom one. */
|
|
33
|
+
const SHELL = "default";
|
|
34
|
+
const SCOPE = "tool.resolve";
|
|
35
|
+
|
|
36
|
+
/** The resolver's injected collaborators — the row paint factory, plus the keyed diagnostic sink. */
|
|
37
|
+
export interface MasterResolverDeps {
|
|
38
|
+
/** Region-1 row paint, resolved per render call so a theme switch repaints. Required. */
|
|
39
|
+
paint: RowPaintFactory;
|
|
40
|
+
/** Diagnostics for a spec whose runtime cannot draw. Required. */
|
|
41
|
+
log: Logger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One table entry: the spec plus its registry key (search's four names share one key — T-20 ruling). */
|
|
45
|
+
export interface SpecEntry {
|
|
46
|
+
/** The spec the runtime renders when enabled. Required. */
|
|
47
|
+
spec: RowSpec;
|
|
48
|
+
/** Registry key governing this name — explicit, because keys are not always "row." + name. Required. */
|
|
49
|
+
key: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The row runtime's built renderers: both slots, always (runtime.ts returns them unconditionally). */
|
|
53
|
+
type RowRuntime = Required<Pick<ToolRenderers, "renderCall" | "renderResult">>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Base renderers pattern-match the component they are handed and clear it (host grep.js:65-67), so a
|
|
57
|
+
* delegated call gets a blank slot: a foreign component there is a type mismatch, hence a throw.
|
|
58
|
+
*/
|
|
59
|
+
function withoutComponent(ctx: RenderContext): RenderContext {
|
|
60
|
+
// Copy, never mutate: the host reuses its ctx object for its own bookkeeping.
|
|
61
|
+
return { ...ctx, lastComponent: undefined };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// shape: closure returning a resolver function — trigger #4: one runtime binding per spec is captured by
|
|
65
|
+
// the factory; the resolver allocates only what a single row construction needs.
|
|
66
|
+
export function createMasterResolver(
|
|
67
|
+
registry: Registry,
|
|
68
|
+
specs: Record<string, SpecEntry>,
|
|
69
|
+
deps: MasterResolverDeps,
|
|
70
|
+
): ToolRendererResolver {
|
|
71
|
+
// Built ONCE per spec, never per row or per frame: the memo, args gate and decorate protocol belong to
|
|
72
|
+
// the spec. A Map, not the injected object — a bare `{}` lookup would answer for "constructor" and
|
|
73
|
+
// style a row the table never declared.
|
|
74
|
+
const runtimes = new Map<string, { renderers: RowRuntime; key: string }>();
|
|
75
|
+
for (const [name, { spec, key }] of Object.entries(specs)) {
|
|
76
|
+
const { renderCall, renderResult } = createRowRenderers(spec, deps);
|
|
77
|
+
// The host type marks the slots optional because a resolver may omit one to delegate; the runtime
|
|
78
|
+
// supplies both. A slotless runtime cannot draw a row, so that name stays with the host chain.
|
|
79
|
+
if (renderCall === undefined || renderResult === undefined) {
|
|
80
|
+
deps.log.logOnce(`runtime:${name}`, SCOPE, `row runtime for ${name} returned no renderers`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
runtimes.set(name, { renderers: { renderCall, renderResult }, key });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (toolName, next) => {
|
|
87
|
+
const entry = runtimes.get(toolName);
|
|
88
|
+
if (entry === undefined) return next(); // not our table: the host chain answers, verbatim
|
|
89
|
+
const base = next(); // captured here and only here — next() is gone once this resolver returns
|
|
90
|
+
const key = entry.key; // explicit: search's four names share "row.search" (T-20 ruling)
|
|
91
|
+
const runtime = entry.renderers;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
renderShell: SHELL,
|
|
95
|
+
renderCall(args: Record<string, unknown>, theme: HostTheme, ctx: RenderContext): UiComponent {
|
|
96
|
+
const baseCall = base?.renderCall;
|
|
97
|
+
if (registry.isEnabled(key) || baseCall === undefined) return runtime.renderCall(args, theme, ctx);
|
|
98
|
+
return baseCall(args, theme, withoutComponent(ctx));
|
|
99
|
+
},
|
|
100
|
+
renderResult(
|
|
101
|
+
result: ToolResult,
|
|
102
|
+
options: RenderResultOptions,
|
|
103
|
+
theme: HostTheme,
|
|
104
|
+
ctx: RenderContext,
|
|
105
|
+
): UiComponent {
|
|
106
|
+
const baseResult = base?.renderResult;
|
|
107
|
+
if (registry.isEnabled(key) || baseResult === undefined)
|
|
108
|
+
return runtime.renderResult(result, options, theme, ctx);
|
|
109
|
+
return baseResult(result, options, theme, withoutComponent(ctx));
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -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,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
|
+
};
|