@xynogen/pix-toolbox 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +40 -0
- package/src/index.ts +1 -0
- package/src/toolbox.test.ts +314 -0
- package/src/toolbox.ts +570 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-toolbox",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi tool — gated tool toggle UI (/toolbox)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "bun test"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"pi": {
|
|
16
|
+
"extensions": [
|
|
17
|
+
"src/index.ts"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi",
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"toolbox"
|
|
25
|
+
],
|
|
26
|
+
"author": "xynogen",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
31
|
+
"directory": "packages/pix-toolbox"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"@earendil-works/pi-tui": "*"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./toolbox.js";
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import registerToolbox, {
|
|
6
|
+
buildRows,
|
|
7
|
+
parseTargets,
|
|
8
|
+
renderList,
|
|
9
|
+
type ToggleOps,
|
|
10
|
+
type ToolRow,
|
|
11
|
+
toggleTool,
|
|
12
|
+
} from "./toolbox.ts";
|
|
13
|
+
|
|
14
|
+
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const toolInfo = (name: string, source = "builtin") =>
|
|
17
|
+
({
|
|
18
|
+
name,
|
|
19
|
+
description: `${name} does things.`,
|
|
20
|
+
parameters: {},
|
|
21
|
+
sourceInfo: { source, path: "", scope: "user", origin: "package" },
|
|
22
|
+
}) as never;
|
|
23
|
+
|
|
24
|
+
const mcpToolInfo = (name: string) => toolInfo(name, "mcp:context7");
|
|
25
|
+
|
|
26
|
+
// ─── buildRows ──────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe("buildRows", () => {
|
|
29
|
+
test("excludes core tools (bash, edit, read, write)", () => {
|
|
30
|
+
const rows = buildRows([
|
|
31
|
+
toolInfo("bash"),
|
|
32
|
+
toolInfo("edit"),
|
|
33
|
+
toolInfo("read"),
|
|
34
|
+
toolInfo("write"),
|
|
35
|
+
toolInfo("grep"),
|
|
36
|
+
toolInfo("ls"),
|
|
37
|
+
]);
|
|
38
|
+
expect(rows.map((r) => r.name)).toEqual(["grep", "ls"]);
|
|
39
|
+
expect(rows.every((r) => !r.mcp)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("flags MCP tools", () => {
|
|
43
|
+
const rows = buildRows([mcpToolInfo("ctx_search")]);
|
|
44
|
+
expect(rows[0].mcp).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("empty input returns empty", () => {
|
|
48
|
+
expect(buildRows([])).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ─── parseTargets ───────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("parseTargets", () => {
|
|
55
|
+
test("splits on commas, spaces, newlines", () => {
|
|
56
|
+
expect(parseTargets("ls, find grep\nfetch")).toEqual([
|
|
57
|
+
"ls",
|
|
58
|
+
"find",
|
|
59
|
+
"grep",
|
|
60
|
+
"fetch",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("dedupes", () => {
|
|
65
|
+
expect(parseTargets("ls, ls")).toEqual(["ls"]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("empty yields empty", () => {
|
|
69
|
+
expect(parseTargets("")).toEqual([]);
|
|
70
|
+
expect(parseTargets(" , ")).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ─── renderList ─────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe("renderList", () => {
|
|
77
|
+
const rows: ToolRow[] = [
|
|
78
|
+
{ name: "read", description: "Read files.", mcp: false },
|
|
79
|
+
{ name: "grep", description: "Search files.", mcp: false },
|
|
80
|
+
{ name: "ctx", description: "MCP search.", mcp: true },
|
|
81
|
+
];
|
|
82
|
+
const isActive = (n: string) => n === "read";
|
|
83
|
+
|
|
84
|
+
test("shows status for each tool", () => {
|
|
85
|
+
const out = renderList(rows, isActive);
|
|
86
|
+
expect(out).toContain("✓ active read");
|
|
87
|
+
expect(out).toContain("# gated grep");
|
|
88
|
+
expect(out).toContain("# gated ctx");
|
|
89
|
+
expect(out).toContain("[MCP]");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("filters by query", () => {
|
|
93
|
+
const out = renderList(rows, isActive, "grep");
|
|
94
|
+
expect(out).toContain("grep");
|
|
95
|
+
expect(out).not.toContain("read");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("no match message", () => {
|
|
99
|
+
const out = renderList(rows, isActive, "zzz");
|
|
100
|
+
expect(out).toContain('No tools matched "zzz"');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("empty rows", () => {
|
|
104
|
+
expect(renderList([], isActive)).toContain("No tools registered");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─── toggleTool ─────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("toggleTool", () => {
|
|
111
|
+
const rows: ToolRow[] = [
|
|
112
|
+
{ name: "read", description: "Read.", mcp: false },
|
|
113
|
+
{ name: "grep", description: "Search.", mcp: false },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
test("enable calls onActivate", () => {
|
|
117
|
+
let called = "";
|
|
118
|
+
const ops: ToggleOps = {
|
|
119
|
+
onActivate: (n) => {
|
|
120
|
+
called = n;
|
|
121
|
+
return true;
|
|
122
|
+
},
|
|
123
|
+
onDeactivate: () => false,
|
|
124
|
+
isActive: () => false,
|
|
125
|
+
};
|
|
126
|
+
const msg = toggleTool("enable", "grep", rows, ops);
|
|
127
|
+
expect(called).toBe("grep");
|
|
128
|
+
expect(msg).toContain("Enabled grep");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("disable calls onDeactivate", () => {
|
|
132
|
+
let called = "";
|
|
133
|
+
const ops: ToggleOps = {
|
|
134
|
+
onActivate: () => false,
|
|
135
|
+
onDeactivate: (n) => {
|
|
136
|
+
called = n;
|
|
137
|
+
return true;
|
|
138
|
+
},
|
|
139
|
+
isActive: () => true,
|
|
140
|
+
};
|
|
141
|
+
const msg = toggleTool("disable", "read", rows, ops);
|
|
142
|
+
expect(called).toBe("read");
|
|
143
|
+
expect(msg).toContain("Disabled read");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("unknown tool returns error", () => {
|
|
147
|
+
const ops: ToggleOps = {
|
|
148
|
+
onActivate: () => false,
|
|
149
|
+
onDeactivate: () => false,
|
|
150
|
+
isActive: () => false,
|
|
151
|
+
};
|
|
152
|
+
expect(toggleTool("enable", "nope", rows, ops)).toContain("Unknown");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("already active returns already message", () => {
|
|
156
|
+
const ops: ToggleOps = {
|
|
157
|
+
onActivate: () => false,
|
|
158
|
+
onDeactivate: () => false,
|
|
159
|
+
isActive: () => true,
|
|
160
|
+
};
|
|
161
|
+
expect(toggleTool("enable", "read", rows, ops)).toContain("already");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("already gated returns already message", () => {
|
|
165
|
+
const ops: ToggleOps = {
|
|
166
|
+
onActivate: () => false,
|
|
167
|
+
onDeactivate: () => false,
|
|
168
|
+
isActive: () => false,
|
|
169
|
+
};
|
|
170
|
+
expect(toggleTool("disable", "read", rows, ops)).toContain("already");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ─── Integration: /toolbox command ──────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
// Isolate from real ~/.pi/agent/toolbox.json on disk
|
|
177
|
+
let tmpAgentDir: string;
|
|
178
|
+
beforeAll(() => {
|
|
179
|
+
tmpAgentDir = mkdtempSync(join(tmpdir(), "toolbox-test-"));
|
|
180
|
+
process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
|
|
181
|
+
});
|
|
182
|
+
afterAll(() => {
|
|
183
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
184
|
+
try {
|
|
185
|
+
rmSync(tmpAgentDir, { recursive: true });
|
|
186
|
+
} catch {
|
|
187
|
+
// temp dir may already be gone — safe to ignore
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
function makeHost(toolNames: string[]) {
|
|
192
|
+
const handlers: Record<string, Array<(p: unknown) => unknown>> = {};
|
|
193
|
+
let active: string[] = [...toolNames];
|
|
194
|
+
const commands: Array<{ name: string; handler: Function }> = [];
|
|
195
|
+
const pi = {
|
|
196
|
+
on(ev: string, fn: (p: unknown) => unknown) {
|
|
197
|
+
(handlers[ev] ??= []).push(fn);
|
|
198
|
+
},
|
|
199
|
+
emit(ev: string, payload: unknown, ctx?: unknown) {
|
|
200
|
+
return Promise.all(
|
|
201
|
+
(handlers[ev] ?? []).map((f) => (f as Function)(payload, ctx)),
|
|
202
|
+
);
|
|
203
|
+
},
|
|
204
|
+
getAllTools() {
|
|
205
|
+
return toolNames.map((name) => ({
|
|
206
|
+
name,
|
|
207
|
+
description: `${name} does things.`,
|
|
208
|
+
parameters: {},
|
|
209
|
+
sourceInfo: { source: "builtin" },
|
|
210
|
+
}));
|
|
211
|
+
},
|
|
212
|
+
getActiveTools() {
|
|
213
|
+
return active;
|
|
214
|
+
},
|
|
215
|
+
setActiveTools(names: string[]) {
|
|
216
|
+
active = [...names];
|
|
217
|
+
},
|
|
218
|
+
getCommands() {
|
|
219
|
+
return [];
|
|
220
|
+
},
|
|
221
|
+
appendEntry() {},
|
|
222
|
+
registerTool() {},
|
|
223
|
+
registerCommand(name: string, def: { handler: Function }) {
|
|
224
|
+
commands.push({ name, handler: def.handler });
|
|
225
|
+
},
|
|
226
|
+
} as never;
|
|
227
|
+
const emit = (ev: string, payload: unknown, ctx?: unknown) =>
|
|
228
|
+
Promise.all((handlers[ev] ?? []).map((f) => (f as Function)(payload, ctx)));
|
|
229
|
+
return {
|
|
230
|
+
pi,
|
|
231
|
+
emit,
|
|
232
|
+
getActive: () => active,
|
|
233
|
+
command: (name: string) => commands.find((c) => c.name === name),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function makeCtx() {
|
|
238
|
+
const notes: Array<{ text: string; level?: string }> = [];
|
|
239
|
+
const ctx = {
|
|
240
|
+
ui: {
|
|
241
|
+
notify(text: string, level?: string) {
|
|
242
|
+
notes.push({ text, level });
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
} as never;
|
|
246
|
+
return { ctx, notes };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
describe("/toolbox command", () => {
|
|
250
|
+
const ALL = ["read", "write", "bash", "grep", "find"];
|
|
251
|
+
|
|
252
|
+
async function boot() {
|
|
253
|
+
const host = makeHost(ALL);
|
|
254
|
+
registerToolbox(host.pi);
|
|
255
|
+
// session_start triggers init
|
|
256
|
+
await host.emit("session_start", {}, {});
|
|
257
|
+
return host;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
test("registers a /toolbox command", async () => {
|
|
261
|
+
const host = await boot();
|
|
262
|
+
expect(host.command("toolbox")).toBeDefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("bare /toolbox falls back to listing when no custom UI", async () => {
|
|
266
|
+
const host = await boot();
|
|
267
|
+
const { ctx, notes } = makeCtx();
|
|
268
|
+
await host.command("toolbox")?.handler("", ctx);
|
|
269
|
+
expect(notes.length).toBe(1);
|
|
270
|
+
// only non-core tools shown, all start active
|
|
271
|
+
expect(notes[0].text).toContain("✓ active grep");
|
|
272
|
+
expect(notes[0].text).toContain("✓ active find");
|
|
273
|
+
// core tools excluded from toolbox
|
|
274
|
+
expect(notes[0].text).not.toContain(" read");
|
|
275
|
+
expect(notes[0].text).not.toContain(" bash");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("/toolbox list shows non-core tools with status", async () => {
|
|
279
|
+
const host = await boot();
|
|
280
|
+
const { ctx, notes } = makeCtx();
|
|
281
|
+
await host.command("toolbox")?.handler("list", ctx);
|
|
282
|
+
expect(notes[0].text).toContain("grep");
|
|
283
|
+
expect(notes[0].text).toContain("find");
|
|
284
|
+
expect(notes[0].text).not.toContain(" bash");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("/toolbox list <query> filters", async () => {
|
|
288
|
+
const host = await boot();
|
|
289
|
+
const { ctx, notes } = makeCtx();
|
|
290
|
+
await host.command("toolbox")?.handler("list fin", ctx);
|
|
291
|
+
expect(notes[0].text).toContain("find");
|
|
292
|
+
expect(notes[0].text).not.toContain("✓ active read");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("opens interactive picker when ctx.ui.custom exists", async () => {
|
|
296
|
+
const host = await boot();
|
|
297
|
+
let customCalled = 0;
|
|
298
|
+
const notes: Array<{ text: string; level?: string }> = [];
|
|
299
|
+
const ctx = {
|
|
300
|
+
ui: {
|
|
301
|
+
notify(text: string, level?: string) {
|
|
302
|
+
notes.push({ text, level });
|
|
303
|
+
},
|
|
304
|
+
async custom() {
|
|
305
|
+
customCalled++;
|
|
306
|
+
return null;
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
} as never;
|
|
310
|
+
await host.command("toolbox")?.handler("", ctx);
|
|
311
|
+
expect(customCalled).toBe(1);
|
|
312
|
+
expect(notes.length).toBe(0);
|
|
313
|
+
});
|
|
314
|
+
});
|
package/src/toolbox.ts
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* toolbox.ts — /toolbox command for user-driven tool gating
|
|
3
|
+
*
|
|
4
|
+
* Registers a `/toolbox` slash command that opens a TUI picker listing every
|
|
5
|
+
* registered tool (built-in and MCP). The user can toggle tools on/off —
|
|
6
|
+
* this controls which tools are described in the system prompt via
|
|
7
|
+
* pi.setActiveTools(). All tools remain callable via function definitions
|
|
8
|
+
* regardless of prompt visibility.
|
|
9
|
+
*
|
|
10
|
+
* Also supports headless usage:
|
|
11
|
+
* /toolbox enable <names> — enable tool(s) by name
|
|
12
|
+
* /toolbox disable <names> — disable tool(s) by name
|
|
13
|
+
* /toolbox list [query] — text search (no picker)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import type {
|
|
19
|
+
ExtensionAPI,
|
|
20
|
+
ExtensionContext,
|
|
21
|
+
Theme,
|
|
22
|
+
ToolInfo,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { DynamicBorder, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import {
|
|
26
|
+
Container,
|
|
27
|
+
decodeKittyPrintable,
|
|
28
|
+
fuzzyFilter,
|
|
29
|
+
Input,
|
|
30
|
+
Key,
|
|
31
|
+
type KeybindingsManager,
|
|
32
|
+
matchesKey,
|
|
33
|
+
type SelectItem,
|
|
34
|
+
SelectList,
|
|
35
|
+
Text,
|
|
36
|
+
type TUI,
|
|
37
|
+
visibleWidth,
|
|
38
|
+
} from "@earendil-works/pi-tui";
|
|
39
|
+
|
|
40
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/** Tools that can never be disabled — always prompt-visible. */
|
|
43
|
+
export const CORE_TOOLS: ReadonlySet<string> = new Set([
|
|
44
|
+
"bash",
|
|
45
|
+
"edit",
|
|
46
|
+
"read",
|
|
47
|
+
"write",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface ToolRow {
|
|
53
|
+
name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
mcp: boolean;
|
|
56
|
+
source?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Callbacks for toggleTool / renderList — test seam. */
|
|
60
|
+
export interface ToggleOps {
|
|
61
|
+
isActive: (name: string) => boolean;
|
|
62
|
+
onActivate: (name: string) => boolean;
|
|
63
|
+
onDeactivate: (name: string) => boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function isMcpTool(info: ToolInfo): boolean {
|
|
69
|
+
return /mcp/i.test(info.sourceInfo?.source ?? "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildRows(tools: ToolInfo[]): ToolRow[] {
|
|
73
|
+
return tools
|
|
74
|
+
.filter((t) => !CORE_TOOLS.has(t.name))
|
|
75
|
+
.map((t) => ({
|
|
76
|
+
name: t.name,
|
|
77
|
+
description: firstSentence(t.description ?? ""),
|
|
78
|
+
mcp: isMcpTool(t),
|
|
79
|
+
source: t.sourceInfo?.source,
|
|
80
|
+
}))
|
|
81
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const firstSentence = (desc: string): string => {
|
|
85
|
+
const clean = (desc ?? "").replace(/\s+/g, " ").trim();
|
|
86
|
+
const m = clean.match(/^.*?[.!?](?=\s|$)/);
|
|
87
|
+
return (m ? m[0] : clean).slice(0, 120);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function parseTargets(raw: string): string[] {
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
const out: string[] = [];
|
|
93
|
+
for (const t of raw.split(/[\s,]+/)) {
|
|
94
|
+
const name = t.trim();
|
|
95
|
+
if (!name || seen.has(name)) continue;
|
|
96
|
+
seen.add(name);
|
|
97
|
+
out.push(name);
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderList(
|
|
103
|
+
rows: ToolRow[],
|
|
104
|
+
isActive: (name: string) => boolean,
|
|
105
|
+
query?: string,
|
|
106
|
+
): string {
|
|
107
|
+
const filtered = query
|
|
108
|
+
? rows.filter(
|
|
109
|
+
(r) =>
|
|
110
|
+
r.name.toLowerCase().includes(query.toLowerCase()) ||
|
|
111
|
+
r.description.toLowerCase().includes(query.toLowerCase()),
|
|
112
|
+
)
|
|
113
|
+
: rows;
|
|
114
|
+
|
|
115
|
+
if (!filtered.length) {
|
|
116
|
+
return query ? `No tools matched "${query}".` : "No tools registered.";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const lines = filtered.map((r) => {
|
|
120
|
+
const status = isActive(r.name) ? "✓ active" : "# gated";
|
|
121
|
+
const kind = r.mcp ? "MCP" : "tool";
|
|
122
|
+
return `${status} ${r.name} [${kind}] ${r.description}`;
|
|
123
|
+
});
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function toggleTool(
|
|
128
|
+
action: "enable" | "disable",
|
|
129
|
+
name: string,
|
|
130
|
+
rows: ToolRow[],
|
|
131
|
+
ops: ToggleOps,
|
|
132
|
+
): string {
|
|
133
|
+
const row = rows.find((r) => r.name === name);
|
|
134
|
+
if (!row) return `Unknown tool "${name}".`;
|
|
135
|
+
|
|
136
|
+
if (action === "enable") {
|
|
137
|
+
const did = ops.onActivate(name);
|
|
138
|
+
return did
|
|
139
|
+
? `Enabled ${name} — now prompt-visible.`
|
|
140
|
+
: `${name} is already active.`;
|
|
141
|
+
}
|
|
142
|
+
const did = ops.onDeactivate(name);
|
|
143
|
+
return did
|
|
144
|
+
? `Disabled ${name} — hidden from prompt.`
|
|
145
|
+
: `${name} is already gated.`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Persistence ───────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
interface ToolboxState {
|
|
151
|
+
enabledTools: string[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function getStatePath(): string {
|
|
155
|
+
return join(getAgentDir(), "toolbox.json");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── State ──────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
function createState(pi: ExtensionAPI) {
|
|
161
|
+
let enabledTools = new Set<string>();
|
|
162
|
+
let initialized = false;
|
|
163
|
+
|
|
164
|
+
function persist(): void {
|
|
165
|
+
// Write to session so state survives branch navigation within a session
|
|
166
|
+
try {
|
|
167
|
+
pi.appendEntry<ToolboxState>("toolbox-config", {
|
|
168
|
+
enabledTools: [...enabledTools],
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.warn("toolbox: persist failed:", err);
|
|
172
|
+
}
|
|
173
|
+
// Write to disk so state survives across completely new sessions
|
|
174
|
+
try {
|
|
175
|
+
const sp = getStatePath();
|
|
176
|
+
mkdirSync(dirname(sp), { recursive: true });
|
|
177
|
+
writeFileSync(
|
|
178
|
+
sp,
|
|
179
|
+
JSON.stringify({ enabledTools: [...enabledTools] }, null, 2),
|
|
180
|
+
"utf-8",
|
|
181
|
+
);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.warn("toolbox: file persist failed:", err);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Load previously persisted enabled tool names from disk. */
|
|
188
|
+
function loadFromFile(): string[] | undefined {
|
|
189
|
+
try {
|
|
190
|
+
const sp = getStatePath();
|
|
191
|
+
if (!existsSync(sp)) return undefined;
|
|
192
|
+
const raw = JSON.parse(readFileSync(sp, "utf-8")) as ToolboxState;
|
|
193
|
+
if (Array.isArray(raw?.enabledTools)) return raw.enabledTools;
|
|
194
|
+
} catch {
|
|
195
|
+
// File doesn't exist, is corrupt, or we're in a test env without getAgentDir
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function restoreFromBranch(ctx: ExtensionContext): void {
|
|
201
|
+
// Prefer file-based persistence (survives across sessions).
|
|
202
|
+
// Fall back to session entries (survives branch navigation within a session).
|
|
203
|
+
// Fall back to full enable (first run).
|
|
204
|
+
const fileSaved = loadFromFile();
|
|
205
|
+
if (fileSaved) {
|
|
206
|
+
const validNames = new Set((pi.getAllTools() ?? []).map((t) => t.name));
|
|
207
|
+
enabledTools = new Set(
|
|
208
|
+
fileSaved.filter((n) => validNames.has(n) || CORE_TOOLS.has(n)),
|
|
209
|
+
);
|
|
210
|
+
for (const ct of CORE_TOOLS) enabledTools.add(ct);
|
|
211
|
+
initialized = true;
|
|
212
|
+
apply();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Fall back to plain init if sessionManager is unavailable (e.g. tests / headless)
|
|
217
|
+
if (!ctx?.sessionManager) {
|
|
218
|
+
ensureInit();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// getEntries() returns ALL entries in the session file — unlike getBranch()
|
|
223
|
+
// which only walks ancestors. Custom entries appended via appendCustomEntry
|
|
224
|
+
// are children of the leaf, not ancestors.
|
|
225
|
+
const allEntries = ctx.sessionManager.getEntries();
|
|
226
|
+
let saved: string[] | undefined;
|
|
227
|
+
|
|
228
|
+
for (const entry of allEntries) {
|
|
229
|
+
if (entry.type === "custom" && entry.customType === "toolbox-config") {
|
|
230
|
+
const data = entry.data as ToolboxState | undefined;
|
|
231
|
+
if (data?.enabledTools) saved = data.enabledTools;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (saved) {
|
|
236
|
+
const validNames = new Set((pi.getAllTools() ?? []).map((t) => t.name));
|
|
237
|
+
enabledTools = new Set(
|
|
238
|
+
saved.filter((n) => validNames.has(n) || CORE_TOOLS.has(n)),
|
|
239
|
+
);
|
|
240
|
+
for (const ct of CORE_TOOLS) enabledTools.add(ct);
|
|
241
|
+
} else {
|
|
242
|
+
const names = (pi.getAllTools() ?? []).map((t) => t.name);
|
|
243
|
+
enabledTools = new Set(names);
|
|
244
|
+
}
|
|
245
|
+
initialized = true;
|
|
246
|
+
apply();
|
|
247
|
+
// Persist to disk on first init so future sessions pick it up
|
|
248
|
+
persist();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function ensureInit(): void {
|
|
252
|
+
if (initialized) return;
|
|
253
|
+
let names: string[] = [];
|
|
254
|
+
try {
|
|
255
|
+
names = (pi.getAllTools() ?? []).map((t) => t.name);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.warn("toolbox: getAllTools failed:", err);
|
|
258
|
+
}
|
|
259
|
+
if (!names.length) return;
|
|
260
|
+
enabledTools = new Set(names);
|
|
261
|
+
initialized = true;
|
|
262
|
+
apply();
|
|
263
|
+
persist();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function apply(): void {
|
|
267
|
+
if (!initialized) return;
|
|
268
|
+
try {
|
|
269
|
+
pi.setActiveTools([...enabledTools]);
|
|
270
|
+
} catch (err) {
|
|
271
|
+
console.warn("toolbox: setActiveTools failed:", err);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function isActive(name: string): boolean {
|
|
276
|
+
return enabledTools.has(name);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function onActivate(name: string): boolean {
|
|
280
|
+
if (!initialized) return false;
|
|
281
|
+
if (enabledTools.has(name)) return false;
|
|
282
|
+
enabledTools.add(name);
|
|
283
|
+
apply();
|
|
284
|
+
persist();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function onDeactivate(name: string): boolean {
|
|
289
|
+
if (!initialized) return false;
|
|
290
|
+
if (CORE_TOOLS.has(name)) return false;
|
|
291
|
+
const did = enabledTools.delete(name);
|
|
292
|
+
if (did) {
|
|
293
|
+
apply();
|
|
294
|
+
persist();
|
|
295
|
+
}
|
|
296
|
+
return did;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
ensureInit,
|
|
301
|
+
restoreFromBranch,
|
|
302
|
+
isActive,
|
|
303
|
+
onActivate,
|
|
304
|
+
onDeactivate,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Registration ───────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
export default function registerToolbox(pi: ExtensionAPI): void {
|
|
311
|
+
const state = createState(pi);
|
|
312
|
+
|
|
313
|
+
// Defer init until tools are registered — session_start fires after all extensions load.
|
|
314
|
+
// Try to restore persisted state; fall back to full init if no config found.
|
|
315
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
316
|
+
state.restoreFromBranch(ctx);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Re-restore when navigating branch history
|
|
320
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
321
|
+
state.restoreFromBranch(ctx);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
function getRows(): ToolRow[] {
|
|
325
|
+
try {
|
|
326
|
+
return buildRows(pi.getAllTools() ?? []);
|
|
327
|
+
} catch {
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const ops: ToggleOps = {
|
|
333
|
+
isActive: state.isActive,
|
|
334
|
+
onActivate: state.onActivate,
|
|
335
|
+
onDeactivate: state.onDeactivate,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
async function showPicker(ctx: {
|
|
339
|
+
ui: {
|
|
340
|
+
custom: <T>(f: unknown) => Promise<T>;
|
|
341
|
+
notify: (m: string, t?: "info" | "warning" | "error") => void;
|
|
342
|
+
};
|
|
343
|
+
}): Promise<void> {
|
|
344
|
+
await ctx.ui.custom<null>(
|
|
345
|
+
(
|
|
346
|
+
tui: TUI,
|
|
347
|
+
theme: Theme,
|
|
348
|
+
_kb: KeybindingsManager,
|
|
349
|
+
done: (r: null) => void,
|
|
350
|
+
) => {
|
|
351
|
+
const accent = "accent";
|
|
352
|
+
const mute = (s: string) => theme.fg("muted", s);
|
|
353
|
+
const container = new Container();
|
|
354
|
+
|
|
355
|
+
type RowState = "active" | "gated";
|
|
356
|
+
const stateOf = (name: string): RowState =>
|
|
357
|
+
ops.isActive(name) ? "active" : "gated";
|
|
358
|
+
|
|
359
|
+
const labelFor = (r: ToolRow): string => {
|
|
360
|
+
const active = stateOf(r.name) === "active";
|
|
361
|
+
const marker = active ? " " : theme.fg("warning", "#");
|
|
362
|
+
const name = active
|
|
363
|
+
? theme.fg("success", r.name)
|
|
364
|
+
: theme.fg("dim", r.name);
|
|
365
|
+
const kind = mute(`[${r.mcp ? "MCP" : "tool"}]`);
|
|
366
|
+
return `${marker} ${name} ${kind}`;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const descFor = (r: ToolRow): string => {
|
|
370
|
+
const active = stateOf(r.name) === "active";
|
|
371
|
+
const tag = active
|
|
372
|
+
? theme.fg("success", "active")
|
|
373
|
+
: theme.fg("warning", "gated");
|
|
374
|
+
return `${tag} ${mute("·")} ${r.description || "(no description)"}`;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const rows = getRows();
|
|
378
|
+
const byValue = new Map<string, ToolRow>();
|
|
379
|
+
const toItem = (r: ToolRow): SelectItem => {
|
|
380
|
+
byValue.set(r.name, r);
|
|
381
|
+
return {
|
|
382
|
+
value: r.name,
|
|
383
|
+
label: labelFor(r),
|
|
384
|
+
description: descFor(r),
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const allItems = rows.map(toItem);
|
|
389
|
+
const widest = allItems.reduce(
|
|
390
|
+
(w, it) => Math.max(w, visibleWidth(it.label)),
|
|
391
|
+
0,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const list = new SelectList(
|
|
395
|
+
allItems,
|
|
396
|
+
Math.min(allItems.length, 14),
|
|
397
|
+
{
|
|
398
|
+
selectedPrefix: (t: string) => theme.fg(accent, t),
|
|
399
|
+
selectedText: (t: string) => theme.fg(accent, t),
|
|
400
|
+
description: (t: string) => t,
|
|
401
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
402
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
minPrimaryColumnWidth: widest + 2,
|
|
406
|
+
maxPrimaryColumnWidth: widest + 2,
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const internal = list as unknown as {
|
|
411
|
+
items: SelectItem[];
|
|
412
|
+
filteredItems: SelectItem[];
|
|
413
|
+
selectedIndex: number;
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const search = new Input();
|
|
417
|
+
const statusLine = new Text("");
|
|
418
|
+
|
|
419
|
+
const refreshLabels = () => {
|
|
420
|
+
for (const it of internal.items) {
|
|
421
|
+
const r = byValue.get(it.value);
|
|
422
|
+
if (!r) continue;
|
|
423
|
+
it.label = labelFor(r);
|
|
424
|
+
it.description = descFor(r);
|
|
425
|
+
}
|
|
426
|
+
list.invalidate();
|
|
427
|
+
container.invalidate();
|
|
428
|
+
tui.requestRender?.();
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const doToggle = (action: "enable" | "disable") => {
|
|
432
|
+
const sel = list.getSelectedItem();
|
|
433
|
+
if (!sel) return;
|
|
434
|
+
const msg = toggleTool(action, sel.value, rows, ops);
|
|
435
|
+
statusLine.setText(theme.fg("muted", msg));
|
|
436
|
+
refreshLabels();
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const flipSelected = () => {
|
|
440
|
+
const sel = list.getSelectedItem();
|
|
441
|
+
if (!sel) return;
|
|
442
|
+
if (stateOf(sel.value) === "active") doToggle("disable");
|
|
443
|
+
else doToggle("enable");
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const applyFilter = (q: string) => {
|
|
447
|
+
const query = q.trim();
|
|
448
|
+
internal.filteredItems =
|
|
449
|
+
query.length === 0
|
|
450
|
+
? internal.items
|
|
451
|
+
: fuzzyFilter(
|
|
452
|
+
internal.items,
|
|
453
|
+
query,
|
|
454
|
+
(it: SelectItem) => `${it.value} ${it.description ?? ""}`,
|
|
455
|
+
);
|
|
456
|
+
internal.selectedIndex = 0;
|
|
457
|
+
list.invalidate();
|
|
458
|
+
container.invalidate();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
list.onSelect = () => done(null);
|
|
462
|
+
list.onCancel = () => done(null);
|
|
463
|
+
search.onEscape = () => done(null);
|
|
464
|
+
|
|
465
|
+
container.addChild(
|
|
466
|
+
new DynamicBorder((s: string) => theme.fg(accent, s)),
|
|
467
|
+
);
|
|
468
|
+
container.addChild(
|
|
469
|
+
new Text(theme.fg(accent, theme.bold("🧰 Toolbox"))),
|
|
470
|
+
);
|
|
471
|
+
container.addChild(new Text(theme.fg("muted", "Search:")));
|
|
472
|
+
container.addChild(search);
|
|
473
|
+
container.addChild(list);
|
|
474
|
+
container.addChild(statusLine);
|
|
475
|
+
container.addChild(
|
|
476
|
+
new Text(
|
|
477
|
+
theme.fg(
|
|
478
|
+
"dim",
|
|
479
|
+
"↑↓ navigate · e enable · d disable · space toggle · esc close",
|
|
480
|
+
),
|
|
481
|
+
),
|
|
482
|
+
);
|
|
483
|
+
container.addChild(
|
|
484
|
+
new DynamicBorder((s: string) => theme.fg(accent, s)),
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
render(w: number) {
|
|
489
|
+
return container.render(w);
|
|
490
|
+
},
|
|
491
|
+
invalidate() {
|
|
492
|
+
container.invalidate();
|
|
493
|
+
},
|
|
494
|
+
handleInput(data: string) {
|
|
495
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
|
|
496
|
+
list.handleInput?.(data);
|
|
497
|
+
} else if (
|
|
498
|
+
matchesKey(data, Key.enter) ||
|
|
499
|
+
matchesKey(data, Key.escape)
|
|
500
|
+
) {
|
|
501
|
+
done(null);
|
|
502
|
+
return;
|
|
503
|
+
} else if (
|
|
504
|
+
matchesKey(data, Key.space) ||
|
|
505
|
+
matchesKey(data, Key.tab)
|
|
506
|
+
) {
|
|
507
|
+
flipSelected();
|
|
508
|
+
} else {
|
|
509
|
+
const printable = decodeKittyPrintable(data);
|
|
510
|
+
if (printable !== undefined) {
|
|
511
|
+
if (printable === "e") {
|
|
512
|
+
doToggle("enable");
|
|
513
|
+
} else if (printable === "d") {
|
|
514
|
+
doToggle("disable");
|
|
515
|
+
} else {
|
|
516
|
+
search.handleInput?.(data);
|
|
517
|
+
applyFilter(search.getValue?.() ?? "");
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
search.handleInput?.(data);
|
|
521
|
+
applyFilter(search.getValue?.() ?? "");
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
container.invalidate();
|
|
525
|
+
tui.requestRender?.();
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
},
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
pi.registerCommand("toolbox", {
|
|
533
|
+
description:
|
|
534
|
+
"Toggle tools on/off. ↑↓ navigate, e/d enable/disable, space toggle. " +
|
|
535
|
+
"Headless: /toolbox enable|disable <names>, /toolbox list [query]",
|
|
536
|
+
handler: async (args, ctx) => {
|
|
537
|
+
const raw = (args ?? "").trim();
|
|
538
|
+
const verb = raw.split(/\s+/, 1)[0]?.toLowerCase();
|
|
539
|
+
|
|
540
|
+
if (verb === "enable" || verb === "disable") {
|
|
541
|
+
const targets = parseTargets(raw.slice(verb.length).trim());
|
|
542
|
+
if (!targets.length) {
|
|
543
|
+
ctx.ui.notify(
|
|
544
|
+
`/toolbox ${verb} needs a tool name, e.g. /toolbox ${verb} grep`,
|
|
545
|
+
"warning",
|
|
546
|
+
);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const rows = getRows();
|
|
550
|
+
const msg = targets
|
|
551
|
+
.map((t) => toggleTool(verb, t, rows, ops))
|
|
552
|
+
.join("\n");
|
|
553
|
+
ctx.ui.notify(msg, "info");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (verb === "list") {
|
|
558
|
+
const query = raw.slice(verb.length).trim() || undefined;
|
|
559
|
+
ctx.ui.notify(renderList(getRows(), ops.isActive, query), "info");
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (typeof ctx.ui.custom === "function") {
|
|
564
|
+
await showPicker(ctx as unknown as Parameters<typeof showPicker>[0]);
|
|
565
|
+
} else {
|
|
566
|
+
ctx.ui.notify(renderList(getRows(), ops.isActive), "info");
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
}
|