@xynogen/pix-toolbox 0.1.16 → 0.2.1
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 +4 -3
- package/src/index.ts +1 -1
- package/src/toolbox.ts +324 -192
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/pix.json` under `toolbox`. 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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-toolbox",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Pi tool
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Pi tool \u2014 gated tool toggle UI (/toolbox)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@xynogen/pix-pretty": "^1.
|
|
38
|
+
"@xynogen/pix-pretty": "^1.30.0",
|
|
39
|
+
"@xynogen/pix-runtime": "^0.13.0"
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"@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,28 +2,24 @@
|
|
|
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
|
|
|
16
|
-
import {
|
|
17
|
-
import { dirname, join } from "node:path";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
18
16
|
import type {
|
|
19
17
|
ExtensionAPI,
|
|
20
18
|
ExtensionContext,
|
|
21
19
|
Theme,
|
|
22
20
|
ToolInfo,
|
|
23
21
|
} from "@earendil-works/pi-coding-agent";
|
|
24
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
25
22
|
import {
|
|
26
|
-
decodeKittyPrintable,
|
|
27
23
|
fuzzyFilter,
|
|
28
24
|
Input,
|
|
29
25
|
Key,
|
|
@@ -42,6 +38,8 @@ import {
|
|
|
42
38
|
modalWidth,
|
|
43
39
|
terminalModalHeight,
|
|
44
40
|
} from "@xynogen/pix-pretty/modal-frame";
|
|
41
|
+
import { config, pixRuntime, updateConfig } from "@xynogen/pix-runtime/config";
|
|
42
|
+
import { toolboxSection } from "@xynogen/pix-runtime/sections";
|
|
45
43
|
|
|
46
44
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
47
45
|
|
|
@@ -55,13 +53,26 @@ export interface ToolRow {
|
|
|
55
53
|
description: string;
|
|
56
54
|
mcp: boolean;
|
|
57
55
|
source?: string;
|
|
56
|
+
exposure?: string;
|
|
58
57
|
}
|
|
59
58
|
|
|
59
|
+
export type ToolState = "enabled" | "deferred" | "disabled";
|
|
60
|
+
export const TOOL_STATES: readonly ToolState[] = ["enabled", "deferred", "disabled"];
|
|
61
|
+
|
|
60
62
|
/** Callbacks for toggleTool / renderList — test seam. */
|
|
61
63
|
export interface ToggleOps {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
stateOf: (name: string) => ToolState;
|
|
65
|
+
/** Returns false when the tool is already in that state. */
|
|
66
|
+
setState: (name: string, state: ToolState) => boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Only tools registered with `deferred` exposure can be found by tool_search. */
|
|
70
|
+
export const canDefer = (row: ToolRow): boolean => row.exposure === "deferred";
|
|
71
|
+
|
|
72
|
+
/** Next state for the space key. Skips `deferred` when the tool cannot be deferred. */
|
|
73
|
+
export function nextState(row: ToolRow, current: ToolState): ToolState {
|
|
74
|
+
const states = TOOL_STATES.filter((s) => s !== "deferred" || canDefer(row));
|
|
75
|
+
return states[(states.indexOf(current) + 1) % states.length] ?? "enabled";
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
@@ -70,16 +81,25 @@ function isMcpTool(info: ToolInfo): boolean {
|
|
|
70
81
|
return /mcp/i.test(info.sourceInfo?.source ?? "");
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Normal tools first, MCP tools last. Inside a group: enabled, deferred, disabled,
|
|
86
|
+
* then by name. Without `stateOf`, the registered exposure stands in for the state.
|
|
87
|
+
*/
|
|
88
|
+
export function buildRows(tools: ToolInfo[], stateOf?: (name: string) => ToolState): ToolRow[] {
|
|
89
|
+
const rows = tools
|
|
75
90
|
.filter((t) => !CORE_TOOLS.has(t.name))
|
|
76
91
|
.map((t) => ({
|
|
77
92
|
name: t.name,
|
|
78
93
|
description: firstSentence(t.description ?? ""),
|
|
79
94
|
mcp: isMcpTool(t),
|
|
80
95
|
source: t.sourceInfo?.source,
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
exposure: t.exposure ?? "direct",
|
|
97
|
+
}));
|
|
98
|
+
const rank = (r: ToolRow): number =>
|
|
99
|
+
TOOL_STATES.indexOf(stateOf?.(r.name) ?? (r.exposure === "deferred" ? "deferred" : "enabled"));
|
|
100
|
+
return rows.sort(
|
|
101
|
+
(a, b) => Number(a.mcp) - Number(b.mcp) || rank(a) - rank(b) || a.name.localeCompare(b.name),
|
|
102
|
+
);
|
|
83
103
|
}
|
|
84
104
|
|
|
85
105
|
const firstSentence = (desc: string): string => {
|
|
@@ -100,9 +120,15 @@ export function parseTargets(raw: string): string[] {
|
|
|
100
120
|
return out;
|
|
101
121
|
}
|
|
102
122
|
|
|
123
|
+
const STATUS: Record<ToolState, string> = {
|
|
124
|
+
enabled: "✓ enabled",
|
|
125
|
+
deferred: "~ deferred",
|
|
126
|
+
disabled: "# disabled",
|
|
127
|
+
};
|
|
128
|
+
|
|
103
129
|
export function renderList(
|
|
104
130
|
rows: ToolRow[],
|
|
105
|
-
|
|
131
|
+
stateOf: (name: string) => ToolState,
|
|
106
132
|
query?: string,
|
|
107
133
|
): string {
|
|
108
134
|
const filtered = query
|
|
@@ -117,180 +143,250 @@ export function renderList(
|
|
|
117
143
|
return query ? `No tools matched "${query}".` : "No tools registered.";
|
|
118
144
|
}
|
|
119
145
|
|
|
120
|
-
const lines =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
146
|
+
const lines: string[] = [];
|
|
147
|
+
let group = "";
|
|
148
|
+
for (const row of filtered) {
|
|
149
|
+
const nextGroup = row.mcp ? "MCP" : "Tools";
|
|
150
|
+
if (group !== nextGroup) {
|
|
151
|
+
group = nextGroup;
|
|
152
|
+
lines.push(`${lines.length ? "\n" : ""}${group}:`);
|
|
153
|
+
}
|
|
154
|
+
const kind = row.mcp ? "MCP" : "tool";
|
|
155
|
+
lines.push(`${STATUS[stateOf(row.name)]} ${row.name} [${kind}] ${row.description}`);
|
|
156
|
+
}
|
|
125
157
|
return lines.join("\n");
|
|
126
158
|
}
|
|
127
159
|
|
|
160
|
+
const DONE: Record<ToolState, string> = {
|
|
161
|
+
enabled: "now in the prompt",
|
|
162
|
+
deferred: "tool_search loads it on demand",
|
|
163
|
+
disabled: "blocked",
|
|
164
|
+
};
|
|
165
|
+
|
|
128
166
|
export function toggleTool(
|
|
129
|
-
|
|
167
|
+
state: ToolState,
|
|
130
168
|
name: string,
|
|
131
169
|
rows: ToolRow[],
|
|
132
170
|
ops: ToggleOps,
|
|
133
171
|
): string {
|
|
134
172
|
const row = rows.find((r) => r.name === name);
|
|
135
173
|
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.`;
|
|
174
|
+
if (CORE_TOOLS.has(name) && state !== "enabled")
|
|
175
|
+
return `${name} is a core tool. It stays enabled.`;
|
|
176
|
+
if (state === "deferred" && !canDefer(row))
|
|
177
|
+
return `${name} has direct exposure. tool_search cannot find it, so it cannot be deferred.`;
|
|
178
|
+
if (!ops.setState(name, state)) return `${name} is already ${state}.`;
|
|
179
|
+
return `${name} ${state} — ${DONE[state]}.`;
|
|
143
180
|
}
|
|
144
181
|
|
|
145
182
|
// ─── Persistence ───────────────────────────────────────────────────────────
|
|
146
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Persisted gate state. Only changes from the default are saved, so a newly
|
|
186
|
+
* installed tool keeps its default (direct → enabled, deferred → deferred).
|
|
187
|
+
* `disabledTools` holds tools the user disabled. `loadedTools` holds deferred
|
|
188
|
+
* tools the user enabled, so they are declared on every session start.
|
|
189
|
+
* `enabledTools` is the legacy form (an allow-list that hid every tool
|
|
190
|
+
* installed later); it is read once and migrated on the next write.
|
|
191
|
+
*/
|
|
147
192
|
interface ToolboxState {
|
|
148
|
-
|
|
193
|
+
disabledTools?: string[];
|
|
194
|
+
loadedTools?: string[];
|
|
195
|
+
enabledTools?: string[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const isStringArray = (v: unknown): v is string[] =>
|
|
199
|
+
Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* MCP codemode tools are named as JS identifiers now (`mcp__my-srv__a-b` → `mcp__my_srv__a_b`).
|
|
203
|
+
* Map saved names to that form so a disabled tool stays disabled. When `known` lacks that form,
|
|
204
|
+
* the tool collided or passed 64 chars and carries a hash suffix. Rebuild it from the old name.
|
|
205
|
+
* Keep in sync with pix-mcp `codemodeToolName` (packages cannot import each other).
|
|
206
|
+
*/
|
|
207
|
+
export function migrateToolName(name: string, known?: ReadonlySet<string>): string {
|
|
208
|
+
if (!name.startsWith("mcp__")) return name;
|
|
209
|
+
const plain = name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
210
|
+
if (!known || known.has(plain)) return plain;
|
|
211
|
+
// Server and tool names can both hold `__`, so try every split. Only a known name matches.
|
|
212
|
+
for (let split = name.indexOf("__", 5); split >= 0; split = name.indexOf("__", split + 1)) {
|
|
213
|
+
const key = `${name.slice(5, split)}\0${name.slice(split + 2)}`;
|
|
214
|
+
const hash = createHash("sha256").update(key).digest("hex").slice(0, 8);
|
|
215
|
+
const hashed = `${plain.slice(0, 64 - hash.length - 1)}_${hash}`;
|
|
216
|
+
if (known.has(hashed)) return hashed;
|
|
217
|
+
}
|
|
218
|
+
return plain;
|
|
149
219
|
}
|
|
150
220
|
|
|
151
|
-
|
|
152
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Disabled tool names from a saved state, or undefined when it holds none.
|
|
223
|
+
* A legacy allow-list maps to "every known non-core tool not in it".
|
|
224
|
+
*/
|
|
225
|
+
export function disabledFromState(raw: unknown, allNames: string[]): string[] | undefined {
|
|
226
|
+
const state = raw as ToolboxState | undefined;
|
|
227
|
+
if (isStringArray(state?.disabledTools)) {
|
|
228
|
+
const known = new Set(allNames);
|
|
229
|
+
return state.disabledTools
|
|
230
|
+
.map((n) => migrateToolName(n, known))
|
|
231
|
+
.filter((n) => !CORE_TOOLS.has(n));
|
|
232
|
+
}
|
|
233
|
+
if (isStringArray(state?.enabledTools)) {
|
|
234
|
+
const enabled = new Set(state.enabledTools);
|
|
235
|
+
return allNames.filter((n) => !enabled.has(n) && !CORE_TOOLS.has(n));
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
153
238
|
}
|
|
154
239
|
|
|
240
|
+
/** Deferred tool names the user chose to load on every session start. */
|
|
241
|
+
export function loadedFromState(raw: unknown, allNames: string[] = []): string[] {
|
|
242
|
+
const loaded = (raw as ToolboxState | undefined)?.loadedTools;
|
|
243
|
+
const known = new Set(allNames);
|
|
244
|
+
return isStringArray(loaded) ? loaded.map((n) => migrateToolName(n, known)) : [];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const exposureOf = (tool: ToolInfo): string => tool.exposure ?? "direct";
|
|
248
|
+
|
|
155
249
|
// ─── State ──────────────────────────────────────────────────────────────────
|
|
156
250
|
|
|
157
251
|
function createState(pi: ExtensionAPI) {
|
|
158
|
-
let
|
|
252
|
+
let disabledTools = new Set<string>();
|
|
253
|
+
let loadedTools = new Set<string>();
|
|
159
254
|
let initialized = false;
|
|
160
255
|
|
|
161
|
-
function
|
|
162
|
-
// Write to session so state survives branch navigation within a session
|
|
256
|
+
function isDeferred(name: string): boolean {
|
|
163
257
|
try {
|
|
164
|
-
pi.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
console.warn("toolbox: persist failed:", err);
|
|
258
|
+
const tool = pi.getAllTools().find((t) => t.name === name);
|
|
259
|
+
return tool ? exposureOf(tool) === "deferred" : false;
|
|
260
|
+
} catch {
|
|
261
|
+
return false;
|
|
169
262
|
}
|
|
170
|
-
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function allNames(): string[] {
|
|
171
266
|
try {
|
|
172
|
-
|
|
173
|
-
mkdirSync(dirname(sp), { recursive: true });
|
|
174
|
-
writeFileSync(sp, JSON.stringify({ enabledTools: [...enabledTools] }, null, 2), "utf-8");
|
|
267
|
+
return (pi.getAllTools() ?? []).map((t) => t.name);
|
|
175
268
|
} catch (err) {
|
|
176
|
-
console.warn("toolbox:
|
|
269
|
+
console.warn("toolbox: getAllTools failed:", err);
|
|
270
|
+
return [];
|
|
177
271
|
}
|
|
178
272
|
}
|
|
179
273
|
|
|
180
|
-
|
|
181
|
-
|
|
274
|
+
function persist(): void {
|
|
275
|
+
const data: ToolboxState = { disabledTools: [...disabledTools].sort() };
|
|
276
|
+
if (loadedTools.size) data.loadedTools = [...loadedTools].sort();
|
|
277
|
+
// Write to session so state survives branch navigation within a session
|
|
182
278
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (Array.isArray(raw?.enabledTools)) return raw.enabledTools;
|
|
187
|
-
} catch {
|
|
188
|
-
// File doesn't exist, is corrupt, or we're in a test env without getAgentDir
|
|
279
|
+
pi.appendEntry<ToolboxState>("toolbox-config", data);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
console.warn("toolbox: persist failed:", err);
|
|
189
282
|
}
|
|
190
|
-
|
|
283
|
+
// Write to disk so state survives across completely new sessions
|
|
284
|
+
void updateConfig(toolboxSection, { ...data, loadedTools: data.loadedTools ?? [] })
|
|
285
|
+
.then((change) => {
|
|
286
|
+
if (
|
|
287
|
+
!change &&
|
|
288
|
+
JSON.stringify(config(toolboxSection).disabledTools) !==
|
|
289
|
+
JSON.stringify(data.disabledTools)
|
|
290
|
+
)
|
|
291
|
+
pi.sendMessage({
|
|
292
|
+
customType: "toolbox-error",
|
|
293
|
+
content: "Failed to save toolbox settings to pix.json",
|
|
294
|
+
display: true,
|
|
295
|
+
});
|
|
296
|
+
})
|
|
297
|
+
.catch((err) =>
|
|
298
|
+
pi.sendMessage({
|
|
299
|
+
customType: "toolbox-error",
|
|
300
|
+
content: `Failed to save toolbox settings: ${String(err)}`,
|
|
301
|
+
display: true,
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
191
304
|
}
|
|
192
305
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
}
|
|
306
|
+
/** Raw persisted state from disk, or undefined when absent/corrupt. */
|
|
307
|
+
function loadFromFile(): { raw: unknown; legacy: boolean } | undefined {
|
|
308
|
+
const raw = config(toolboxSection);
|
|
309
|
+
return Object.keys(raw).length ? { raw, legacy: !isStringArray(raw.disabledTools) } : undefined;
|
|
310
|
+
}
|
|
212
311
|
|
|
312
|
+
/** Latest toolbox-config entry in the session, or undefined. */
|
|
313
|
+
function loadFromSession(ctx: ExtensionContext): unknown {
|
|
314
|
+
if (!ctx?.sessionManager) return undefined;
|
|
213
315
|
// getEntries() returns ALL entries in the session file — unlike getBranch()
|
|
214
316
|
// which only walks ancestors. Custom entries appended via appendCustomEntry
|
|
215
317
|
// 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
|
-
}
|
|
318
|
+
let saved: unknown;
|
|
319
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
320
|
+
if (entry.type === "custom" && entry.customType === "toolbox-config") saved = entry.data;
|
|
224
321
|
}
|
|
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();
|
|
322
|
+
return saved;
|
|
238
323
|
}
|
|
239
324
|
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
325
|
+
function restoreFromBranch(ctx: ExtensionContext): void {
|
|
326
|
+
// Prefer file-based persistence (survives across sessions), then session
|
|
327
|
+
// entries (survive branch navigation), then nothing disabled (first run).
|
|
328
|
+
const names = allNames();
|
|
329
|
+
const file = loadFromFile();
|
|
330
|
+
const fromFile = file ? disabledFromState(file.raw, names) : undefined;
|
|
331
|
+
const fromSession = fromFile ? undefined : loadFromSession(ctx);
|
|
332
|
+
const disabled = fromFile ?? disabledFromState(fromSession, names) ?? [];
|
|
333
|
+
disabledTools = new Set(disabled);
|
|
334
|
+
loadedTools = new Set(loadedFromState(fromFile ? file?.raw : fromSession, names));
|
|
250
335
|
initialized = true;
|
|
251
336
|
apply();
|
|
252
|
-
|
|
337
|
+
// Migrate a legacy allow-list file to the disabled-list form.
|
|
338
|
+
if (file?.legacy && fromFile) persist();
|
|
253
339
|
}
|
|
254
340
|
|
|
255
|
-
|
|
341
|
+
/** Rebuild the active set. `drop` removes one tool that apply() would otherwise keep. */
|
|
342
|
+
function apply(drop?: string): void {
|
|
256
343
|
if (!initialized) return;
|
|
257
344
|
try {
|
|
258
|
-
pi.
|
|
345
|
+
const tools = pi.getAllTools();
|
|
346
|
+
const active = new Set(pi.getActiveTools());
|
|
347
|
+
if (drop) active.delete(drop);
|
|
348
|
+
pi.setActiveTools(
|
|
349
|
+
tools
|
|
350
|
+
.filter(
|
|
351
|
+
(tool) =>
|
|
352
|
+
!disabledTools.has(tool.name) &&
|
|
353
|
+
(["direct", "model-only"].includes(exposureOf(tool)) ||
|
|
354
|
+
loadedTools.has(tool.name) ||
|
|
355
|
+
active.has(tool.name)),
|
|
356
|
+
)
|
|
357
|
+
.map((tool) => tool.name),
|
|
358
|
+
);
|
|
259
359
|
} catch (err) {
|
|
260
360
|
console.warn("toolbox: setActiveTools failed:", err);
|
|
261
361
|
}
|
|
262
362
|
}
|
|
263
363
|
|
|
264
|
-
function
|
|
265
|
-
|
|
364
|
+
function stateOf(name: string): ToolState {
|
|
365
|
+
if (disabledTools.has(name)) return "disabled";
|
|
366
|
+
if (pi.getActiveTools().includes(name)) return "enabled";
|
|
367
|
+
return isDeferred(name) ? "deferred" : "disabled";
|
|
266
368
|
}
|
|
267
369
|
|
|
268
|
-
function
|
|
269
|
-
if (!initialized) return false;
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
370
|
+
function setState(name: string, next: ToolState): boolean {
|
|
371
|
+
if (!initialized || stateOf(name) === next) return false;
|
|
372
|
+
if (CORE_TOOLS.has(name) && next !== "enabled") return false;
|
|
373
|
+
if (next === "deferred" && !isDeferred(name)) return false;
|
|
374
|
+
if (next === "disabled") disabledTools.add(name);
|
|
375
|
+
else disabledTools.delete(name);
|
|
376
|
+
if (next === "enabled" && isDeferred(name)) loadedTools.add(name);
|
|
377
|
+
else loadedTools.delete(name);
|
|
378
|
+
apply(next === "enabled" ? undefined : name);
|
|
273
379
|
persist();
|
|
274
380
|
return true;
|
|
275
381
|
}
|
|
276
382
|
|
|
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
383
|
return {
|
|
289
|
-
ensureInit,
|
|
290
384
|
restoreFromBranch,
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
385
|
+
stateOf,
|
|
386
|
+
setState,
|
|
387
|
+
isDisabled: (name: string) => initialized && disabledTools.has(name),
|
|
388
|
+
/** tool_search activates its matches. Re-apply so a disabled match drops out. */
|
|
389
|
+
reapply: () => apply(),
|
|
294
390
|
};
|
|
295
391
|
}
|
|
296
392
|
|
|
@@ -302,6 +398,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
302
398
|
// Defer init until tools are registered — session_start fires after all extensions load.
|
|
303
399
|
// Try to restore persisted state; fall back to full init if no config found.
|
|
304
400
|
pi.on("session_start", async (_event, ctx) => {
|
|
401
|
+
await pixRuntime().init();
|
|
305
402
|
state.restoreFromBranch(ctx);
|
|
306
403
|
});
|
|
307
404
|
|
|
@@ -310,19 +407,27 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
310
407
|
state.restoreFromBranch(ctx);
|
|
311
408
|
});
|
|
312
409
|
|
|
410
|
+
// Disabled means unreachable: block direct and codemode calls, and undo a tool_search load.
|
|
411
|
+
pi.on("tool_call", async (event) => {
|
|
412
|
+
if (!state.isDisabled(event.toolName)) return;
|
|
413
|
+
return {
|
|
414
|
+
block: true,
|
|
415
|
+
reason: `${event.toolName} is disabled in /toolbox. Ask the user to enable it.`,
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
pi.on("tool_execution_end", async (event) => {
|
|
419
|
+
if (event.toolName === "tool_search") state.reapply();
|
|
420
|
+
});
|
|
421
|
+
|
|
313
422
|
function getRows(): ToolRow[] {
|
|
314
423
|
try {
|
|
315
|
-
return buildRows(pi.getAllTools() ?? []);
|
|
424
|
+
return buildRows(pi.getAllTools() ?? [], state.stateOf);
|
|
316
425
|
} catch {
|
|
317
426
|
return [];
|
|
318
427
|
}
|
|
319
428
|
}
|
|
320
429
|
|
|
321
|
-
const ops: ToggleOps = {
|
|
322
|
-
isActive: state.isActive,
|
|
323
|
-
onActivate: state.onActivate,
|
|
324
|
-
onDeactivate: state.onDeactivate,
|
|
325
|
-
};
|
|
430
|
+
const ops: ToggleOps = { stateOf: state.stateOf, setState: state.setState };
|
|
326
431
|
|
|
327
432
|
async function showPicker(ctx: {
|
|
328
433
|
ui: {
|
|
@@ -348,20 +453,23 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
348
453
|
theme.fg("text", key) + theme.fg("muted", ` ${action}`);
|
|
349
454
|
const guideSep = theme.fg("muted", " · ");
|
|
350
455
|
|
|
351
|
-
|
|
352
|
-
const
|
|
456
|
+
// Marker + color per state. The text tag keeps state readable without color.
|
|
457
|
+
const LOOK: Record<ToolState, { mark: string; color: "success" | "accent" | "warning" }> = {
|
|
458
|
+
enabled: { mark: "✓", color: "success" },
|
|
459
|
+
deferred: { mark: "~", color: "accent" },
|
|
460
|
+
disabled: { mark: "#", color: "warning" },
|
|
461
|
+
};
|
|
353
462
|
|
|
354
463
|
const labelFor = (r: ToolRow): string => {
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
const name =
|
|
358
|
-
|
|
359
|
-
return `${marker} ${name} ${kind}`;
|
|
464
|
+
const s = ops.stateOf(r.name);
|
|
465
|
+
const look = LOOK[s];
|
|
466
|
+
const name = s === "enabled" ? theme.fg("success", r.name) : theme.fg("muted", r.name);
|
|
467
|
+
return `${theme.fg(look.color, look.mark)} ${name}`;
|
|
360
468
|
};
|
|
361
469
|
|
|
362
470
|
const descFor = (r: ToolRow): string => {
|
|
363
|
-
const
|
|
364
|
-
const tag =
|
|
471
|
+
const s = ops.stateOf(r.name);
|
|
472
|
+
const tag = theme.fg(LOOK[s].color, s.padEnd(8));
|
|
365
473
|
return `${tag} ${mute("·")} ${r.description || "(no description)"}`;
|
|
366
474
|
};
|
|
367
475
|
|
|
@@ -402,6 +510,16 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
402
510
|
selectedIndex: number;
|
|
403
511
|
};
|
|
404
512
|
|
|
513
|
+
const TABS = ["Tools", "MCP"] as const;
|
|
514
|
+
let tab: (typeof TABS)[number] = "Tools";
|
|
515
|
+
const inTab = (it: SelectItem) => (byValue.get(it.value)?.mcp === true) === (tab === "MCP");
|
|
516
|
+
const tabBar = () =>
|
|
517
|
+
TABS.map((name) => {
|
|
518
|
+
const n = rows.filter((r) => r.mcp === (name === "MCP")).length;
|
|
519
|
+
const label = ` ${name} (${n}) `;
|
|
520
|
+
return name === tab ? theme.fg(accent, theme.bold(label)) : mute(label);
|
|
521
|
+
}).join(mute("│"));
|
|
522
|
+
|
|
405
523
|
const search = new Input();
|
|
406
524
|
let statusText = "";
|
|
407
525
|
const pager = new ModalPager();
|
|
@@ -417,34 +535,37 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
417
535
|
tui.requestRender?.();
|
|
418
536
|
};
|
|
419
537
|
|
|
420
|
-
const
|
|
538
|
+
const setSelected = (next: ToolState | "cycle") => {
|
|
421
539
|
const sel = list.getSelectedItem();
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
540
|
+
const row = sel && byValue.get(sel.value);
|
|
541
|
+
if (!row) return;
|
|
542
|
+
const target = next === "cycle" ? nextState(row, ops.stateOf(row.name)) : next;
|
|
543
|
+
statusText = theme.fg("dim", toggleTool(target, row.name, rows, ops));
|
|
425
544
|
refreshLabels();
|
|
426
545
|
};
|
|
427
546
|
|
|
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
547
|
const applyFilter = (q: string) => {
|
|
436
548
|
const query = q.trim();
|
|
549
|
+
const items = internal.items.filter(inTab);
|
|
437
550
|
internal.filteredItems =
|
|
438
551
|
query.length === 0
|
|
439
|
-
?
|
|
552
|
+
? items
|
|
440
553
|
: fuzzyFilter(
|
|
441
|
-
|
|
554
|
+
items,
|
|
442
555
|
query,
|
|
443
556
|
(it: SelectItem) => `${it.value} ${it.description ?? ""}`,
|
|
444
557
|
);
|
|
445
558
|
internal.selectedIndex = 0;
|
|
446
559
|
list.invalidate();
|
|
447
560
|
};
|
|
561
|
+
applyFilter("");
|
|
562
|
+
|
|
563
|
+
const switchTab = (direction: -1 | 1) => {
|
|
564
|
+
tab = TABS[(TABS.indexOf(tab) + direction + TABS.length) % TABS.length] ?? "Tools";
|
|
565
|
+
statusText = "";
|
|
566
|
+
applyFilter(search.getValue?.() ?? "");
|
|
567
|
+
pager.followSelection();
|
|
568
|
+
};
|
|
448
569
|
|
|
449
570
|
list.onSelect = () => done(null);
|
|
450
571
|
list.onCancel = () => done(null);
|
|
@@ -455,16 +576,23 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
455
576
|
const mw = modalWidth(w);
|
|
456
577
|
const inner = mw - 4; // CHROME = 2 border + 2 padding
|
|
457
578
|
const footer = statusText ? ["", statusText] : [""];
|
|
579
|
+
// maxVisible = all items, so list line i is filteredItems[i].
|
|
580
|
+
const body = list.render(inner);
|
|
581
|
+
const selLine = internal.selectedIndex;
|
|
458
582
|
footer.push(
|
|
459
|
-
guide("
|
|
583
|
+
guide("tab", "switch tab") +
|
|
584
|
+
guideSep +
|
|
585
|
+
guide("↑↓", "navigate") +
|
|
460
586
|
guideSep +
|
|
461
587
|
guide("←→/PgUp/PgDn", "inspect") +
|
|
462
588
|
guideSep +
|
|
463
|
-
guide("
|
|
589
|
+
guide("^E", "enable") +
|
|
590
|
+
guideSep +
|
|
591
|
+
guide("^F", "defer") +
|
|
464
592
|
guideSep +
|
|
465
|
-
guide("
|
|
593
|
+
guide("^D", "disable") +
|
|
466
594
|
guideSep +
|
|
467
|
-
guide("space", "
|
|
595
|
+
guide("space", "cycle") +
|
|
468
596
|
guideSep +
|
|
469
597
|
guide("esc", "close"),
|
|
470
598
|
);
|
|
@@ -474,15 +602,14 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
474
602
|
minHeight: MIN_MODAL_HEIGHT,
|
|
475
603
|
header: [
|
|
476
604
|
theme.fg(accent, theme.bold("🧰 Toolbox")),
|
|
605
|
+
tabBar(),
|
|
606
|
+
"",
|
|
477
607
|
theme.fg("dim", "Search:"),
|
|
478
608
|
...search.render(inner),
|
|
479
609
|
"",
|
|
480
610
|
],
|
|
481
|
-
body
|
|
482
|
-
selectedBodyRange: pager.selectedRange({
|
|
483
|
-
start: internal.selectedIndex,
|
|
484
|
-
end: internal.selectedIndex + 1,
|
|
485
|
-
}),
|
|
611
|
+
body,
|
|
612
|
+
selectedBodyRange: pager.selectedRange({ start: selLine, end: selLine + 1 }),
|
|
486
613
|
footer,
|
|
487
614
|
bodyOffset: pager.bodyOffset,
|
|
488
615
|
color: (s) => theme.fg(accent, s),
|
|
@@ -507,25 +634,23 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
507
634
|
} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape)) {
|
|
508
635
|
done(null);
|
|
509
636
|
return;
|
|
510
|
-
} else if (matchesKey(data, Key.
|
|
511
|
-
|
|
637
|
+
} else if (matchesKey(data, Key.shift(Key.tab))) {
|
|
638
|
+
switchTab(-1);
|
|
639
|
+
} else if (matchesKey(data, Key.tab)) {
|
|
640
|
+
switchTab(1);
|
|
641
|
+
} else if (matchesKey(data, Key.space)) {
|
|
642
|
+
setSelected("cycle");
|
|
643
|
+
} else if (matchesKey(data, Key.ctrl("e"))) {
|
|
644
|
+
setSelected("enabled");
|
|
645
|
+
} else if (matchesKey(data, Key.ctrl("f"))) {
|
|
646
|
+
setSelected("deferred");
|
|
647
|
+
} else if (matchesKey(data, Key.ctrl("d"))) {
|
|
648
|
+
setSelected("disabled");
|
|
512
649
|
} 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
|
-
}
|
|
650
|
+
// Every other key goes to the search. Ctrl hotkeys never collide with typed text.
|
|
651
|
+
search.handleInput?.(data);
|
|
652
|
+
applyFilter(search.getValue?.() ?? "");
|
|
653
|
+
pager.followSelection();
|
|
529
654
|
}
|
|
530
655
|
list.invalidate();
|
|
531
656
|
tui.requestRender?.();
|
|
@@ -538,13 +663,20 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
538
663
|
|
|
539
664
|
pi.registerCommand("toolbox", {
|
|
540
665
|
description:
|
|
541
|
-
"
|
|
542
|
-
"
|
|
666
|
+
"Set each tool to enabled, deferred or disabled. tab Tools/MCP, ↑↓ navigate, " +
|
|
667
|
+
"ctrl+e/f/d enable/defer/disable, space cycle. " +
|
|
668
|
+
"Headless: /toolbox enable|defer|disable <names>, /toolbox list [query]",
|
|
543
669
|
handler: async (args, ctx) => {
|
|
544
670
|
const raw = (args ?? "").trim();
|
|
545
671
|
const verb = raw.split(/\s+/, 1)[0]?.toLowerCase();
|
|
546
672
|
|
|
547
|
-
|
|
673
|
+
const VERBS: Record<string, ToolState> = {
|
|
674
|
+
enable: "enabled",
|
|
675
|
+
defer: "deferred",
|
|
676
|
+
disable: "disabled",
|
|
677
|
+
};
|
|
678
|
+
const target = verb ? VERBS[verb] : undefined;
|
|
679
|
+
if (verb && target) {
|
|
548
680
|
const targets = parseTargets(raw.slice(verb.length).trim());
|
|
549
681
|
if (!targets.length) {
|
|
550
682
|
ctx.ui.notify(
|
|
@@ -554,14 +686,14 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
554
686
|
return;
|
|
555
687
|
}
|
|
556
688
|
const rows = getRows();
|
|
557
|
-
const msg = targets.map((t) => toggleTool(
|
|
689
|
+
const msg = targets.map((t) => toggleTool(target, t, rows, ops)).join("\n");
|
|
558
690
|
ctx.ui.notify(msg, "info");
|
|
559
691
|
return;
|
|
560
692
|
}
|
|
561
693
|
|
|
562
694
|
if (verb === "list") {
|
|
563
695
|
const query = raw.slice(verb.length).trim() || undefined;
|
|
564
|
-
ctx.ui.notify(renderList(getRows(), ops.
|
|
696
|
+
ctx.ui.notify(renderList(getRows(), ops.stateOf, query), "info");
|
|
565
697
|
return;
|
|
566
698
|
}
|
|
567
699
|
|
|
@@ -569,7 +701,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
|
|
|
569
701
|
// SAFETY: The runtime command context satisfies showPicker's narrowed UI contract.
|
|
570
702
|
await showPicker(ctx as unknown as Parameters<typeof showPicker>[0]);
|
|
571
703
|
} else {
|
|
572
|
-
ctx.ui.notify(renderList(getRows(), ops.
|
|
704
|
+
ctx.ui.notify(renderList(getRows(), ops.stateOf), "info");
|
|
573
705
|
}
|
|
574
706
|
},
|
|
575
707
|
});
|