pi-ui-extension 1.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/README.md +43 -0
- package/extensions/ctx/README.md +35 -0
- package/extensions/ctx/activity.ts +226 -0
- package/extensions/ctx/data.test.ts +65 -0
- package/extensions/ctx/data.ts +230 -0
- package/extensions/ctx/panel.test.ts +103 -0
- package/extensions/ctx/panel.ts +185 -0
- package/extensions/ctx/tools.test.ts +64 -0
- package/extensions/fabric-detection.test.ts +15 -0
- package/extensions/fabric-detection.ts +7 -0
- package/extensions/footer/index.ts +62 -0
- package/extensions/index.ts +577 -0
- package/extensions/mcp/integration.test.ts +64 -0
- package/extensions/mcp/resources.test.ts +27 -0
- package/extensions/mcp/resources.ts +31 -0
- package/extensions/resource-dialog.test.ts +45 -0
- package/extensions/session/mutations.test.ts +30 -0
- package/extensions/session/mutations.ts +17 -0
- package/extensions/session/panel.test.ts +238 -0
- package/extensions/session/panel.ts +461 -0
- package/extensions/sidebar/default-panels.test.ts +97 -0
- package/extensions/sidebar/default-panels.ts +155 -0
- package/extensions/sidebar/index.ts +212 -0
- package/extensions/sidebar/layout.test.ts +68 -0
- package/extensions/sidebar/layout.ts +75 -0
- package/extensions/sidebar/panel.ts +49 -0
- package/extensions/sidebar/registry.ts +35 -0
- package/extensions/sidebar/resize.test.ts +71 -0
- package/extensions/sidebar/resize.ts +154 -0
- package/extensions/tool-view/controller.test.ts +57 -0
- package/extensions/tool-view/controller.ts +519 -0
- package/extensions/tool-view/panel.test.ts +73 -0
- package/extensions/tool-view/panel.ts +221 -0
- package/extensions/tool-view/trust.test.ts +41 -0
- package/extensions/tool-view/types.ts +6 -0
- package/extensions/tool-view/usage.test.ts +231 -0
- package/extensions/tool-view/usage.ts +303 -0
- package/extensions/tool-view/wheel.test.ts +32 -0
- package/package.json +53 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBashToolDefinition,
|
|
3
|
+
createEditToolDefinition,
|
|
4
|
+
createFindToolDefinition,
|
|
5
|
+
createGrepToolDefinition,
|
|
6
|
+
createLsToolDefinition,
|
|
7
|
+
createReadToolDefinition,
|
|
8
|
+
createWriteToolDefinition,
|
|
9
|
+
getAgentDir,
|
|
10
|
+
SettingsManager,
|
|
11
|
+
type AgentToolResult,
|
|
12
|
+
type ExtensionAPI,
|
|
13
|
+
type ExtensionContext,
|
|
14
|
+
type Theme,
|
|
15
|
+
type ToolDefinition,
|
|
16
|
+
type ToolRenderResultOptions,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { Box, Container, Text } from "@earendil-works/pi-tui";
|
|
19
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
20
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
21
|
+
import { TOOL_VIEW_MODES, isToolViewMode, type ToolViewMode } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MODE_FILE = resolve(getAgentDir(), "tool-view-mode.json");
|
|
24
|
+
const MODE_STATUS_ID = "pi-ui-extension:tool-view";
|
|
25
|
+
const MODE_COMMAND = "tool-view";
|
|
26
|
+
const MODE_SHORTCUT = "ctrl+shift+o";
|
|
27
|
+
const MODE_ORDER = TOOL_VIEW_MODES;
|
|
28
|
+
|
|
29
|
+
type AnyToolDefinition = ToolDefinition<any, any, any>;
|
|
30
|
+
type AnyToolResult = AgentToolResult<any>;
|
|
31
|
+
type AnyRenderContext = Parameters<NonNullable<AnyToolDefinition["renderCall"]>>[2];
|
|
32
|
+
|
|
33
|
+
type RendererState = {
|
|
34
|
+
nativeState?: any;
|
|
35
|
+
nativeCallComponent?: ReturnType<NonNullable<AnyToolDefinition["renderCall"]>>;
|
|
36
|
+
nativeResultComponent?: ReturnType<NonNullable<AnyToolDefinition["renderResult"]>>;
|
|
37
|
+
nativeBox?: Box;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
interface StoredMode {
|
|
41
|
+
mode: ToolViewMode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CompactToolSpec {
|
|
45
|
+
call(args: any): { title: string; detail?: string };
|
|
46
|
+
result(result: AnyToolResult, context: AnyRenderContext): string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function modeLabel(mode: ToolViewMode): string {
|
|
50
|
+
switch (mode) {
|
|
51
|
+
case "normal": return "完整";
|
|
52
|
+
case "compact": return "紧凑";
|
|
53
|
+
case "hidden": return "隐藏";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function errorMessage(error: unknown): string {
|
|
58
|
+
return error instanceof Error ? error.message : String(error);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function compactText(value: unknown, maxLength = 96): string {
|
|
62
|
+
const text = typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
63
|
+
if (text.length <= maxLength) return text;
|
|
64
|
+
return `${text.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function textOutput(result: AnyToolResult): string {
|
|
68
|
+
return result.content
|
|
69
|
+
.filter((item): item is Extract<(typeof result.content)[number], { type: "text" }> => item.type === "text")
|
|
70
|
+
.map((item) => item.text)
|
|
71
|
+
.join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function nonEmptyLineCount(text: string): number {
|
|
75
|
+
if (!text.trim()) return 0;
|
|
76
|
+
return text.split("\n").filter((line) => line.trim().length > 0).length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function itemCount(text: string): number {
|
|
80
|
+
return nonEmptyLineCount(text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatCount(count: number, singular: string, plural = `${singular}s`): string {
|
|
84
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function diffStats(diff: string | undefined): string {
|
|
88
|
+
if (!diff) return "已应用";
|
|
89
|
+
let additions = 0;
|
|
90
|
+
let removals = 0;
|
|
91
|
+
for (const line of diff.split("\n")) {
|
|
92
|
+
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
93
|
+
if (line.startsWith("-") && !line.startsWith("---")) removals++;
|
|
94
|
+
}
|
|
95
|
+
return `+${additions}/-${removals}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPathInside(filePath: string, root: string): boolean {
|
|
99
|
+
const pathFromRoot = relative(root, filePath);
|
|
100
|
+
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveToolPath(cwd: string, value: unknown): string | undefined {
|
|
104
|
+
if (typeof value !== "string" || value.trim().length === 0) return undefined;
|
|
105
|
+
return resolve(cwd, value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isLikelySkillPath(filePath: string): boolean {
|
|
109
|
+
const normalized = filePath.replaceAll("\\", "/");
|
|
110
|
+
return (
|
|
111
|
+
normalized.includes("/.agents/skills/") ||
|
|
112
|
+
normalized.includes("/.pi/agent/skills/") ||
|
|
113
|
+
normalized.includes("/.pi/skills/")
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const COMPACT_SPECS: Record<string, CompactToolSpec> = {
|
|
118
|
+
read: {
|
|
119
|
+
call: (args) => ({ title: "read", detail: compactText(args.path) || "…" }),
|
|
120
|
+
result: (result) => {
|
|
121
|
+
const output = textOutput(result);
|
|
122
|
+
const imageCount = result.content.filter((item) => item.type === "image").length;
|
|
123
|
+
if (imageCount > 0 && !output.trim()) return formatCount(imageCount, "image");
|
|
124
|
+
return formatCount(output.split("\n").length, "line");
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
bash: {
|
|
128
|
+
call: (args) => ({ title: "$", detail: compactText(args.command) || "…" }),
|
|
129
|
+
result: (result) => {
|
|
130
|
+
const lines = nonEmptyLineCount(textOutput(result));
|
|
131
|
+
return lines > 0 ? `完成 · ${formatCount(lines, "line")}` : "完成";
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
edit: {
|
|
135
|
+
call: (args) => ({ title: "edit", detail: compactText(args.path) || "…" }),
|
|
136
|
+
result: (result) => diffStats(result.details?.diff),
|
|
137
|
+
},
|
|
138
|
+
write: {
|
|
139
|
+
call: (args) => {
|
|
140
|
+
const lines = typeof args.content === "string" ? args.content.split("\n").length : 0;
|
|
141
|
+
return {
|
|
142
|
+
title: "write",
|
|
143
|
+
detail: `${compactText(args.path) || "…"}${lines > 0 ? ` · ${formatCount(lines, "line")}` : ""}`,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
result: () => "已写入",
|
|
147
|
+
},
|
|
148
|
+
grep: {
|
|
149
|
+
call: (args) => ({
|
|
150
|
+
title: "grep",
|
|
151
|
+
detail: `${compactText(args.pattern) || "…"} · ${compactText(args.path) || "."}`,
|
|
152
|
+
}),
|
|
153
|
+
result: (result) => formatCount(itemCount(textOutput(result)), "match", "matches"),
|
|
154
|
+
},
|
|
155
|
+
find: {
|
|
156
|
+
call: (args) => ({
|
|
157
|
+
title: "find",
|
|
158
|
+
detail: `${compactText(args.pattern) || "…"} · ${compactText(args.path) || "."}`,
|
|
159
|
+
}),
|
|
160
|
+
result: (result) => formatCount(itemCount(textOutput(result)), "file"),
|
|
161
|
+
},
|
|
162
|
+
ls: {
|
|
163
|
+
call: (args) => ({ title: "ls", detail: compactText(args.path) || "." }),
|
|
164
|
+
result: (result) => formatCount(itemCount(textOutput(result)), "entry", "entries"),
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export interface ToolViewController {
|
|
169
|
+
getMode(): ToolViewMode;
|
|
170
|
+
setMode(mode: ToolViewMode, ctx: ExtensionContext, notify?: boolean): Promise<void>;
|
|
171
|
+
onModeChange(listener: (mode: ToolViewMode) => void): () => void;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type ToolViewControllerOptions = {
|
|
175
|
+
readonly modeFile?: string;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export async function installToolViewController(
|
|
179
|
+
pi: ExtensionAPI,
|
|
180
|
+
options: ToolViewControllerOptions = {},
|
|
181
|
+
): Promise<ToolViewController> {
|
|
182
|
+
const modeFile = options.modeFile ?? DEFAULT_MODE_FILE;
|
|
183
|
+
let mode = await loadMode();
|
|
184
|
+
let skillRoots = new Set<string>();
|
|
185
|
+
let activeSessionId: string | undefined;
|
|
186
|
+
let sessionGeneration = 0;
|
|
187
|
+
let modeChangeQueue = Promise.resolve();
|
|
188
|
+
const modeListeners = new Set<(mode: ToolViewMode) => void>();
|
|
189
|
+
|
|
190
|
+
async function loadMode(): Promise<ToolViewMode> {
|
|
191
|
+
try {
|
|
192
|
+
const stored = JSON.parse(await readFile(modeFile, "utf8")) as Partial<StoredMode>;
|
|
193
|
+
return typeof stored.mode === "string" && isToolViewMode(stored.mode) ? stored.mode : "compact";
|
|
194
|
+
} catch {
|
|
195
|
+
return "compact";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function persistMode(nextMode: ToolViewMode): Promise<void> {
|
|
200
|
+
const tempFile = `${modeFile}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
201
|
+
const stored: StoredMode = { mode: nextMode };
|
|
202
|
+
await writeFile(tempFile, `${JSON.stringify(stored, null, 2)}\n`, "utf8");
|
|
203
|
+
await rename(tempFile, modeFile);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function notifyModeChange(): void {
|
|
207
|
+
for (const listener of modeListeners) listener(mode);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function updateStatus(ctx: ExtensionContext): void {
|
|
211
|
+
if (ctx.mode !== "tui") return;
|
|
212
|
+
ctx.ui.setStatus(MODE_STATUS_ID, `工具:${modeLabel(mode)}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function requestTranscriptRerender(ctx: ExtensionContext): void {
|
|
216
|
+
if (ctx.mode !== "tui") return;
|
|
217
|
+
// Pi has no dedicated transcript-invalidate API. Toggling the public
|
|
218
|
+
// expanded state forces every historical tool row to rebuild, then
|
|
219
|
+
// restores the user's original expansion preference.
|
|
220
|
+
const expanded = ctx.ui.getToolsExpanded();
|
|
221
|
+
ctx.ui.setToolsExpanded(!expanded);
|
|
222
|
+
ctx.ui.setToolsExpanded(expanded);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function setMode(nextMode: ToolViewMode, ctx: ExtensionContext, notify = true): Promise<void> {
|
|
226
|
+
const generation = sessionGeneration;
|
|
227
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
228
|
+
modeChangeQueue = modeChangeQueue.then(async () => {
|
|
229
|
+
mode = nextMode;
|
|
230
|
+
let persistError: unknown;
|
|
231
|
+
try {
|
|
232
|
+
await persistMode(mode);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
persistError = error;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Event callbacks can receive different context wrapper objects for the
|
|
238
|
+
// same session. Guard by stable session identity plus generation rather
|
|
239
|
+
// than object identity, while still rejecting contexts invalidated by a
|
|
240
|
+
// session replacement or reload during the disk write.
|
|
241
|
+
if (generation !== sessionGeneration || activeSessionId !== sessionId) return;
|
|
242
|
+
if (persistError) {
|
|
243
|
+
ctx.ui.notify(`工具显示模式已切换,但保存失败:${errorMessage(persistError)}`, "warning");
|
|
244
|
+
}
|
|
245
|
+
updateStatus(ctx);
|
|
246
|
+
notifyModeChange();
|
|
247
|
+
requestTranscriptRerender(ctx);
|
|
248
|
+
if (notify) ctx.ui.notify(`工具显示模式:${modeLabel(mode)}(${mode})`, "info");
|
|
249
|
+
});
|
|
250
|
+
return modeChangeQueue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function cycleMode(ctx: ExtensionContext): Promise<void> {
|
|
254
|
+
const index = MODE_ORDER.indexOf(mode);
|
|
255
|
+
return setMode(MODE_ORDER[(index + 1) % MODE_ORDER.length]!, ctx);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function updateSkillRootsFromPromptOptions(skills: Array<{ baseDir?: string; filePath?: string }> | undefined): void {
|
|
259
|
+
const roots = new Set<string>();
|
|
260
|
+
for (const skill of skills ?? []) {
|
|
261
|
+
if (skill.baseDir) roots.add(resolve(skill.baseDir));
|
|
262
|
+
else if (skill.filePath) roots.add(dirname(resolve(skill.filePath)));
|
|
263
|
+
}
|
|
264
|
+
skillRoots = roots;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function shouldPreserveReadRenderer(args: any, context: AnyRenderContext): boolean {
|
|
268
|
+
const absolutePath = resolveToolPath(context.cwd, args.path);
|
|
269
|
+
if (!absolutePath) return false;
|
|
270
|
+
if (isLikelySkillPath(absolutePath)) return true;
|
|
271
|
+
for (const root of skillRoots) {
|
|
272
|
+
if (isPathInside(absolutePath, root)) return true;
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function nativeContext(
|
|
278
|
+
context: AnyRenderContext,
|
|
279
|
+
nativeState: any,
|
|
280
|
+
lastComponent: RendererState["nativeCallComponent"] | RendererState["nativeResultComponent"],
|
|
281
|
+
): AnyRenderContext {
|
|
282
|
+
return { ...context, state: nativeState, lastComponent };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function callNativeRenderer(
|
|
286
|
+
definition: AnyToolDefinition,
|
|
287
|
+
args: any,
|
|
288
|
+
theme: Theme,
|
|
289
|
+
context: AnyRenderContext,
|
|
290
|
+
) {
|
|
291
|
+
const state = context.state as RendererState;
|
|
292
|
+
state.nativeState ??= {};
|
|
293
|
+
try {
|
|
294
|
+
const component = definition.renderCall?.(
|
|
295
|
+
args,
|
|
296
|
+
theme,
|
|
297
|
+
nativeContext(context, state.nativeState, state.nativeCallComponent),
|
|
298
|
+
) ?? new Container();
|
|
299
|
+
state.nativeCallComponent = component;
|
|
300
|
+
return component;
|
|
301
|
+
} catch {
|
|
302
|
+
state.nativeCallComponent = undefined;
|
|
303
|
+
return new Text(theme.fg("toolTitle", theme.bold(definition.name)), 0, 0);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function resultNativeRenderer(
|
|
308
|
+
definition: AnyToolDefinition,
|
|
309
|
+
result: AnyToolResult,
|
|
310
|
+
options: ToolRenderResultOptions,
|
|
311
|
+
theme: Theme,
|
|
312
|
+
context: AnyRenderContext,
|
|
313
|
+
) {
|
|
314
|
+
const state = context.state as RendererState;
|
|
315
|
+
state.nativeState ??= {};
|
|
316
|
+
try {
|
|
317
|
+
const component = definition.renderResult?.(
|
|
318
|
+
result,
|
|
319
|
+
options,
|
|
320
|
+
theme,
|
|
321
|
+
nativeContext(context, state.nativeState, state.nativeResultComponent),
|
|
322
|
+
) ?? new Container();
|
|
323
|
+
state.nativeResultComponent = component;
|
|
324
|
+
return component;
|
|
325
|
+
} catch {
|
|
326
|
+
state.nativeResultComponent = undefined;
|
|
327
|
+
const output = textOutput(result);
|
|
328
|
+
return output ? new Text(theme.fg("toolOutput", output), 0, 0) : new Container();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function compactCall(toolName: string, args: any, theme: Theme) {
|
|
333
|
+
const summary = COMPACT_SPECS[toolName]!.call(args);
|
|
334
|
+
const title = theme.fg("toolTitle", theme.bold(summary.title));
|
|
335
|
+
const detail = summary.detail ? ` ${theme.fg("accent", summary.detail)}` : "";
|
|
336
|
+
return new Text(title + detail, 0, 0);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function compactResult(
|
|
340
|
+
toolName: string,
|
|
341
|
+
result: AnyToolResult,
|
|
342
|
+
theme: Theme,
|
|
343
|
+
context: AnyRenderContext,
|
|
344
|
+
) {
|
|
345
|
+
const summary = COMPACT_SPECS[toolName]!.result(result, context);
|
|
346
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", summary), 0, 0);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function registerManagedTool(definitionFactory: (cwd: string, projectTrusted: boolean) => AnyToolDefinition): void {
|
|
350
|
+
// Registration and rendering must never opt into project settings.
|
|
351
|
+
const initialDefinition = definitionFactory(process.cwd(), false);
|
|
352
|
+
const definitionsByCwd = new Map<string, AnyToolDefinition>([[process.cwd(), initialDefinition]]);
|
|
353
|
+
const definitionFor = (cwd: string): AnyToolDefinition => {
|
|
354
|
+
const cached = definitionsByCwd.get(cwd);
|
|
355
|
+
if (cached) return cached;
|
|
356
|
+
const created = definitionFactory(cwd, false);
|
|
357
|
+
definitionsByCwd.set(cwd, created);
|
|
358
|
+
return created;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const toolName = initialDefinition.name;
|
|
362
|
+
pi.registerTool({
|
|
363
|
+
name: initialDefinition.name,
|
|
364
|
+
label: initialDefinition.label,
|
|
365
|
+
description: initialDefinition.description,
|
|
366
|
+
promptSnippet: initialDefinition.promptSnippet,
|
|
367
|
+
promptGuidelines: initialDefinition.promptGuidelines,
|
|
368
|
+
parameters: initialDefinition.parameters,
|
|
369
|
+
constrainedSampling: initialDefinition.constrainedSampling,
|
|
370
|
+
prepareArguments: initialDefinition.prepareArguments,
|
|
371
|
+
executionMode: initialDefinition.executionMode,
|
|
372
|
+
// Use a self-rendered shell so hidden mode can remove the entire row. In
|
|
373
|
+
// normal mode, wrap native renderers to reproduce Pi's default shell.
|
|
374
|
+
renderShell: "self",
|
|
375
|
+
|
|
376
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
377
|
+
// Resolve current settings and trust on every execution, independently of the render cache.
|
|
378
|
+
return definitionFactory(ctx.cwd, ctx.isProjectTrusted()).execute(toolCallId, params, signal, onUpdate, ctx);
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
renderCall(args, theme, context) {
|
|
382
|
+
const nativeDefinition = definitionFor(context.cwd);
|
|
383
|
+
const preserveNative = toolName === "read" && shouldPreserveReadRenderer(args, context);
|
|
384
|
+
const useNative = preserveNative || mode === "normal" || (mode === "compact" && (context.isError || context.expanded || !COMPACT_SPECS[toolName]));
|
|
385
|
+
if (useNative) {
|
|
386
|
+
const component = callNativeRenderer(nativeDefinition, args, theme, context);
|
|
387
|
+
if (nativeDefinition.renderShell === "self") return component;
|
|
388
|
+
const state = context.state as RendererState;
|
|
389
|
+
const box = state.nativeBox ?? new Box(1, 1);
|
|
390
|
+
state.nativeBox = box;
|
|
391
|
+
box.clear();
|
|
392
|
+
box.setBgFn((text) => theme.bg(
|
|
393
|
+
context.isError ? "toolErrorBg" : context.isPartial ? "toolPendingBg" : "toolSuccessBg",
|
|
394
|
+
text,
|
|
395
|
+
));
|
|
396
|
+
box.addChild(component);
|
|
397
|
+
return box;
|
|
398
|
+
}
|
|
399
|
+
if (mode === "hidden") return new Container();
|
|
400
|
+
return compactCall(toolName, args, theme);
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
renderResult(result, options, theme, context) {
|
|
404
|
+
const nativeDefinition = definitionFor(context.cwd);
|
|
405
|
+
const preserveNative = toolName === "read" && shouldPreserveReadRenderer(context.args, context);
|
|
406
|
+
const useNative = preserveNative || mode === "normal" || (mode === "compact" && (context.isError || options.expanded || !COMPACT_SPECS[toolName]));
|
|
407
|
+
if (useNative) {
|
|
408
|
+
const component = resultNativeRenderer(nativeDefinition, result, options, theme, context);
|
|
409
|
+
if (nativeDefinition.renderShell === "self") return component;
|
|
410
|
+
const state = context.state as RendererState;
|
|
411
|
+
// renderCall runs immediately before renderResult and rebuilds this
|
|
412
|
+
// shared box with the native call component.
|
|
413
|
+
state.nativeBox?.addChild(component);
|
|
414
|
+
return new Container();
|
|
415
|
+
}
|
|
416
|
+
if (mode === "hidden") {
|
|
417
|
+
const state = context.state as RendererState;
|
|
418
|
+
// If this row previously used a native streaming renderer (notably
|
|
419
|
+
// bash), let its final render run once so timers/resources settle,
|
|
420
|
+
// then discard the visual component.
|
|
421
|
+
if (!options.isPartial && state.nativeResultComponent) {
|
|
422
|
+
resultNativeRenderer(nativeDefinition, result, options, theme, context);
|
|
423
|
+
}
|
|
424
|
+
return new Container();
|
|
425
|
+
}
|
|
426
|
+
if (options.isPartial) return new Text(theme.fg("warning", "运行中…"), 0, 0);
|
|
427
|
+
const state = context.state as RendererState;
|
|
428
|
+
if (state.nativeResultComponent) {
|
|
429
|
+
resultNativeRenderer(nativeDefinition, result, options, theme, context);
|
|
430
|
+
}
|
|
431
|
+
return compactResult(toolName, result, theme, context);
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
registerManagedTool((cwd, projectTrusted) => {
|
|
437
|
+
const settings = SettingsManager.create(cwd, getAgentDir(), { projectTrusted });
|
|
438
|
+
return createReadToolDefinition(cwd, { autoResizeImages: settings.getImageAutoResize() });
|
|
439
|
+
});
|
|
440
|
+
registerManagedTool((cwd, projectTrusted) => {
|
|
441
|
+
const settings = SettingsManager.create(cwd, getAgentDir(), { projectTrusted });
|
|
442
|
+
return createBashToolDefinition(cwd, {
|
|
443
|
+
commandPrefix: settings.getShellCommandPrefix(),
|
|
444
|
+
shellPath: settings.getShellPath(),
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
registerManagedTool((cwd) => createEditToolDefinition(cwd));
|
|
448
|
+
registerManagedTool((cwd) => createWriteToolDefinition(cwd));
|
|
449
|
+
registerManagedTool((cwd) => createGrepToolDefinition(cwd));
|
|
450
|
+
registerManagedTool((cwd) => createFindToolDefinition(cwd));
|
|
451
|
+
registerManagedTool((cwd) => createLsToolDefinition(cwd));
|
|
452
|
+
|
|
453
|
+
pi.registerCommand(MODE_COMMAND, {
|
|
454
|
+
description: "切换工具显示模式:normal、compact、hidden、status",
|
|
455
|
+
getArgumentCompletions: (prefix) => {
|
|
456
|
+
const values = [...MODE_ORDER, "status"];
|
|
457
|
+
const matches = values.filter((value) => value.startsWith(prefix.trim()));
|
|
458
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
459
|
+
},
|
|
460
|
+
handler: async (args, ctx) => {
|
|
461
|
+
const requested = args.trim().toLowerCase();
|
|
462
|
+
if (!requested || requested === "status") {
|
|
463
|
+
if (!requested && ctx.mode === "tui") {
|
|
464
|
+
const choices = MODE_ORDER.map(
|
|
465
|
+
(value) => `${modeLabel(value)}(${value})${value === mode ? " · 当前" : ""}`,
|
|
466
|
+
);
|
|
467
|
+
const selected = await ctx.ui.select("选择工具显示模式", choices);
|
|
468
|
+
const selectedIndex = selected ? choices.indexOf(selected) : -1;
|
|
469
|
+
if (selectedIndex >= 0) await setMode(MODE_ORDER[selectedIndex]!, ctx);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
ctx.ui.notify(`当前工具显示模式:${modeLabel(mode)}(${mode})`, "info");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (!isToolViewMode(requested)) {
|
|
476
|
+
ctx.ui.notify("用法:/tool-view normal|compact|hidden|status", "warning");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
await setMode(requested, ctx);
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
pi.registerShortcut(MODE_SHORTCUT, {
|
|
484
|
+
description: "循环切换工具显示模式",
|
|
485
|
+
handler: cycleMode,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
pi.on("before_agent_start", (event) => {
|
|
489
|
+
updateSkillRootsFromPromptOptions(event.systemPromptOptions.skills);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
pi.on("session_start", (_event, ctx) => {
|
|
493
|
+
sessionGeneration++;
|
|
494
|
+
activeSessionId = ctx.sessionManager.getSessionId();
|
|
495
|
+
updateSkillRootsFromPromptOptions(
|
|
496
|
+
pi.getCommands()
|
|
497
|
+
.filter((command) => command.source === "skill")
|
|
498
|
+
.map((command) => ({ filePath: command.sourceInfo.path })),
|
|
499
|
+
);
|
|
500
|
+
updateStatus(ctx);
|
|
501
|
+
notifyModeChange();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
505
|
+
sessionGeneration++;
|
|
506
|
+
activeSessionId = undefined;
|
|
507
|
+
if (ctx.mode === "tui") ctx.ui.setStatus(MODE_STATUS_ID, undefined);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
getMode: () => mode,
|
|
512
|
+
setMode,
|
|
513
|
+
onModeChange(listener) {
|
|
514
|
+
modeListeners.add(listener);
|
|
515
|
+
listener(mode);
|
|
516
|
+
return () => modeListeners.delete(listener);
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { ToolViewPanel, groupUsageCounts } from "./panel.ts";
|
|
4
|
+
|
|
5
|
+
test("groups Tool View usage into Tool and MCP sections", () => {
|
|
6
|
+
const grouped = groupUsageCounts([
|
|
7
|
+
{ name: "read", kind: "tool", exchange: 2, session: 10 },
|
|
8
|
+
{ name: "codebase-memory-mcp", kind: "mcp", exchange: 1, session: 4 },
|
|
9
|
+
{ name: "mcp", kind: "tool", exchange: 1, session: 3 },
|
|
10
|
+
{ name: "context-mode", kind: "mcp", exchange: 0, session: 7 },
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
assert.deepEqual(grouped.tool.map((count) => count.name), ["read", "mcp"]);
|
|
14
|
+
assert.deepEqual(grouped.mcp.map((count) => count.name), ["codebase-memory-mcp", "context-mode"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function renderPanel(showModeSelector: boolean): string[] {
|
|
18
|
+
const tui = { addInputListener: () => () => {}, requestRender() {} } as any;
|
|
19
|
+
const theme = { fg: (_color: string, text: string) => text } as any;
|
|
20
|
+
return new ToolViewPanel(theme, {
|
|
21
|
+
tui,
|
|
22
|
+
getDesiredWidth: () => 60,
|
|
23
|
+
getMode: () => "compact",
|
|
24
|
+
getUsage: () => ({
|
|
25
|
+
counts: [
|
|
26
|
+
{ name: "fabric_exec", kind: "tool", exchange: 1, session: 4 },
|
|
27
|
+
{ name: "context-mode", kind: "mcp", exchange: 1, session: 3 },
|
|
28
|
+
],
|
|
29
|
+
}),
|
|
30
|
+
onSelect() {},
|
|
31
|
+
showModeSelector: () => showModeSelector,
|
|
32
|
+
}).render(60);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("shows a centered Fabric notice while preserving Tool and MCP usage", () => {
|
|
36
|
+
const lines = renderPanel(false);
|
|
37
|
+
const output = lines.join("\n");
|
|
38
|
+
assert.equal(lines.length, 7);
|
|
39
|
+
assert.doesNotMatch(output, /Normal|Compact|Hidden/);
|
|
40
|
+
assert.match(lines[1]!, / {10,}View controlled by Fabric {10,}/);
|
|
41
|
+
assert.match(output, /TOOLS/);
|
|
42
|
+
assert.match(output, /fabric_exec/);
|
|
43
|
+
assert.match(output, /MCP/);
|
|
44
|
+
assert.match(output, /context-mode/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("shows the mode selector when Fabric is absent", () => {
|
|
48
|
+
const output = renderPanel(true).join("\n");
|
|
49
|
+
assert.match(output, /Normal/);
|
|
50
|
+
assert.match(output, /Compact/);
|
|
51
|
+
assert.match(output, /Hidden/);
|
|
52
|
+
assert.match(output, /fabric_exec/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("re-evaluates selector visibility after Fabric takes tool ownership", () => {
|
|
56
|
+
let showModeSelector = true;
|
|
57
|
+
const tui = { addInputListener: () => () => {}, requestRender() {} } as any;
|
|
58
|
+
const theme = { fg: (_color: string, text: string) => text } as any;
|
|
59
|
+
const panel = new ToolViewPanel(theme, {
|
|
60
|
+
tui,
|
|
61
|
+
getDesiredWidth: () => 60,
|
|
62
|
+
getMode: () => "compact",
|
|
63
|
+
getUsage: () => ({ counts: [] }),
|
|
64
|
+
onSelect() {},
|
|
65
|
+
showModeSelector: () => showModeSelector,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
assert.match(panel.render(60).join("\n"), /Compact/);
|
|
69
|
+
showModeSelector = false;
|
|
70
|
+
const fullCodeOutput = panel.render(60).join("\n");
|
|
71
|
+
assert.doesNotMatch(fullCodeOutput, /Normal|Compact|Hidden/);
|
|
72
|
+
assert.match(fullCodeOutput, /View controlled by Fabric/);
|
|
73
|
+
});
|