@anthodev/pi-tools-style 0.1.4 → 0.2.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/README.md +128 -52
- package/index.ts +116 -90
- package/package.json +5 -5
- package/src/config-menu.ts +261 -0
- package/src/frame.ts +282 -97
- package/src/render-decorator.ts +181 -200
- package/src/settings.ts +202 -30
- package/src/tool-category.ts +3 -30
- package/src/tool-presentation.ts +1484 -0
- package/src/tool-renderer.ts +752 -0
- package/src/tool-spinner.ts +72 -89
- package/src/web-search-presentation.ts +986 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { keyHint, type ExtensionAPI, type Theme, type ToolInfo, type ToolRendererResolver, type ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Container, MouseRegion, Text, getCapabilities, getImageDimensions, imageFallback, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
3
|
+
import { containsTerminalImage, hasStableToolBody, isInteractiveToolComponent } from "./frame.js";
|
|
4
|
+
import { isExecutionToolName, type ToolContext, type ToolResult, type ToolSnapshot, type ToolView, type ToolViewFactory } from "./tool-presentation.js";
|
|
5
|
+
import { setToolSpinnerActive, type ToolSpinnerTarget } from "./tool-spinner.js";
|
|
6
|
+
import { getIconMode, getSettings, isShellEnabledByConfig, isToolEnabledByConfig } from "./settings.js";
|
|
7
|
+
import type { IconMode } from "./tool-icon.js";
|
|
8
|
+
import { isWebSearchTool } from "./web-search-presentation.js";
|
|
9
|
+
|
|
10
|
+
export interface ToolChildBounds {
|
|
11
|
+
component: Component;
|
|
12
|
+
/** Origin local to the owning fragment, in terminal cells. */
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ToolLayout {
|
|
20
|
+
callRows: string[];
|
|
21
|
+
resultRows: string[];
|
|
22
|
+
callChildBounds: readonly ToolChildBounds[];
|
|
23
|
+
resultChildBounds: readonly ToolChildBounds[];
|
|
24
|
+
/** Fragment origins in the complete, shared layout. */
|
|
25
|
+
callOffset: number;
|
|
26
|
+
resultOffset: number;
|
|
27
|
+
/** Refreshes callRows[0] in place; every other row and all bounds retain identity.
|
|
28
|
+
* Valid only while semantic inputs, width, padding, theme, icons and expansion are unchanged. */
|
|
29
|
+
refreshHeader?: (spinnerFrameIndex?: number) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One producer and one shared layout path, installed when semantic views are available. */
|
|
33
|
+
export interface ToolRendererImplementation {
|
|
34
|
+
createToolView: ToolViewFactory;
|
|
35
|
+
/** Display-only clock override for shell; public tool callers omit it. */
|
|
36
|
+
layoutToolView: (view: ToolView, snapshot: ToolSnapshot, theme: Theme, width: number, spinnerFrameIndex?: number) => ToolLayout;
|
|
37
|
+
/** May close over the exact-name settings list; called after every snapshot. */
|
|
38
|
+
isWebSearchTool?: (toolName: string, toolInfo: ToolInfo | undefined, details: unknown) => boolean;
|
|
39
|
+
/** Drop owned derived preparation for a real component/environment invalidation. */
|
|
40
|
+
invalidateToolView?: (state: object) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Shared current renderer state; shell reads this same object without a second enabled flag. */
|
|
44
|
+
export interface ToolRendererRuntimeState {
|
|
45
|
+
readonly enabled: boolean;
|
|
46
|
+
readonly revision: number;
|
|
47
|
+
readonly implementation: ToolRendererImplementation | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ShellCompatibilityNotification {
|
|
51
|
+
notify: () => void;
|
|
52
|
+
notified: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ToolRuntime {
|
|
56
|
+
enabled: boolean;
|
|
57
|
+
enabledInitialized: boolean;
|
|
58
|
+
initialEnvironmentDisabled: boolean;
|
|
59
|
+
revision: number;
|
|
60
|
+
implementation: ToolRendererImplementation | undefined;
|
|
61
|
+
presentations: WeakRef<ToolPresentation>[];
|
|
62
|
+
shellCompatibility: ShellCompatibilityNotification | undefined;
|
|
63
|
+
}
|
|
64
|
+
const RUNTIME_KEY = Symbol.for("pi-tools-style:runtime");
|
|
65
|
+
function runtime(): ToolRuntime {
|
|
66
|
+
let value = Reflect.get(globalThis, RUNTIME_KEY) as ToolRuntime | undefined;
|
|
67
|
+
if (!value) {
|
|
68
|
+
const initialEnvironmentDisabled = process.env.PI_TOOLS_STYLE === "0";
|
|
69
|
+
value = { enabled: !initialEnvironmentDisabled, enabledInitialized: false, initialEnvironmentDisabled,
|
|
70
|
+
revision: 0, implementation: undefined, presentations: [], shellCompatibility: undefined };
|
|
71
|
+
Reflect.set(globalThis, RUNTIME_KEY, value);
|
|
72
|
+
}
|
|
73
|
+
else if (typeof value.enabledInitialized !== "boolean") {
|
|
74
|
+
// A hot-upgraded runtime already represents an established process choice.
|
|
75
|
+
value.enabledInitialized = true;
|
|
76
|
+
value.initialEnvironmentDisabled = false;
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Public execution events identify one live TUI state, not replay/export states. */
|
|
82
|
+
export class ToolRendererSession {
|
|
83
|
+
private readonly calls = new Map<string, WeakRef<object> | undefined>();
|
|
84
|
+
|
|
85
|
+
executionStarted(toolCallId: string): void {
|
|
86
|
+
this.calls.set(toolCallId, undefined);
|
|
87
|
+
for (const ref of runtime().presentations) {
|
|
88
|
+
const row = ref.deref();
|
|
89
|
+
if (row?.session !== this || row.toolCallId !== toolCallId) continue;
|
|
90
|
+
if (row.canClaimAnimation) this.claimState(toolCallId, row.state);
|
|
91
|
+
row.invalidateRuntime();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
executionEnded(toolCallId: string): void {
|
|
95
|
+
this.calls.delete(toolCallId);
|
|
96
|
+
for (const ref of runtime().presentations) {
|
|
97
|
+
const row = ref.deref();
|
|
98
|
+
if (row?.session === this && row.toolCallId === toolCallId) row.invalidateRuntime();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
claimState(toolCallId: string, state: object): void {
|
|
102
|
+
if (this.calls.has(toolCallId) && !this.calls.get(toolCallId)?.deref()) this.calls.set(toolCallId, new WeakRef(state));
|
|
103
|
+
}
|
|
104
|
+
isExecuting(toolCallId: string, state: object): boolean {
|
|
105
|
+
return this.calls.get(toolCallId)?.deref() === state;
|
|
106
|
+
}
|
|
107
|
+
reset(): void {
|
|
108
|
+
this.calls.clear();
|
|
109
|
+
for (const ref of runtime().presentations) {
|
|
110
|
+
const row = ref.deref();
|
|
111
|
+
if (row?.session === this) row.stopAnimation();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
shutdown(): void {
|
|
115
|
+
this.reset();
|
|
116
|
+
runtime().presentations = runtime().presentations.filter((ref) => {
|
|
117
|
+
const row = ref.deref();
|
|
118
|
+
return row !== undefined && row.session !== this;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isToolRendererEnabled(): boolean { return runtime().enabled; }
|
|
124
|
+
export function getToolRendererRuntimeState(): ToolRendererRuntimeState { return runtime(); }
|
|
125
|
+
/** Disk/environment defaults apply once; explicit process choices survive session starts and reload. */
|
|
126
|
+
export function initializeToolRendererEnabled(enabled: boolean): void {
|
|
127
|
+
const current = runtime();
|
|
128
|
+
if (current.enabledInitialized) return;
|
|
129
|
+
current.enabledInitialized = true;
|
|
130
|
+
const initialEnabled = !current.initialEnvironmentDisabled && enabled;
|
|
131
|
+
if (current.enabled === initialEnabled) return;
|
|
132
|
+
current.enabled = initialEnabled;
|
|
133
|
+
invalidateToolPresentations();
|
|
134
|
+
}
|
|
135
|
+
export function setToolRendererEnabled(enabled: boolean): void {
|
|
136
|
+
const current = runtime();
|
|
137
|
+
current.enabledInitialized = true;
|
|
138
|
+
if (current.enabled === enabled) return;
|
|
139
|
+
current.enabled = enabled;
|
|
140
|
+
invalidateToolPresentations();
|
|
141
|
+
}
|
|
142
|
+
export function setToolRendererImplementation(implementation: ToolRendererImplementation | undefined): void {
|
|
143
|
+
runtime().implementation = implementation;
|
|
144
|
+
invalidateToolPresentations();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Bind one interactive session notification; shutdown removes the captured UI context. */
|
|
148
|
+
export function setShellCompatibilityNotifier(notify: (() => void) | undefined): void {
|
|
149
|
+
runtime().shellCompatibility = notify ? { notify, notified: false } : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** An incompatible private shell shape must never disable public tools or interrupt native rendering. */
|
|
153
|
+
export function reportShellIncompatibility(): void {
|
|
154
|
+
const current = runtime();
|
|
155
|
+
const notification = current.shellCompatibility;
|
|
156
|
+
if (!current.enabled || !isShellEnabledByConfig() || !notification || notification.notified) return;
|
|
157
|
+
notification.notified = true;
|
|
158
|
+
try {
|
|
159
|
+
notification.notify();
|
|
160
|
+
} catch {
|
|
161
|
+
// A detached UI cannot turn the native fallback into a rendering failure.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** An exact-name change must not cold-start another row's retained body or clock. */
|
|
165
|
+
export function invalidateToolPresentations(toolName?: string): void {
|
|
166
|
+
const current = runtime();
|
|
167
|
+
if (toolName === undefined) current.revision++;
|
|
168
|
+
const live: WeakRef<ToolPresentation>[] = [];
|
|
169
|
+
for (const ref of current.presentations) {
|
|
170
|
+
const presentation = ref.deref();
|
|
171
|
+
if (!presentation) continue;
|
|
172
|
+
live.push(ref);
|
|
173
|
+
if (toolName === undefined || presentation.toolName === toolName) presentation.invalidateRuntime();
|
|
174
|
+
}
|
|
175
|
+
current.presentations = live;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const BUILTINS: Readonly<Record<string, true | undefined>> = { bash: true, powershell: true, read: true, edit: true, write: true, find: true, grep: true, ls: true };
|
|
179
|
+
const MCP_RESOURCES: Readonly<Record<string, true | undefined>> = { list_mcp_resources: true, list_mcp_resource_templates: true, read_mcp_resource: true };
|
|
180
|
+
function isBuiltin(name: string, info: ToolInfo | undefined): boolean {
|
|
181
|
+
return BUILTINS[name] === true && info?.sourceInfo.source === "builtin" && info.sourceInfo.path === `builtin:${name}`;
|
|
182
|
+
}
|
|
183
|
+
function isMcp(info: ToolInfo | undefined): boolean {
|
|
184
|
+
return info?.sourceInfo.source === "builtin" && info.sourceInfo.path === "builtin:mcp";
|
|
185
|
+
}
|
|
186
|
+
function isWeb(name: string, info: ToolInfo | undefined, details: unknown): boolean {
|
|
187
|
+
const recognizer = runtime().implementation?.isWebSearchTool;
|
|
188
|
+
return recognizer ? recognizer(name, info, details) : isWebSearchTool(name, info, details, getSettings().webSearchTools);
|
|
189
|
+
}
|
|
190
|
+
function capturedInfo(info: ToolInfo | undefined): ToolInfo | undefined {
|
|
191
|
+
return info ? { ...info, sourceInfo: { ...info.sourceInfo }, ...(info.namespace ? { namespace: { ...info.namespace } } : {}) } : undefined;
|
|
192
|
+
}
|
|
193
|
+
function classify(name: string, info: ToolInfo | undefined): ToolSnapshot["presentation"] {
|
|
194
|
+
if (isExecutionToolName(name)) return "execution";
|
|
195
|
+
if (isWeb(name, info, undefined)) return "web";
|
|
196
|
+
if (isMcp(info)) return "mcp";
|
|
197
|
+
if (isBuiltin(name, info)) return "builtin";
|
|
198
|
+
if (!info && (name.startsWith("mcp__") || MCP_RESOURCES[name] === true)) return "mcp-unresolved";
|
|
199
|
+
return "generic";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface ToolPresentationCatalogEntry {
|
|
203
|
+
readonly name: string;
|
|
204
|
+
readonly presentation: ToolSnapshot["presentation"];
|
|
205
|
+
readonly available: boolean;
|
|
206
|
+
readonly support: "confirmed" | "conditional";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Public availability plus actual owned historical rows, never name-only renderer ownership. */
|
|
210
|
+
export function getToolPresentationCatalog(tools: readonly ToolInfo[]): readonly ToolPresentationCatalogEntry[] {
|
|
211
|
+
const available = new Map<string, ToolInfo>();
|
|
212
|
+
const names = new Set(Object.keys(BUILTINS));
|
|
213
|
+
for (const tool of tools) {
|
|
214
|
+
if (tool.exposure === "hidden") continue;
|
|
215
|
+
available.set(tool.name, tool);
|
|
216
|
+
names.add(tool.name);
|
|
217
|
+
}
|
|
218
|
+
const observed = new Map<string, ToolSnapshot["presentation"]>();
|
|
219
|
+
const current = runtime();
|
|
220
|
+
const live: WeakRef<ToolPresentation>[] = [];
|
|
221
|
+
for (const ref of current.presentations) {
|
|
222
|
+
const row = ref.deref();
|
|
223
|
+
if (!row) continue;
|
|
224
|
+
live.push(ref);
|
|
225
|
+
names.add(row.toolName);
|
|
226
|
+
// Web promotion is sticky even if another historical row predates recognition.
|
|
227
|
+
if (observed.get(row.toolName) !== "web") observed.set(row.toolName, row.catalogPresentation);
|
|
228
|
+
}
|
|
229
|
+
current.presentations = live;
|
|
230
|
+
const settings = getSettings();
|
|
231
|
+
for (const name of Object.keys(settings.tools)) names.add(name);
|
|
232
|
+
for (const name of settings.webSearchTools) names.add(name);
|
|
233
|
+
const entries: ToolPresentationCatalogEntry[] = [];
|
|
234
|
+
for (const name of names) {
|
|
235
|
+
const info = available.get(name);
|
|
236
|
+
const publicKind = classify(name, info);
|
|
237
|
+
const historicalKind = observed.get(name);
|
|
238
|
+
const presentation = historicalKind === "web" ? "web"
|
|
239
|
+
: historicalKind !== undefined && (!info || publicKind === "generic" || publicKind === "mcp-unresolved")
|
|
240
|
+
? historicalKind : publicKind;
|
|
241
|
+
entries.push({
|
|
242
|
+
name, presentation, available: info !== undefined,
|
|
243
|
+
support: historicalKind !== undefined || (info !== undefined && publicKind !== "generic" && publicKind !== "mcp-unresolved")
|
|
244
|
+
? "confirmed" : "conditional",
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return entries;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function installToolRenderer(pi: ExtensionAPI): ToolRendererSession {
|
|
251
|
+
const session = new ToolRendererSession();
|
|
252
|
+
pi.registerToolRenderer(createToolRendererResolver(pi, session));
|
|
253
|
+
return session;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function createToolRendererResolver(pi: Pick<ExtensionAPI, "getAllTools">, session = new ToolRendererSession()): ToolRendererResolver {
|
|
257
|
+
return (toolName, next) => {
|
|
258
|
+
const downstream = next();
|
|
259
|
+
const info = capturedInfo(pi.getAllTools().find((tool) => tool.name === toolName));
|
|
260
|
+
const kind = classify(toolName, info);
|
|
261
|
+
if ((kind === "generic" || kind === "mcp-unresolved") && downstream?.renderShell === "self") return downstream;
|
|
262
|
+
const capturedDownstream: ToolRenderers | undefined = downstream ? {} : undefined;
|
|
263
|
+
if (downstream && capturedDownstream) {
|
|
264
|
+
const { renderShell, renderCall, renderResult } = downstream;
|
|
265
|
+
if (renderShell !== undefined) capturedDownstream.renderShell = renderShell;
|
|
266
|
+
if (renderCall !== undefined) capturedDownstream.renderCall = renderCall;
|
|
267
|
+
if (renderResult !== undefined) capturedDownstream.renderResult = renderResult;
|
|
268
|
+
}
|
|
269
|
+
// Pi supplies a separate state object per historical row, including HTML rendering.
|
|
270
|
+
const rows = new WeakMap<object, ToolPresentation>();
|
|
271
|
+
const getRow = (context: ToolContext): ToolPresentation => {
|
|
272
|
+
let row = rows.get(context.state);
|
|
273
|
+
if (!row) {
|
|
274
|
+
row = new ToolPresentation(toolName, capturedDownstream, info, kind, pi, session, context.state, context.toolCallId);
|
|
275
|
+
rows.set(context.state, row);
|
|
276
|
+
runtime().presentations.push(new WeakRef(row));
|
|
277
|
+
}
|
|
278
|
+
return row;
|
|
279
|
+
};
|
|
280
|
+
return {
|
|
281
|
+
renderShell: "self",
|
|
282
|
+
renderCall: (args, theme, context) => {
|
|
283
|
+
const row = getRow(context);
|
|
284
|
+
row.updateCall(args, theme, context);
|
|
285
|
+
return row.callFragment;
|
|
286
|
+
},
|
|
287
|
+
renderResult: (result, options, theme, context) => {
|
|
288
|
+
const row = getRow(context);
|
|
289
|
+
row.updateResult(result, options, theme, context);
|
|
290
|
+
return row.resultFragment;
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type Slot = "call" | "result";
|
|
297
|
+
type ResultOptions = Parameters<NonNullable<ToolRenderers["renderResult"]>>[1];
|
|
298
|
+
interface CallSnapshot { args: unknown; theme: Theme; context: ToolContext }
|
|
299
|
+
interface ResultSnapshot { result: ToolResult; options: ResultOptions; theme: Theme; context: ToolContext }
|
|
300
|
+
interface CachedLayout {
|
|
301
|
+
layout: ToolLayout;
|
|
302
|
+
width: number;
|
|
303
|
+
revision: number;
|
|
304
|
+
runtimeRevision: number;
|
|
305
|
+
presentationEnabled: boolean;
|
|
306
|
+
nativeShell?: Component;
|
|
307
|
+
}
|
|
308
|
+
interface RetainedView {
|
|
309
|
+
view: ToolView;
|
|
310
|
+
snapshot: ToolSnapshot;
|
|
311
|
+
themeColors: Theme["colors"];
|
|
312
|
+
colorMode: string | undefined;
|
|
313
|
+
appearance: Theme["appearance"];
|
|
314
|
+
iconMode: IconMode;
|
|
315
|
+
headerOnly: boolean;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Stored lines are for composition only; the original child owns focus and capture. */
|
|
319
|
+
class NativeLines implements Component {
|
|
320
|
+
constructor(readonly child: Component, private readonly rows: string[], private readonly region: MouseRegion, private readonly width: number) {}
|
|
321
|
+
render(): string[] { return this.rows; }
|
|
322
|
+
invalidate(): void { this.child.invalidate(); }
|
|
323
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
324
|
+
return this.region.handleMouse({ ...event, width: this.width, height: this.rows.length });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
class ToolFragment implements Component {
|
|
328
|
+
constructor(private readonly presentation: ToolPresentation, private readonly slot: Slot) {}
|
|
329
|
+
render(width: number): string[] {
|
|
330
|
+
const layout = this.presentation.ensureLayout(width).layout;
|
|
331
|
+
return this.slot === "call" ? layout.callRows : layout.resultRows;
|
|
332
|
+
}
|
|
333
|
+
invalidate(): void { this.presentation.invalidate(); }
|
|
334
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
335
|
+
return this.presentation.handleMouse(this.slot, event);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
class ToolPresentation {
|
|
340
|
+
readonly callFragment: Component = new ToolFragment(this, "call");
|
|
341
|
+
readonly resultFragment: Component = new ToolFragment(this, "result");
|
|
342
|
+
private readonly nativeState = {};
|
|
343
|
+
private readonly regions = new WeakMap<Component, MouseRegion>();
|
|
344
|
+
private call: CallSnapshot | undefined;
|
|
345
|
+
private result: ResultSnapshot | undefined;
|
|
346
|
+
private context!: ToolContext;
|
|
347
|
+
private theme!: Theme;
|
|
348
|
+
private nativeCall: Component | undefined;
|
|
349
|
+
private nativeResult: Component | undefined;
|
|
350
|
+
private callChild: Component | undefined;
|
|
351
|
+
private resultChild: Component | undefined;
|
|
352
|
+
private callDirty = true;
|
|
353
|
+
private resultDirty = true;
|
|
354
|
+
private nativeActivated = false;
|
|
355
|
+
private revision = 0;
|
|
356
|
+
private cached: CachedLayout | undefined;
|
|
357
|
+
private retained: RetainedView | undefined;
|
|
358
|
+
private headerDirty = false;
|
|
359
|
+
/** Only the tick's own synchronous SDK invalidate/updateDisplay transaction. */
|
|
360
|
+
private animationSlot: Slot | "done" | undefined;
|
|
361
|
+
private fullText: Text | undefined;
|
|
362
|
+
private readonly nativeBox = new Box();
|
|
363
|
+
private readonly nativeContainer = new Container();
|
|
364
|
+
private localExpanded: boolean | undefined;
|
|
365
|
+
private lastExpanded: boolean | undefined;
|
|
366
|
+
private observedBeforeExecution = false;
|
|
367
|
+
private readonly spinnerTarget: ToolSpinnerTarget;
|
|
368
|
+
|
|
369
|
+
constructor(
|
|
370
|
+
readonly toolName: string,
|
|
371
|
+
private readonly downstream: ToolRenderers | undefined,
|
|
372
|
+
private toolInfo: ToolInfo | undefined,
|
|
373
|
+
private presentation: ToolSnapshot["presentation"],
|
|
374
|
+
private readonly pi: Pick<ExtensionAPI, "getAllTools">,
|
|
375
|
+
readonly session: ToolRendererSession,
|
|
376
|
+
readonly state: object,
|
|
377
|
+
readonly toolCallId: string,
|
|
378
|
+
) {
|
|
379
|
+
this.spinnerTarget = { state, invalidate: () => this.invalidateAnimation() };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Read only during menu discovery; this class remains private to the weak runtime registry. */
|
|
383
|
+
get catalogPresentation(): ToolSnapshot["presentation"] { return this.presentation; }
|
|
384
|
+
|
|
385
|
+
/** An already-started snapshot alone cannot claim animation; observe a pending lifecycle first. */
|
|
386
|
+
get canClaimAnimation(): boolean {
|
|
387
|
+
return this.observedBeforeExecution && (!this.result || this.context.isPartial);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
updateCall(args: unknown, theme: Theme, context: ToolContext): void {
|
|
391
|
+
const animation = this.animationSlot === "call" && this.call !== undefined
|
|
392
|
+
&& args === this.call.args && this.sameAnimationContext(theme, context, this.call.context);
|
|
393
|
+
if (!animation) this.animationSlot = undefined;
|
|
394
|
+
this.call = { args, theme, context: { ...context } };
|
|
395
|
+
this.context = this.call.context;
|
|
396
|
+
this.theme = theme;
|
|
397
|
+
if (animation) {
|
|
398
|
+
this.animationSlot = this.result ? "result" : "done";
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
this.observedBeforeExecution ||= !context.executionStarted || !context.argsComplete;
|
|
402
|
+
this.callDirty = true;
|
|
403
|
+
this.changed();
|
|
404
|
+
this.refreshProvenance();
|
|
405
|
+
this.observeExpansion();
|
|
406
|
+
if (this.canClaimAnimation) this.session.claimState(this.toolCallId, this.state);
|
|
407
|
+
this.refreshAnimation();
|
|
408
|
+
// A previously mounted native renderer must see completion even while hidden.
|
|
409
|
+
if (this.nativeActivated) this.prepareNativeCall();
|
|
410
|
+
}
|
|
411
|
+
updateResult(result: ToolResult, options: ResultOptions, theme: Theme, context: ToolContext): void {
|
|
412
|
+
const previous = this.result;
|
|
413
|
+
const animation = this.animationSlot === "result" && previous !== undefined
|
|
414
|
+
// Pi makes a new result wrapper on every updateDisplay; only its source references are stable.
|
|
415
|
+
&& result.content === previous.result.content && result.details === previous.result.details
|
|
416
|
+
&& options.expanded === previous.options.expanded && options.isPartial === previous.options.isPartial
|
|
417
|
+
&& this.sameAnimationContext(theme, context, previous.context);
|
|
418
|
+
if (!animation) this.animationSlot = undefined;
|
|
419
|
+
this.result = { result, options: { ...options }, theme, context: { ...context } };
|
|
420
|
+
this.context = this.result.context;
|
|
421
|
+
this.theme = theme;
|
|
422
|
+
if (animation) {
|
|
423
|
+
this.animationSlot = "done";
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
this.observedBeforeExecution ||= !context.executionStarted || !context.argsComplete;
|
|
427
|
+
this.resultDirty = true;
|
|
428
|
+
this.changed();
|
|
429
|
+
this.refreshProvenance();
|
|
430
|
+
this.observeExpansion();
|
|
431
|
+
if (this.canClaimAnimation) this.session.claimState(this.toolCallId, this.state);
|
|
432
|
+
this.refreshAnimation();
|
|
433
|
+
if (this.nativeActivated) this.prepareNativeResult();
|
|
434
|
+
}
|
|
435
|
+
private sameEnvironment(theme: Theme): boolean {
|
|
436
|
+
const retained = this.retained;
|
|
437
|
+
return retained !== undefined && retained.themeColors === theme.colors
|
|
438
|
+
&& retained.colorMode === theme.getColorMode?.() && retained.appearance === theme.appearance
|
|
439
|
+
&& retained.iconMode === getIconMode();
|
|
440
|
+
}
|
|
441
|
+
private sameAnimationContext(theme: Theme, context: ToolContext, previous: ToolContext): boolean {
|
|
442
|
+
return this.sameEnvironment(theme) && context.args === previous.args && context.state === previous.state
|
|
443
|
+
&& context.toolCallId === previous.toolCallId && context.cwd === previous.cwd
|
|
444
|
+
&& context.executionStarted === previous.executionStarted && context.argsComplete === previous.argsComplete
|
|
445
|
+
&& context.isPartial === previous.isPartial && context.expanded === previous.expanded
|
|
446
|
+
&& context.showImages === previous.showImages && context.isError === previous.isError
|
|
447
|
+
&& context.durationMs === previous.durationMs && context.outputPad === previous.outputPad;
|
|
448
|
+
}
|
|
449
|
+
private changed(): void {
|
|
450
|
+
this.revision++;
|
|
451
|
+
this.cached = undefined;
|
|
452
|
+
this.retained = undefined;
|
|
453
|
+
this.headerDirty = false;
|
|
454
|
+
this.animationSlot = undefined;
|
|
455
|
+
}
|
|
456
|
+
private invalidateView(): void {
|
|
457
|
+
this.changed();
|
|
458
|
+
runtime().implementation?.invalidateToolView?.(this.state);
|
|
459
|
+
}
|
|
460
|
+
private refreshProvenance(): void {
|
|
461
|
+
if (!this.toolInfo && this.presentation === "mcp-unresolved") {
|
|
462
|
+
this.toolInfo = capturedInfo(this.pi.getAllTools().find((tool) => tool.name === this.toolName));
|
|
463
|
+
if (this.toolInfo) this.presentation = classify(this.toolName, this.toolInfo);
|
|
464
|
+
}
|
|
465
|
+
if (isWeb(this.toolName, this.toolInfo, this.result?.result.details)) this.presentation = "web";
|
|
466
|
+
}
|
|
467
|
+
private autoExpandedActive(): boolean {
|
|
468
|
+
return this.presentation === "builtin" && (this.toolName === "edit" || this.toolName === "write") && (!this.result || this.context.isPartial);
|
|
469
|
+
}
|
|
470
|
+
private observeExpansion(): void {
|
|
471
|
+
if (!this.autoExpandedActive()) this.localExpanded = undefined;
|
|
472
|
+
else if (this.lastExpanded !== undefined && this.lastExpanded !== this.context.expanded) this.localExpanded = this.context.expanded;
|
|
473
|
+
this.lastExpanded = this.context.expanded;
|
|
474
|
+
}
|
|
475
|
+
private expanded(): boolean {
|
|
476
|
+
return this.autoExpandedActive() ? (this.localExpanded ?? true) : this.context.expanded;
|
|
477
|
+
}
|
|
478
|
+
private refreshAnimation(): void {
|
|
479
|
+
const current = runtime();
|
|
480
|
+
const running = (!this.result || this.context.isPartial) && (this.context.executionStarted || this.result !== undefined);
|
|
481
|
+
setToolSpinnerActive(this.spinnerTarget, current.enabled && isToolEnabledByConfig(this.toolName)
|
|
482
|
+
&& current.implementation !== undefined && running && this.session.isExecuting(this.toolCallId, this.state));
|
|
483
|
+
}
|
|
484
|
+
stopAnimation(): void {
|
|
485
|
+
setToolSpinnerActive(this.spinnerTarget, false);
|
|
486
|
+
}
|
|
487
|
+
private invalidateAnimation(): void {
|
|
488
|
+
const current = runtime();
|
|
489
|
+
const cached = this.cached;
|
|
490
|
+
if (!current.enabled || !isToolEnabledByConfig(this.toolName) || !current.implementation || !this.retained?.headerOnly
|
|
491
|
+
|| !cached?.layout.refreshHeader || cached.nativeShell
|
|
492
|
+
|| cached.revision !== this.revision || cached.runtimeRevision !== current.revision
|
|
493
|
+
|| !this.sameEnvironment(this.theme)) {
|
|
494
|
+
this.invalidateRuntime();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
this.headerDirty = true;
|
|
498
|
+
this.animationSlot = "call";
|
|
499
|
+
try {
|
|
500
|
+
// Installed Pi synchronously invalidates both fragments and re-enters each callback once.
|
|
501
|
+
// Later requestRender painting is outside this scope; real inputs always win over the clock.
|
|
502
|
+
this.context.invalidate();
|
|
503
|
+
} finally {
|
|
504
|
+
this.animationSlot = undefined;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
invalidateRuntime(): void {
|
|
508
|
+
this.invalidateView();
|
|
509
|
+
this.refreshAnimation();
|
|
510
|
+
this.context?.invalidate();
|
|
511
|
+
}
|
|
512
|
+
invalidate(): void {
|
|
513
|
+
if (this.animationSlot !== undefined) return;
|
|
514
|
+
this.callDirty = true;
|
|
515
|
+
this.resultDirty = true;
|
|
516
|
+
this.invalidateView();
|
|
517
|
+
this.callChild?.invalidate();
|
|
518
|
+
if (this.resultChild !== this.callChild) this.resultChild?.invalidate();
|
|
519
|
+
}
|
|
520
|
+
private nativeContext(context: ToolContext, lastComponent: Component | undefined): ToolContext {
|
|
521
|
+
return {
|
|
522
|
+
...context, state: this.nativeState, lastComponent,
|
|
523
|
+
invalidate: () => {
|
|
524
|
+
this.callDirty = true;
|
|
525
|
+
this.resultDirty = true;
|
|
526
|
+
this.invalidateView();
|
|
527
|
+
context.invalidate();
|
|
528
|
+
},
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
private prepareNativeCall(): void {
|
|
532
|
+
if (!this.callDirty) return;
|
|
533
|
+
this.callDirty = false;
|
|
534
|
+
const snapshot = this.call ?? { args: this.context.args, theme: this.theme, context: this.context };
|
|
535
|
+
const renderer = this.downstream?.renderCall;
|
|
536
|
+
if (renderer) {
|
|
537
|
+
try {
|
|
538
|
+
this.nativeCall = renderer(snapshot.args, snapshot.theme, this.nativeContext(snapshot.context, this.nativeCall));
|
|
539
|
+
} catch { this.nativeCall = undefined; }
|
|
540
|
+
} else this.nativeCall = undefined;
|
|
541
|
+
this.callChild = this.nativeCall;
|
|
542
|
+
}
|
|
543
|
+
private ensureNativeCall(): Component | undefined {
|
|
544
|
+
this.prepareNativeCall();
|
|
545
|
+
if (!this.callChild) {
|
|
546
|
+
const snapshot = this.call ?? { args: this.context.args, theme: this.theme, context: this.context };
|
|
547
|
+
this.callChild = new Text(formatNativeCall(this.toolName, snapshot.args, snapshot.theme, snapshot.context.expanded), 0, 0);
|
|
548
|
+
}
|
|
549
|
+
return this.callChild;
|
|
550
|
+
}
|
|
551
|
+
private prepareNativeResult(): void {
|
|
552
|
+
if (!this.result || !this.resultDirty) return;
|
|
553
|
+
this.resultDirty = false;
|
|
554
|
+
const snapshot = this.result;
|
|
555
|
+
const renderer = this.downstream?.renderResult;
|
|
556
|
+
if (renderer) {
|
|
557
|
+
try {
|
|
558
|
+
this.nativeResult = renderer(snapshot.result, snapshot.options, snapshot.theme, this.nativeContext(snapshot.context, this.nativeResult));
|
|
559
|
+
} catch { this.nativeResult = undefined; }
|
|
560
|
+
} else this.nativeResult = undefined;
|
|
561
|
+
this.resultChild = this.nativeResult;
|
|
562
|
+
}
|
|
563
|
+
private ensureNativeResult(): Component | undefined {
|
|
564
|
+
if (!this.result) return this.resultChild;
|
|
565
|
+
this.prepareNativeResult();
|
|
566
|
+
this.resultChild ??= createNativeResultFallback(this.result);
|
|
567
|
+
return this.resultChild;
|
|
568
|
+
}
|
|
569
|
+
private nativeChildren(): { call: Component | undefined; result: Component | undefined } {
|
|
570
|
+
this.nativeActivated = true;
|
|
571
|
+
return { call: this.ensureNativeCall(), result: this.ensureNativeResult() };
|
|
572
|
+
}
|
|
573
|
+
private snapshot(): ToolSnapshot {
|
|
574
|
+
return {
|
|
575
|
+
args: this.call ? this.call.args : this.context.args,
|
|
576
|
+
context: this.expanded() === this.context.expanded ? this.context : { ...this.context, expanded: this.expanded() },
|
|
577
|
+
presentation: this.presentation,
|
|
578
|
+
...(this.result ? { result: this.result.result } : {}),
|
|
579
|
+
...(this.toolInfo ? { toolInfo: this.toolInfo } : {}),
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
ensureLayout(width: number): CachedLayout {
|
|
583
|
+
const current = runtime();
|
|
584
|
+
const presentationEnabled = current.enabled && isToolEnabledByConfig(this.toolName);
|
|
585
|
+
if (this.retained && !this.sameEnvironment(this.theme)) {
|
|
586
|
+
this.callDirty = true;
|
|
587
|
+
this.resultDirty = true;
|
|
588
|
+
this.invalidateView();
|
|
589
|
+
}
|
|
590
|
+
const cached = this.cached;
|
|
591
|
+
if (cached?.width === width && cached.revision === this.revision && cached.runtimeRevision === current.revision
|
|
592
|
+
&& cached.presentationEnabled === presentationEnabled) {
|
|
593
|
+
if (!this.headerDirty) return cached;
|
|
594
|
+
try {
|
|
595
|
+
cached.layout.refreshHeader?.();
|
|
596
|
+
this.headerDirty = false;
|
|
597
|
+
return cached;
|
|
598
|
+
} catch {
|
|
599
|
+
// A failed capability takes the same complete-layout/native fallback path as initial preparation.
|
|
600
|
+
this.changed();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
let rendered: { layout: ToolLayout; nativeShell?: Component } | undefined;
|
|
604
|
+
if (presentationEnabled && current.implementation) {
|
|
605
|
+
try {
|
|
606
|
+
if (!this.retained) {
|
|
607
|
+
const snapshot = this.snapshot();
|
|
608
|
+
let view = current.implementation.createToolView(this.toolName, snapshot, this.theme);
|
|
609
|
+
if (this.presentation === "generic") {
|
|
610
|
+
this.nativeActivated = true;
|
|
611
|
+
this.prepareNativeCall();
|
|
612
|
+
this.prepareNativeResult();
|
|
613
|
+
view = { ...view, sections: [
|
|
614
|
+
...(this.nativeCall ? [{
|
|
615
|
+
component: this.nativeCall, slot: "call" as const, label: "Input",
|
|
616
|
+
previewPolicy: { edge: "head" as const, count: 10, unit: "visual-lines" as const },
|
|
617
|
+
}] : view.sections.filter((section) => section.slot === "call")),
|
|
618
|
+
...(this.nativeResult ? [{
|
|
619
|
+
component: this.nativeResult, slot: "result" as const, label: "Output",
|
|
620
|
+
previewPolicy: { edge: "head" as const, count: 5, unit: "visual-lines" as const },
|
|
621
|
+
}] : view.sections.filter((section) => section.slot === "result")),
|
|
622
|
+
] };
|
|
623
|
+
}
|
|
624
|
+
this.retained = {
|
|
625
|
+
view, snapshot, themeColors: this.theme.colors, colorMode: this.theme.getColorMode?.(),
|
|
626
|
+
appearance: this.theme.appearance, iconMode: getIconMode(),
|
|
627
|
+
headerOnly: !view.sections.some((section) => isInteractiveToolComponent(section.component)
|
|
628
|
+
|| (this.presentation === "generic" && !hasStableToolBody(section.component))),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const { view, snapshot } = this.retained;
|
|
632
|
+
if (view.layout !== "framed" || width >= Math.max(5, 3 + 2 * snapshot.context.outputPad)) {
|
|
633
|
+
rendered = { layout: current.implementation.layoutToolView(view, snapshot, this.theme, width) };
|
|
634
|
+
}
|
|
635
|
+
} catch { /* Presentation failure restores Pi, not a synthetic execution result. */ }
|
|
636
|
+
}
|
|
637
|
+
if (!rendered) rendered = this.nativeLayout(width, presentationEnabled);
|
|
638
|
+
this.cached = { ...rendered, width, revision: this.revision, runtimeRevision: current.revision, presentationEnabled };
|
|
639
|
+
this.headerDirty = false;
|
|
640
|
+
return this.cached;
|
|
641
|
+
}
|
|
642
|
+
private region(component: Component): MouseRegion {
|
|
643
|
+
let region = this.regions.get(component);
|
|
644
|
+
if (!region) {
|
|
645
|
+
region = new MouseRegion(component, () => undefined);
|
|
646
|
+
this.regions.set(component, region);
|
|
647
|
+
}
|
|
648
|
+
return region;
|
|
649
|
+
}
|
|
650
|
+
private nativeLayout(width: number, allowImagePassthrough: boolean): { layout: ToolLayout; nativeShell: Component } {
|
|
651
|
+
const bgFn = (text: string): string => this.theme.bg(this.context.isPartial ? "toolPendingBg" : this.context.isError ? "toolErrorBg" : "toolSuccessBg", text);
|
|
652
|
+
if (!this.downstream) {
|
|
653
|
+
let text = this.theme.fg("toolTitle", this.theme.bold(this.toolName));
|
|
654
|
+
const args = JSON.stringify(this.call ? this.call.args : this.context.args, null, 2);
|
|
655
|
+
if (args) text += `\n\n${args}`;
|
|
656
|
+
const output = nativeText(this.result?.result, this.context.showImages);
|
|
657
|
+
if (output) text += `\n${output}`;
|
|
658
|
+
this.fullText ??= new Text("", this.context.outputPad, 1, bgFn);
|
|
659
|
+
this.fullText.setCustomBgFn(bgFn);
|
|
660
|
+
this.fullText.setPaddingX(this.context.outputPad);
|
|
661
|
+
this.fullText.setText(text);
|
|
662
|
+
const rows = this.fullText.render(width);
|
|
663
|
+
const bounds = { component: this.fullText, x: 0, y: 0, width, height: rows.length };
|
|
664
|
+
return {
|
|
665
|
+
layout: { callRows: this.result ? [] : rows, resultRows: this.result ? rows : [], callChildBounds: this.result ? [] : [bounds], resultChildBounds: this.result ? [bounds] : [], callOffset: 0, resultOffset: 0 },
|
|
666
|
+
nativeShell: this.region(this.fullText),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const children = this.nativeChildren();
|
|
670
|
+
const self = this.downstream.renderShell === "self";
|
|
671
|
+
const childWidth = self ? width : Math.max(1, width - 2 * this.context.outputPad);
|
|
672
|
+
const callRows = children.call?.render(childWidth) ?? [];
|
|
673
|
+
const resultRows = !children.result ? []
|
|
674
|
+
: children.result === children.call ? callRows
|
|
675
|
+
: children.result.render(childWidth);
|
|
676
|
+
const passthrough = allowImagePassthrough && (containsTerminalImage(callRows) || containsTerminalImage(resultRows));
|
|
677
|
+
const shell = self || passthrough ? this.nativeContainer : this.nativeBox;
|
|
678
|
+
shell.clear();
|
|
679
|
+
if (shell instanceof Box) { shell.setPaddingX(this.context.outputPad); shell.setBgFn(bgFn); }
|
|
680
|
+
if (children.call) shell.addChild(new NativeLines(children.call, callRows, this.region(children.call), childWidth));
|
|
681
|
+
if (children.result) shell.addChild(new NativeLines(children.result, resultRows, this.region(children.result), childWidth));
|
|
682
|
+
const rows = shell.render(width);
|
|
683
|
+
const padX = shell instanceof Box ? this.context.outputPad : 0;
|
|
684
|
+
const padY = shell instanceof Box && rows.length > 0 ? 1 : 0;
|
|
685
|
+
const split = this.result ? Math.min(rows.length, padY + callRows.length) : rows.length;
|
|
686
|
+
const callBounds = children.call ? [{ component: children.call, x: padX, y: padY, width: childWidth, height: callRows.length }] : [];
|
|
687
|
+
const resultBounds = children.result ? [{ component: children.result, x: padX, y: padY + callRows.length - split, width: childWidth, height: resultRows.length }] : [];
|
|
688
|
+
return { layout: { callRows: rows.slice(0, split), resultRows: rows.slice(split), callChildBounds: callBounds, resultChildBounds: resultBounds, callOffset: 0, resultOffset: split }, nativeShell: shell };
|
|
689
|
+
}
|
|
690
|
+
handleMouse(slot: Slot, event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
691
|
+
const cached = this.ensureLayout(event.width);
|
|
692
|
+
const layout = cached.layout;
|
|
693
|
+
if (event.y < 0 || event.y >= (slot === "call" ? layout.callRows.length : layout.resultRows.length)) return undefined;
|
|
694
|
+
if (cached.nativeShell) {
|
|
695
|
+
return cached.nativeShell.handleMouse?.({ ...event, y: event.y + (slot === "call" ? layout.callOffset : layout.resultOffset), height: layout.callRows.length + layout.resultRows.length });
|
|
696
|
+
}
|
|
697
|
+
const bounds = slot === "call" ? layout.callChildBounds : layout.resultChildBounds;
|
|
698
|
+
for (const child of bounds) {
|
|
699
|
+
if (event.x >= child.x && event.x < child.x + child.width && event.y >= child.y && event.y < child.y + child.height) {
|
|
700
|
+
const dispatched = this.region(child.component).handleMouse({ ...event, x: event.x - child.x, y: event.y - child.y, width: child.width, height: child.height });
|
|
701
|
+
if (dispatched) return dispatched;
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (event.type === "click" && event.button === "left" && event.x >= 0 && event.x < event.width && this.autoExpandedActive()) {
|
|
706
|
+
this.localExpanded = !this.expanded();
|
|
707
|
+
this.changed();
|
|
708
|
+
this.context.invalidate();
|
|
709
|
+
return { handled: true };
|
|
710
|
+
}
|
|
711
|
+
return undefined;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function formatNativeCall(title: string, args: unknown, theme: Theme, expanded: boolean): string {
|
|
716
|
+
const header = theme.fg("toolTitle", theme.bold(title));
|
|
717
|
+
if (args == null) return header;
|
|
718
|
+
const entries = typeof args === "object" && !Array.isArray(args) ? Object.entries(args) : [["args", args]];
|
|
719
|
+
if (entries.length === 0) return header;
|
|
720
|
+
if (expanded) {
|
|
721
|
+
const lines = entries.map(([key, value]) => {
|
|
722
|
+
const text = typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
|
|
723
|
+
return ` ${key}: ${text.replace(/\t/g, " ").replace(/\r/g, "").split("\n").join("\n ")}`;
|
|
724
|
+
});
|
|
725
|
+
return `${header}\n${theme.fg("muted", lines.join("\n"))}`;
|
|
726
|
+
}
|
|
727
|
+
const pairs = entries.map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`).join(" ");
|
|
728
|
+
return `${header} ${theme.fg("muted", pairs.length > 100 ? `${pairs.slice(0, 97)}...` : pairs)}`;
|
|
729
|
+
}
|
|
730
|
+
function createNativeResultFallback(snapshot: ResultSnapshot): Component | undefined {
|
|
731
|
+
const output = nativeText(snapshot.result, snapshot.context.showImages);
|
|
732
|
+
if (!output) return undefined;
|
|
733
|
+
const lines = output.split("\n");
|
|
734
|
+
const shown = snapshot.options.expanded ? lines : lines.slice(0, 10);
|
|
735
|
+
const remaining = lines.length - shown.length;
|
|
736
|
+
let text = shown.map((line) => snapshot.theme.fg("toolOutput", line)).join("\n");
|
|
737
|
+
if (remaining > 0) text += `${snapshot.theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${snapshot.theme.fg("muted", ")")}`;
|
|
738
|
+
return new Text(text, 0, 0);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Pi's OFF path strips ANSI and binary controls, rather than using the semantic
|
|
742
|
+
// display normalizer. Keep it distinct so restoration is byte-for-byte native.
|
|
743
|
+
const NATIVE_ANSI = /(?:\u001b\][\s\S]*?(?:\u0007|\u001b\u005c|\u009c))|[\u001b\u009b][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]/g;
|
|
744
|
+
function nativeText(result: ToolResult | undefined, showImages: boolean): string {
|
|
745
|
+
if (!result) return "";
|
|
746
|
+
let text = result.content.filter((block) => block.type === "text").map((block) => block.text.replace(NATIVE_ANSI, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\ufff9-\ufffb]/g, "").replace(/\r/g, "")).join("\n");
|
|
747
|
+
if (!getCapabilities().images || !showImages) {
|
|
748
|
+
const images = result.content.filter((block) => block.type === "image").map((block) => imageFallback(block.mimeType ?? "image/unknown", block.data && block.mimeType ? (getImageDimensions(block.data, block.mimeType) ?? undefined) : undefined)).join("\n");
|
|
749
|
+
if (images) text = text ? `${text}\n${images}` : images;
|
|
750
|
+
}
|
|
751
|
+
return text;
|
|
752
|
+
}
|