@xynogen/pix-toolbox 0.1.16 → 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/LICENSE +21 -0
- package/README.md +20 -9
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/toolbox.ts +306 -176
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xynogen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -4,12 +4,19 @@ Pi tool — gated tool toggle UI (`/toolbox`).
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
-
Registers a `/toolbox` slash command — a TUI fuzzy-search picker listing every registered tool (built-in, extension, MCP)
|
|
7
|
+
Registers a `/toolbox` slash command — a TUI fuzzy-search picker listing every registered tool (built-in, extension, MCP), split into **Tools** and **MCP** tabs. Each tool has one of three states:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
| State | Mark | Meaning |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| enabled | `✓` | Declared in the system prompt. |
|
|
12
|
+
| deferred | `~` | Not declared. `tool_search` loads it on demand. Only for tools with `deferred` exposure. |
|
|
13
|
+
| disabled | `#` | Not declared, and every call is blocked, also through `tool_search` or `codemode`. |
|
|
14
|
+
|
|
15
|
+
Keys: `tab` switch tab · `↑↓` navigate · `ctrl+e` enable · `ctrl+f` defer · `ctrl+d` disable · `space` cycle · any other key types into the search.
|
|
16
|
+
|
|
17
|
+
- Four tools (`bash`, `edit`, `read`, `write`) are protected and stay enabled.
|
|
18
|
+
- State persists to `~/.pi/agent/toolbox.json`. Only changes from the default are saved: `disabledTools` (tools you disabled) and `loadedTools` (deferred tools you enabled). A newly installed tool keeps its default. A legacy `enabledTools` file is migrated on the next session start.
|
|
19
|
+
- Headless subcommands: `/toolbox enable|defer|disable <names>`, `/toolbox list [query]`.
|
|
13
20
|
|
|
14
21
|
## Install
|
|
15
22
|
|
|
@@ -17,18 +24,22 @@ Registers a `/toolbox` slash command — a TUI fuzzy-search picker listing every
|
|
|
17
24
|
pi install npm:@xynogen/pix-toolbox
|
|
18
25
|
```
|
|
19
26
|
|
|
20
|
-
> Standalone
|
|
27
|
+
> Standalone and opt-in. [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core) does not bundle it. It is a power-user tool-toggle UI.
|
|
21
28
|
|
|
22
29
|
## Full distro
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
To install the complete pix suite (all packages + Pi itself):
|
|
31
|
+
This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
|
|
27
32
|
|
|
28
33
|
```bash
|
|
34
|
+
# Linux / macOS
|
|
29
35
|
curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
|
|
30
36
|
```
|
|
31
37
|
|
|
38
|
+
```powershell
|
|
39
|
+
# Windows
|
|
40
|
+
irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
|
|
41
|
+
```
|
|
42
|
+
|
|
32
43
|
## License
|
|
33
44
|
|
|
34
45
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-toolbox",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pi tool — gated tool toggle UI (/toolbox)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@xynogen/pix-pretty": "^1.
|
|
38
|
+
"@xynogen/pix-pretty": "^1.29.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { default } from "./toolbox.
|
|
1
|
+
export { default } from "./toolbox.ts";
|
package/src/toolbox.ts
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
* toolbox.ts — /toolbox command for user-driven tool gating
|
|
3
3
|
*
|
|
4
4
|
* Registers a `/toolbox` slash command that opens a TUI picker listing every
|
|
5
|
-
* registered tool (built-in and MCP).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* registered tool (built-in and MCP). Each tool has one of three states:
|
|
6
|
+
* enabled — declared in the system prompt (pi.setActiveTools)
|
|
7
|
+
* deferred — not declared; tool_search loads it on demand (deferred exposure only)
|
|
8
|
+
* disabled — not declared and blocked, even through tool_search or codemode
|
|
9
9
|
*
|
|
10
10
|
* Also supports headless usage:
|
|
11
|
-
* /toolbox enable <names>
|
|
12
|
-
* /toolbox disable <names> — disable tool(s) by name
|
|
11
|
+
* /toolbox enable|defer|disable <names>
|
|
13
12
|
* /toolbox list [query] — text search (no picker)
|
|
14
13
|
*/
|
|
15
14
|
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
16
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
import type {
|
|
@@ -23,7 +23,6 @@ import type {
|
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
25
25
|
import {
|
|
26
|
-
decodeKittyPrintable,
|
|
27
26
|
fuzzyFilter,
|
|
28
27
|
Input,
|
|
29
28
|
Key,
|
|
@@ -55,13 +54,26 @@ export interface ToolRow {
|
|
|
55
54
|
description: string;
|
|
56
55
|
mcp: boolean;
|
|
57
56
|
source?: string;
|
|
57
|
+
exposure?: string;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
export type ToolState = "enabled" | "deferred" | "disabled";
|
|
61
|
+
export const TOOL_STATES: readonly ToolState[] = ["enabled", "deferred", "disabled"];
|
|
62
|
+
|
|
60
63
|
/** Callbacks for toggleTool / renderList — test seam. */
|
|
61
64
|
export interface ToggleOps {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
stateOf: (name: string) => ToolState;
|
|
66
|
+
/** Returns false when the tool is already in that state. */
|
|
67
|
+
setState: (name: string, state: ToolState) => boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Only tools registered with `deferred` exposure can be found by tool_search. */
|
|
71
|
+
export const canDefer = (row: ToolRow): boolean => row.exposure === "deferred";
|
|
72
|
+
|
|
73
|
+
/** Next state for the space key. Skips `deferred` when the tool cannot be deferred. */
|
|
74
|
+
export function nextState(row: ToolRow, current: ToolState): ToolState {
|
|
75
|
+
const states = TOOL_STATES.filter((s) => s !== "deferred" || canDefer(row));
|
|
76
|
+
return states[(states.indexOf(current) + 1) % states.length] ?? "enabled";
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -70,16 +82,25 @@ function isMcpTool(info: ToolInfo): boolean {
|
|
|
70
82
|
return /mcp/i.test(info.sourceInfo?.source ?? "");
|
|
71
83
|
}
|
|
72
84
|
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Normal tools first, MCP tools last. Inside a group: enabled, deferred, disabled,
|
|
87
|
+
* then by name. Without `stateOf`, the registered exposure stands in for the state.
|
|
88
|
+
*/
|
|
89
|
+
export function buildRows(tools: ToolInfo[], stateOf?: (name: string) => ToolState): ToolRow[] {
|
|
90
|
+
const rows = tools
|
|
75
91
|
.filter((t) => !CORE_TOOLS.has(t.name))
|
|
76
92
|
.map((t) => ({
|
|
77
93
|
name: t.name,
|
|
78
94
|
description: firstSentence(t.description ?? ""),
|
|
79
95
|
mcp: isMcpTool(t),
|
|
80
96
|
source: t.sourceInfo?.source,
|
|
81
|
-
|
|
82
|
-
|
|
97
|
+
exposure: t.exposure ?? "direct",
|
|
98
|
+
}));
|
|
99
|
+
const rank = (r: ToolRow): number =>
|
|
100
|
+
TOOL_STATES.indexOf(stateOf?.(r.name) ?? (r.exposure === "deferred" ? "deferred" : "enabled"));
|
|
101
|
+
return rows.sort(
|
|
102
|
+
(a, b) => Number(a.mcp) - Number(b.mcp) || rank(a) - rank(b) || a.name.localeCompare(b.name),
|
|
103
|
+
);
|
|
83
104
|
}
|
|
84
105
|
|
|
85
106
|
const firstSentence = (desc: string): string => {
|
|
@@ -100,9 +121,15 @@ export function parseTargets(raw: string): string[] {
|
|
|
100
121
|
return out;
|
|
101
122
|
}
|
|
102
123
|
|
|
124
|
+
const STATUS: Record<ToolState, string> = {
|
|
125
|
+
enabled: "✓ enabled",
|
|
126
|
+
deferred: "~ deferred",
|
|
127
|
+
disabled: "# disabled",
|
|
128
|
+
};
|
|
129
|
+
|
|
103
130
|
export function renderList(
|
|
104
131
|
rows: ToolRow[],
|
|
105
|
-
|
|
132
|
+
stateOf: (name: string) => ToolState,
|
|
106
133
|
query?: string,
|
|
107
134
|
): string {
|
|
108
135
|
const filtered = query
|
|
@@ -117,53 +144,144 @@ export function renderList(
|
|
|
117
144
|
return query ? `No tools matched "${query}".` : "No tools registered.";
|
|
118
145
|
}
|
|
119
146
|
|
|
120
|
-
const lines =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
let group = "";
|
|
149
|
+
for (const row of filtered) {
|
|
150
|
+
const nextGroup = row.mcp ? "MCP" : "Tools";
|
|
151
|
+
if (group !== nextGroup) {
|
|
152
|
+
group = nextGroup;
|
|
153
|
+
lines.push(`${lines.length ? "\n" : ""}${group}:`);
|
|
154
|
+
}
|
|
155
|
+
const kind = row.mcp ? "MCP" : "tool";
|
|
156
|
+
lines.push(`${STATUS[stateOf(row.name)]} ${row.name} [${kind}] ${row.description}`);
|
|
157
|
+
}
|
|
125
158
|
return lines.join("\n");
|
|
126
159
|
}
|
|
127
160
|
|
|
161
|
+
const DONE: Record<ToolState, string> = {
|
|
162
|
+
enabled: "now in the prompt",
|
|
163
|
+
deferred: "tool_search loads it on demand",
|
|
164
|
+
disabled: "blocked",
|
|
165
|
+
};
|
|
166
|
+
|
|
128
167
|
export function toggleTool(
|
|
129
|
-
|
|
168
|
+
state: ToolState,
|
|
130
169
|
name: string,
|
|
131
170
|
rows: ToolRow[],
|
|
132
171
|
ops: ToggleOps,
|
|
133
172
|
): string {
|
|
134
173
|
const row = rows.find((r) => r.name === name);
|
|
135
174
|
if (!row) return `Unknown tool "${name}".`;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
return did ? `Disabled ${name} — hidden from prompt.` : `${name} is already gated.`;
|
|
175
|
+
if (CORE_TOOLS.has(name) && state !== "enabled")
|
|
176
|
+
return `${name} is a core tool. It stays enabled.`;
|
|
177
|
+
if (state === "deferred" && !canDefer(row))
|
|
178
|
+
return `${name} has direct exposure. tool_search cannot find it, so it cannot be deferred.`;
|
|
179
|
+
if (!ops.setState(name, state)) return `${name} is already ${state}.`;
|
|
180
|
+
return `${name} ${state} — ${DONE[state]}.`;
|
|
143
181
|
}
|
|
144
182
|
|
|
145
183
|
// ─── Persistence ───────────────────────────────────────────────────────────
|
|
146
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Persisted gate state. Only changes from the default are saved, so a newly
|
|
187
|
+
* installed tool keeps its default (direct → enabled, deferred → deferred).
|
|
188
|
+
* `disabledTools` holds tools the user disabled. `loadedTools` holds deferred
|
|
189
|
+
* tools the user enabled, so they are declared on every session start.
|
|
190
|
+
* `enabledTools` is the legacy form (an allow-list that hid every tool
|
|
191
|
+
* installed later); it is read once and migrated on the next write.
|
|
192
|
+
*/
|
|
147
193
|
interface ToolboxState {
|
|
148
|
-
|
|
194
|
+
disabledTools?: string[];
|
|
195
|
+
loadedTools?: string[];
|
|
196
|
+
enabledTools?: string[];
|
|
149
197
|
}
|
|
150
198
|
|
|
151
199
|
function getStatePath(): string {
|
|
152
200
|
return join(getAgentDir(), "toolbox.json");
|
|
153
201
|
}
|
|
154
202
|
|
|
203
|
+
const isStringArray = (v: unknown): v is string[] =>
|
|
204
|
+
Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* MCP codemode tools are named as JS identifiers now (`mcp__my-srv__a-b` → `mcp__my_srv__a_b`).
|
|
208
|
+
* Map saved names to that form so a disabled tool stays disabled. When `known` lacks that form,
|
|
209
|
+
* the tool collided or passed 64 chars and carries a hash suffix. Rebuild it from the old name.
|
|
210
|
+
* Keep in sync with pix-mcp `codemodeToolName` (packages cannot import each other).
|
|
211
|
+
*/
|
|
212
|
+
export function migrateToolName(name: string, known?: ReadonlySet<string>): string {
|
|
213
|
+
if (!name.startsWith("mcp__")) return name;
|
|
214
|
+
const plain = name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
215
|
+
if (!known || known.has(plain)) return plain;
|
|
216
|
+
// Server and tool names can both hold `__`, so try every split. Only a known name matches.
|
|
217
|
+
for (let split = name.indexOf("__", 5); split >= 0; split = name.indexOf("__", split + 1)) {
|
|
218
|
+
const key = `${name.slice(5, split)}\0${name.slice(split + 2)}`;
|
|
219
|
+
const hash = createHash("sha256").update(key).digest("hex").slice(0, 8);
|
|
220
|
+
const hashed = `${plain.slice(0, 64 - hash.length - 1)}_${hash}`;
|
|
221
|
+
if (known.has(hashed)) return hashed;
|
|
222
|
+
}
|
|
223
|
+
return plain;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Disabled tool names from a saved state, or undefined when it holds none.
|
|
228
|
+
* A legacy allow-list maps to "every known non-core tool not in it".
|
|
229
|
+
*/
|
|
230
|
+
export function disabledFromState(raw: unknown, allNames: string[]): string[] | undefined {
|
|
231
|
+
const state = raw as ToolboxState | undefined;
|
|
232
|
+
if (isStringArray(state?.disabledTools)) {
|
|
233
|
+
const known = new Set(allNames);
|
|
234
|
+
return state.disabledTools
|
|
235
|
+
.map((n) => migrateToolName(n, known))
|
|
236
|
+
.filter((n) => !CORE_TOOLS.has(n));
|
|
237
|
+
}
|
|
238
|
+
if (isStringArray(state?.enabledTools)) {
|
|
239
|
+
const enabled = new Set(state.enabledTools);
|
|
240
|
+
return allNames.filter((n) => !enabled.has(n) && !CORE_TOOLS.has(n));
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Deferred tool names the user chose to load on every session start. */
|
|
246
|
+
export function loadedFromState(raw: unknown, allNames: string[] = []): string[] {
|
|
247
|
+
const loaded = (raw as ToolboxState | undefined)?.loadedTools;
|
|
248
|
+
const known = new Set(allNames);
|
|
249
|
+
return isStringArray(loaded) ? loaded.map((n) => migrateToolName(n, known)) : [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const exposureOf = (tool: ToolInfo): string => tool.exposure ?? "direct";
|
|
253
|
+
|
|
155
254
|
// ─── State ──────────────────────────────────────────────────────────────────
|
|
156
255
|
|
|
157
256
|
function createState(pi: ExtensionAPI) {
|
|
158
|
-
let
|
|
257
|
+
let disabledTools = new Set<string>();
|
|
258
|
+
let loadedTools = new Set<string>();
|
|
159
259
|
let initialized = false;
|
|
160
260
|
|
|
261
|
+
function isDeferred(name: string): boolean {
|
|
262
|
+
try {
|
|
263
|
+
const tool = pi.getAllTools().find((t) => t.name === name);
|
|
264
|
+
return tool ? exposureOf(tool) === "deferred" : false;
|
|
265
|
+
} catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function allNames(): string[] {
|
|
271
|
+
try {
|
|
272
|
+
return (pi.getAllTools() ?? []).map((t) => t.name);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
console.warn("toolbox: getAllTools failed:", err);
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
161
279
|
function persist(): void {
|
|
280
|
+
const data: ToolboxState = { disabledTools: [...disabledTools].sort() };
|
|
281
|
+
if (loadedTools.size) data.loadedTools = [...loadedTools].sort();
|
|
162
282
|
// Write to session so state survives branch navigation within a session
|
|
163
283
|
try {
|
|
164
|
-
pi.appendEntry<ToolboxState>("toolbox-config",
|
|
165
|
-
enabledTools: [...enabledTools],
|
|
166
|
-
});
|
|
284
|
+
pi.appendEntry<ToolboxState>("toolbox-config", data);
|
|
167
285
|
} catch (err) {
|
|
168
286
|
console.warn("toolbox: persist failed:", err);
|
|
169
287
|
}
|
|
@@ -171,126 +289,103 @@ function createState(pi: ExtensionAPI) {
|
|
|
171
289
|
try {
|
|
172
290
|
const sp = getStatePath();
|
|
173
291
|
mkdirSync(dirname(sp), { recursive: true });
|
|
174
|
-
writeFileSync(sp, JSON.stringify(
|
|
292
|
+
writeFileSync(sp, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
|
|
175
293
|
} catch (err) {
|
|
176
294
|
console.warn("toolbox: file persist failed:", err);
|
|
177
295
|
}
|
|
178
296
|
}
|
|
179
297
|
|
|
180
|
-
/**
|
|
181
|
-
function loadFromFile():
|
|
298
|
+
/** Raw persisted state from disk, or undefined when absent/corrupt. */
|
|
299
|
+
function loadFromFile(): { raw: unknown; legacy: boolean } | undefined {
|
|
182
300
|
try {
|
|
183
301
|
const sp = getStatePath();
|
|
184
302
|
if (!existsSync(sp)) return undefined;
|
|
185
303
|
const raw = JSON.parse(readFileSync(sp, "utf-8")) as ToolboxState;
|
|
186
|
-
|
|
304
|
+
return { raw, legacy: !isStringArray(raw?.disabledTools) };
|
|
187
305
|
} catch {
|
|
188
|
-
//
|
|
306
|
+
// corrupt, or a test env without getAgentDir
|
|
307
|
+
return undefined;
|
|
189
308
|
}
|
|
190
|
-
return undefined;
|
|
191
309
|
}
|
|
192
310
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
// Fall back to full enable (first run).
|
|
197
|
-
const fileSaved = loadFromFile();
|
|
198
|
-
if (fileSaved) {
|
|
199
|
-
const validNames = new Set((pi.getAllTools() ?? []).map((t) => t.name));
|
|
200
|
-
enabledTools = new Set(fileSaved.filter((n) => validNames.has(n) || CORE_TOOLS.has(n)));
|
|
201
|
-
for (const ct of CORE_TOOLS) enabledTools.add(ct);
|
|
202
|
-
initialized = true;
|
|
203
|
-
apply();
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Fall back to plain init if sessionManager is unavailable (e.g. tests / headless)
|
|
208
|
-
if (!ctx?.sessionManager) {
|
|
209
|
-
ensureInit();
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
|
|
311
|
+
/** Latest toolbox-config entry in the session, or undefined. */
|
|
312
|
+
function loadFromSession(ctx: ExtensionContext): unknown {
|
|
313
|
+
if (!ctx?.sessionManager) return undefined;
|
|
213
314
|
// getEntries() returns ALL entries in the session file — unlike getBranch()
|
|
214
315
|
// which only walks ancestors. Custom entries appended via appendCustomEntry
|
|
215
316
|
// are children of the leaf, not ancestors.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
for (const entry of allEntries) {
|
|
220
|
-
if (entry.type === "custom" && entry.customType === "toolbox-config") {
|
|
221
|
-
const data = entry.data as ToolboxState | undefined;
|
|
222
|
-
if (data?.enabledTools) saved = data.enabledTools;
|
|
223
|
-
}
|
|
317
|
+
let saved: unknown;
|
|
318
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
319
|
+
if (entry.type === "custom" && entry.customType === "toolbox-config") saved = entry.data;
|
|
224
320
|
}
|
|
225
|
-
|
|
226
|
-
if (saved) {
|
|
227
|
-
const validNames = new Set((pi.getAllTools() ?? []).map((t) => t.name));
|
|
228
|
-
enabledTools = new Set(saved.filter((n) => validNames.has(n) || CORE_TOOLS.has(n)));
|
|
229
|
-
for (const ct of CORE_TOOLS) enabledTools.add(ct);
|
|
230
|
-
} else {
|
|
231
|
-
const names = (pi.getAllTools() ?? []).map((t) => t.name);
|
|
232
|
-
enabledTools = new Set(names);
|
|
233
|
-
}
|
|
234
|
-
initialized = true;
|
|
235
|
-
apply();
|
|
236
|
-
// Persist to disk on first init so future sessions pick it up
|
|
237
|
-
persist();
|
|
321
|
+
return saved;
|
|
238
322
|
}
|
|
239
323
|
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
324
|
+
function restoreFromBranch(ctx: ExtensionContext): void {
|
|
325
|
+
// Prefer file-based persistence (survives across sessions), then session
|
|
326
|
+
// entries (survive branch navigation), then nothing disabled (first run).
|
|
327
|
+
const names = allNames();
|
|
328
|
+
const file = loadFromFile();
|
|
329
|
+
const fromFile = file ? disabledFromState(file.raw, names) : undefined;
|
|
330
|
+
const fromSession = fromFile ? undefined : loadFromSession(ctx);
|
|
331
|
+
const disabled = fromFile ?? disabledFromState(fromSession, names) ?? [];
|
|
332
|
+
disabledTools = new Set(disabled);
|
|
333
|
+
loadedTools = new Set(loadedFromState(fromFile ? file?.raw : fromSession, names));
|
|
250
334
|
initialized = true;
|
|
251
335
|
apply();
|
|
252
|
-
|
|
336
|
+
// Migrate a legacy allow-list file to the disabled-list form.
|
|
337
|
+
if (file?.legacy && fromFile) persist();
|
|
253
338
|
}
|
|
254
339
|
|
|
255
|
-
|
|
340
|
+
/** Rebuild the active set. `drop` removes one tool that apply() would otherwise keep. */
|
|
341
|
+
function apply(drop?: string): void {
|
|
256
342
|
if (!initialized) return;
|
|
257
343
|
try {
|
|
258
|
-
pi.
|
|
344
|
+
const tools = pi.getAllTools();
|
|
345
|
+
const active = new Set(pi.getActiveTools());
|
|
346
|
+
if (drop) active.delete(drop);
|
|
347
|
+
pi.setActiveTools(
|
|
348
|
+
tools
|
|
349
|
+
.filter(
|
|
350
|
+
(tool) =>
|
|
351
|
+
!disabledTools.has(tool.name) &&
|
|
352
|
+
(["direct", "model-only"].includes(exposureOf(tool)) ||
|
|
353
|
+
loadedTools.has(tool.name) ||
|
|
354
|
+
active.has(tool.name)),
|
|
355
|
+
)
|
|
356
|
+
.map((tool) => tool.name),
|
|
357
|
+
);
|
|
259
358
|
} catch (err) {
|
|
260
359
|
console.warn("toolbox: setActiveTools failed:", err);
|
|
261
360
|
}
|
|
262
361
|
}
|
|
263
362
|
|
|
264
|
-
function
|
|
265
|
-
|
|
363
|
+
function stateOf(name: string): ToolState {
|
|
364
|
+
if (disabledTools.has(name)) return "disabled";
|
|
365
|
+
if (pi.getActiveTools().includes(name)) return "enabled";
|
|
366
|
+
return isDeferred(name) ? "deferred" : "disabled";
|
|
266
367
|
}
|
|
267
368
|
|
|
268
|
-
function
|
|
269
|
-
if (!initialized) return false;
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
369
|
+
function setState(name: string, next: ToolState): boolean {
|
|
370
|
+
if (!initialized || stateOf(name) === next) return false;
|
|
371
|
+
if (CORE_TOOLS.has(name) && next !== "enabled") return false;
|
|
372
|
+
if (next === "deferred" && !isDeferred(name)) return false;
|
|
373
|
+
if (next === "disabled") disabledTools.add(name);
|
|
374
|
+
else disabledTools.delete(name);
|
|
375
|
+
if (next === "enabled" && isDeferred(name)) loadedTools.add(name);
|
|
376
|
+
else loadedTools.delete(name);
|
|
377
|
+
apply(next === "enabled" ? undefined : name);
|
|
273
378
|
persist();
|
|
274
379
|
return true;
|
|
275
380
|
}
|
|
276
381
|
|
|
277
|
-
function onDeactivate(name: string): boolean {
|
|
278
|
-
if (!initialized) return false;
|
|
279
|
-
if (CORE_TOOLS.has(name)) return false;
|
|
280
|
-
const did = enabledTools.delete(name);
|
|
281
|
-
if (did) {
|
|
282
|
-
apply();
|
|
283
|
-
persist();
|
|
284
|
-
}
|
|
285
|
-
return did;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
382
|
return {
|
|
289
|
-
ensureInit,
|
|
290
383
|
restoreFromBranch,
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
384
|
+
stateOf,
|
|
385
|
+
setState,
|
|
386
|
+
isDisabled: (name: string) => initialized && disabledTools.has(name),
|
|
387
|
+
/** tool_search activates its matches. Re-apply so a disabled match drops out. */
|
|
388
|
+
reapply: () => apply(),
|
|
294
389
|
};
|
|
295
390
|
}
|
|
296
391
|
|
|
@@ -310,19 +405,27 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
310
405
|
state.restoreFromBranch(ctx);
|
|
311
406
|
});
|
|
312
407
|
|
|
408
|
+
// Disabled means unreachable: block direct and codemode calls, and undo a tool_search load.
|
|
409
|
+
pi.on("tool_call", async (event) => {
|
|
410
|
+
if (!state.isDisabled(event.toolName)) return;
|
|
411
|
+
return {
|
|
412
|
+
block: true,
|
|
413
|
+
reason: `${event.toolName} is disabled in /toolbox. Ask the user to enable it.`,
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
pi.on("tool_execution_end", async (event) => {
|
|
417
|
+
if (event.toolName === "tool_search") state.reapply();
|
|
418
|
+
});
|
|
419
|
+
|
|
313
420
|
function getRows(): ToolRow[] {
|
|
314
421
|
try {
|
|
315
|
-
return buildRows(pi.getAllTools() ?? []);
|
|
422
|
+
return buildRows(pi.getAllTools() ?? [], state.stateOf);
|
|
316
423
|
} catch {
|
|
317
424
|
return [];
|
|
318
425
|
}
|
|
319
426
|
}
|
|
320
427
|
|
|
321
|
-
const ops: ToggleOps = {
|
|
322
|
-
isActive: state.isActive,
|
|
323
|
-
onActivate: state.onActivate,
|
|
324
|
-
onDeactivate: state.onDeactivate,
|
|
325
|
-
};
|
|
428
|
+
const ops: ToggleOps = { stateOf: state.stateOf, setState: state.setState };
|
|
326
429
|
|
|
327
430
|
async function showPicker(ctx: {
|
|
328
431
|
ui: {
|
|
@@ -348,20 +451,23 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
348
451
|
theme.fg("text", key) + theme.fg("muted", ` ${action}`);
|
|
349
452
|
const guideSep = theme.fg("muted", " · ");
|
|
350
453
|
|
|
351
|
-
|
|
352
|
-
const
|
|
454
|
+
// Marker + color per state. The text tag keeps state readable without color.
|
|
455
|
+
const LOOK: Record<ToolState, { mark: string; color: "success" | "accent" | "warning" }> = {
|
|
456
|
+
enabled: { mark: "✓", color: "success" },
|
|
457
|
+
deferred: { mark: "~", color: "accent" },
|
|
458
|
+
disabled: { mark: "#", color: "warning" },
|
|
459
|
+
};
|
|
353
460
|
|
|
354
461
|
const labelFor = (r: ToolRow): string => {
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
const name =
|
|
358
|
-
|
|
359
|
-
return `${marker} ${name} ${kind}`;
|
|
462
|
+
const s = ops.stateOf(r.name);
|
|
463
|
+
const look = LOOK[s];
|
|
464
|
+
const name = s === "enabled" ? theme.fg("success", r.name) : theme.fg("muted", r.name);
|
|
465
|
+
return `${theme.fg(look.color, look.mark)} ${name}`;
|
|
360
466
|
};
|
|
361
467
|
|
|
362
468
|
const descFor = (r: ToolRow): string => {
|
|
363
|
-
const
|
|
364
|
-
const tag =
|
|
469
|
+
const s = ops.stateOf(r.name);
|
|
470
|
+
const tag = theme.fg(LOOK[s].color, s.padEnd(8));
|
|
365
471
|
return `${tag} ${mute("·")} ${r.description || "(no description)"}`;
|
|
366
472
|
};
|
|
367
473
|
|
|
@@ -402,6 +508,16 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
402
508
|
selectedIndex: number;
|
|
403
509
|
};
|
|
404
510
|
|
|
511
|
+
const TABS = ["Tools", "MCP"] as const;
|
|
512
|
+
let tab: (typeof TABS)[number] = "Tools";
|
|
513
|
+
const inTab = (it: SelectItem) => (byValue.get(it.value)?.mcp === true) === (tab === "MCP");
|
|
514
|
+
const tabBar = () =>
|
|
515
|
+
TABS.map((name) => {
|
|
516
|
+
const n = rows.filter((r) => r.mcp === (name === "MCP")).length;
|
|
517
|
+
const label = ` ${name} (${n}) `;
|
|
518
|
+
return name === tab ? theme.fg(accent, theme.bold(label)) : mute(label);
|
|
519
|
+
}).join(mute("│"));
|
|
520
|
+
|
|
405
521
|
const search = new Input();
|
|
406
522
|
let statusText = "";
|
|
407
523
|
const pager = new ModalPager();
|
|
@@ -417,34 +533,37 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
417
533
|
tui.requestRender?.();
|
|
418
534
|
};
|
|
419
535
|
|
|
420
|
-
const
|
|
536
|
+
const setSelected = (next: ToolState | "cycle") => {
|
|
421
537
|
const sel = list.getSelectedItem();
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
538
|
+
const row = sel && byValue.get(sel.value);
|
|
539
|
+
if (!row) return;
|
|
540
|
+
const target = next === "cycle" ? nextState(row, ops.stateOf(row.name)) : next;
|
|
541
|
+
statusText = theme.fg("dim", toggleTool(target, row.name, rows, ops));
|
|
425
542
|
refreshLabels();
|
|
426
543
|
};
|
|
427
544
|
|
|
428
|
-
const flipSelected = () => {
|
|
429
|
-
const sel = list.getSelectedItem();
|
|
430
|
-
if (!sel) return;
|
|
431
|
-
if (stateOf(sel.value) === "active") doToggle("disable");
|
|
432
|
-
else doToggle("enable");
|
|
433
|
-
};
|
|
434
|
-
|
|
435
545
|
const applyFilter = (q: string) => {
|
|
436
546
|
const query = q.trim();
|
|
547
|
+
const items = internal.items.filter(inTab);
|
|
437
548
|
internal.filteredItems =
|
|
438
549
|
query.length === 0
|
|
439
|
-
?
|
|
550
|
+
? items
|
|
440
551
|
: fuzzyFilter(
|
|
441
|
-
|
|
552
|
+
items,
|
|
442
553
|
query,
|
|
443
554
|
(it: SelectItem) => `${it.value} ${it.description ?? ""}`,
|
|
444
555
|
);
|
|
445
556
|
internal.selectedIndex = 0;
|
|
446
557
|
list.invalidate();
|
|
447
558
|
};
|
|
559
|
+
applyFilter("");
|
|
560
|
+
|
|
561
|
+
const switchTab = (direction: -1 | 1) => {
|
|
562
|
+
tab = TABS[(TABS.indexOf(tab) + direction + TABS.length) % TABS.length] ?? "Tools";
|
|
563
|
+
statusText = "";
|
|
564
|
+
applyFilter(search.getValue?.() ?? "");
|
|
565
|
+
pager.followSelection();
|
|
566
|
+
};
|
|
448
567
|
|
|
449
568
|
list.onSelect = () => done(null);
|
|
450
569
|
list.onCancel = () => done(null);
|
|
@@ -455,16 +574,23 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
455
574
|
const mw = modalWidth(w);
|
|
456
575
|
const inner = mw - 4; // CHROME = 2 border + 2 padding
|
|
457
576
|
const footer = statusText ? ["", statusText] : [""];
|
|
577
|
+
// maxVisible = all items, so list line i is filteredItems[i].
|
|
578
|
+
const body = list.render(inner);
|
|
579
|
+
const selLine = internal.selectedIndex;
|
|
458
580
|
footer.push(
|
|
459
|
-
guide("
|
|
581
|
+
guide("tab", "switch tab") +
|
|
582
|
+
guideSep +
|
|
583
|
+
guide("↑↓", "navigate") +
|
|
460
584
|
guideSep +
|
|
461
585
|
guide("←→/PgUp/PgDn", "inspect") +
|
|
462
586
|
guideSep +
|
|
463
|
-
guide("
|
|
587
|
+
guide("^E", "enable") +
|
|
588
|
+
guideSep +
|
|
589
|
+
guide("^F", "defer") +
|
|
464
590
|
guideSep +
|
|
465
|
-
guide("
|
|
591
|
+
guide("^D", "disable") +
|
|
466
592
|
guideSep +
|
|
467
|
-
guide("space", "
|
|
593
|
+
guide("space", "cycle") +
|
|
468
594
|
guideSep +
|
|
469
595
|
guide("esc", "close"),
|
|
470
596
|
);
|
|
@@ -474,15 +600,14 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
474
600
|
minHeight: MIN_MODAL_HEIGHT,
|
|
475
601
|
header: [
|
|
476
602
|
theme.fg(accent, theme.bold("🧰 Toolbox")),
|
|
603
|
+
tabBar(),
|
|
604
|
+
"",
|
|
477
605
|
theme.fg("dim", "Search:"),
|
|
478
606
|
...search.render(inner),
|
|
479
607
|
"",
|
|
480
608
|
],
|
|
481
|
-
body
|
|
482
|
-
selectedBodyRange: pager.selectedRange({
|
|
483
|
-
start: internal.selectedIndex,
|
|
484
|
-
end: internal.selectedIndex + 1,
|
|
485
|
-
}),
|
|
609
|
+
body,
|
|
610
|
+
selectedBodyRange: pager.selectedRange({ start: selLine, end: selLine + 1 }),
|
|
486
611
|
footer,
|
|
487
612
|
bodyOffset: pager.bodyOffset,
|
|
488
613
|
color: (s) => theme.fg(accent, s),
|
|
@@ -507,25 +632,23 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
507
632
|
} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
|
|
508
633
|
done(null);
|
|
509
634
|
return;
|
|
510
|
-
} else if (matchesKey(data, Key.
|
|
511
|
-
|
|
635
|
+
} else if (matchesKey(data, Key.shift(Key.tab))) {
|
|
636
|
+
switchTab(-1);
|
|
637
|
+
} else if (matchesKey(data, Key.tab)) {
|
|
638
|
+
switchTab(1);
|
|
639
|
+
} else if (matchesKey(data, Key.space)) {
|
|
640
|
+
setSelected("cycle");
|
|
641
|
+
} else if (matchesKey(data, Key.ctrl("e"))) {
|
|
642
|
+
setSelected("enabled");
|
|
643
|
+
} else if (matchesKey(data, Key.ctrl("f"))) {
|
|
644
|
+
setSelected("deferred");
|
|
645
|
+
} else if (matchesKey(data, Key.ctrl("d"))) {
|
|
646
|
+
setSelected("disabled");
|
|
512
647
|
} else {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
} else if (printable === "d") {
|
|
518
|
-
doToggle("disable");
|
|
519
|
-
} else {
|
|
520
|
-
search.handleInput?.(data);
|
|
521
|
-
applyFilter(search.getValue?.() ?? "");
|
|
522
|
-
pager.followSelection();
|
|
523
|
-
}
|
|
524
|
-
} else {
|
|
525
|
-
search.handleInput?.(data);
|
|
526
|
-
applyFilter(search.getValue?.() ?? "");
|
|
527
|
-
pager.followSelection();
|
|
528
|
-
}
|
|
648
|
+
// Every other key goes to the search. Ctrl hotkeys never collide with typed text.
|
|
649
|
+
search.handleInput?.(data);
|
|
650
|
+
applyFilter(search.getValue?.() ?? "");
|
|
651
|
+
pager.followSelection();
|
|
529
652
|
}
|
|
530
653
|
list.invalidate();
|
|
531
654
|
tui.requestRender?.();
|
|
@@ -538,13 +661,20 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
538
661
|
|
|
539
662
|
pi.registerCommand("toolbox", {
|
|
540
663
|
description:
|
|
541
|
-
"
|
|
542
|
-
"
|
|
664
|
+
"Set each tool to enabled, deferred or disabled. tab Tools/MCP, ↑↓ navigate, " +
|
|
665
|
+
"ctrl+e/f/d enable/defer/disable, space cycle. " +
|
|
666
|
+
"Headless: /toolbox enable|defer|disable <names>, /toolbox list [query]",
|
|
543
667
|
handler: async (args, ctx) => {
|
|
544
668
|
const raw = (args ?? "").trim();
|
|
545
669
|
const verb = raw.split(/\s+/, 1)[0]?.toLowerCase();
|
|
546
670
|
|
|
547
|
-
|
|
671
|
+
const VERBS: Record<string, ToolState> = {
|
|
672
|
+
enable: "enabled",
|
|
673
|
+
defer: "deferred",
|
|
674
|
+
disable: "disabled",
|
|
675
|
+
};
|
|
676
|
+
const target = verb ? VERBS[verb] : undefined;
|
|
677
|
+
if (verb && target) {
|
|
548
678
|
const targets = parseTargets(raw.slice(verb.length).trim());
|
|
549
679
|
if (!targets.length) {
|
|
550
680
|
ctx.ui.notify(
|
|
@@ -554,14 +684,14 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
554
684
|
return;
|
|
555
685
|
}
|
|
556
686
|
const rows = getRows();
|
|
557
|
-
const msg = targets.map((t) => toggleTool(
|
|
687
|
+
const msg = targets.map((t) => toggleTool(target, t, rows, ops)).join("\n");
|
|
558
688
|
ctx.ui.notify(msg, "info");
|
|
559
689
|
return;
|
|
560
690
|
}
|
|
561
691
|
|
|
562
692
|
if (verb === "list") {
|
|
563
693
|
const query = raw.slice(verb.length).trim() || undefined;
|
|
564
|
-
ctx.ui.notify(renderList(getRows(), ops.
|
|
694
|
+
ctx.ui.notify(renderList(getRows(), ops.stateOf, query), "info");
|
|
565
695
|
return;
|
|
566
696
|
}
|
|
567
697
|
|
|
@@ -569,7 +699,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
569
699
|
// SAFETY: The runtime command context satisfies showPicker's narrowed UI contract.
|
|
570
700
|
await showPicker(ctx as unknown as Parameters<typeof showPicker>[0]);
|
|
571
701
|
} else {
|
|
572
|
-
ctx.ui.notify(renderList(getRows(), ops.
|
|
702
|
+
ctx.ui.notify(renderList(getRows(), ops.stateOf), "info");
|
|
573
703
|
}
|
|
574
704
|
},
|
|
575
705
|
});
|