@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 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). Toggle tools on/off to control which are described in the system prompt via `pi.setActiveTools()`.
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
- - Toggling only affects **prompt visibility** — all tools stay callable via their function definitions.
10
- - Four tools (`bash`, `edit`, `read`, `write`) are protected and can't be disabled.
11
- - Gate state persists to `~/.pi/agent/toolbox.json`.
12
- - Headless subcommands: `/toolbox enable <names>`, `/toolbox disable <names>`, `/toolbox list [query]`.
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/opt-in — **not** bundled by [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core). A power-user tool-toggle UI, installed only if you want it.
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
- Source: [github.com/xynogen/pix-mono](https://github.com/xynogen/pix-mono)
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.16",
4
- "description": "Pi tool — gated tool toggle UI (/toolbox)",
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.11.2"
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.js";
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). 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.
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> — enable tool(s) by name
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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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
- isActive: (name: string) => boolean;
63
- onActivate: (name: string) => boolean;
64
- onDeactivate: (name: string) => boolean;
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
- export function buildRows(tools: ToolInfo[]): ToolRow[] {
74
- return tools
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
- .sort((a, b) => a.name.localeCompare(b.name));
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
- isActive: (name: string) => boolean,
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 = filtered.map((r) => {
121
- const status = isActive(r.name) ? "✓ active" : "# gated";
122
- const kind = r.mcp ? "MCP" : "tool";
123
- return `${status} ${r.name} [${kind}] ${r.description}`;
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
- action: "enable" | "disable",
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
- if (action === "enable") {
138
- const did = ops.onActivate(name);
139
- return did ? `Enabled ${name} — now prompt-visible.` : `${name} is already active.`;
140
- }
141
- const did = ops.onDeactivate(name);
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
- enabledTools: string[];
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
- function getStatePath(): string {
152
- return join(getAgentDir(), "toolbox.json");
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 enabledTools = new Set<string>();
252
+ let disabledTools = new Set<string>();
253
+ let loadedTools = new Set<string>();
159
254
  let initialized = false;
160
255
 
161
- function persist(): void {
162
- // Write to session so state survives branch navigation within a session
256
+ function isDeferred(name: string): boolean {
163
257
  try {
164
- pi.appendEntry<ToolboxState>("toolbox-config", {
165
- enabledTools: [...enabledTools],
166
- });
167
- } catch (err) {
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
- // Write to disk so state survives across completely new sessions
263
+ }
264
+
265
+ function allNames(): string[] {
171
266
  try {
172
- const sp = getStatePath();
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: file persist failed:", err);
269
+ console.warn("toolbox: getAllTools failed:", err);
270
+ return [];
177
271
  }
178
272
  }
179
273
 
180
- /** Load previously persisted enabled tool names from disk. */
181
- function loadFromFile(): string[] | undefined {
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
- const sp = getStatePath();
184
- if (!existsSync(sp)) return undefined;
185
- const raw = JSON.parse(readFileSync(sp, "utf-8")) as ToolboxState;
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
- return undefined;
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
- function restoreFromBranch(ctx: ExtensionContext): void {
194
- // Prefer file-based persistence (survives across sessions).
195
- // Fall back to session entries (survives branch navigation within a session).
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
- }
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
- const allEntries = ctx.sessionManager.getEntries();
217
- let saved: string[] | undefined;
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 ensureInit(): void {
241
- if (initialized) return;
242
- let names: string[] = [];
243
- try {
244
- names = (pi.getAllTools() ?? []).map((t) => t.name);
245
- } catch (err) {
246
- console.warn("toolbox: getAllTools failed:", err);
247
- }
248
- if (!names.length) return;
249
- enabledTools = new Set(names);
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
- persist();
337
+ // Migrate a legacy allow-list file to the disabled-list form.
338
+ if (file?.legacy && fromFile) persist();
253
339
  }
254
340
 
255
- function apply(): void {
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.setActiveTools([...enabledTools]);
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 isActive(name: string): boolean {
265
- return enabledTools.has(name);
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 onActivate(name: string): boolean {
269
- if (!initialized) return false;
270
- if (enabledTools.has(name)) return false;
271
- enabledTools.add(name);
272
- apply();
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
- isActive,
292
- onActivate,
293
- onDeactivate,
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
- type RowState = "active" | "gated";
352
- const stateOf = (name: string): RowState => (ops.isActive(name) ? "active" : "gated");
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 active = stateOf(r.name) === "active";
356
- const marker = active ? " " : theme.fg("warning", "#");
357
- const name = active ? theme.fg("success", r.name) : theme.fg("muted", r.name);
358
- const kind = mute(`[${r.mcp ? "MCP" : "tool"}]`);
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 active = stateOf(r.name) === "active";
364
- const tag = active ? theme.fg("success", "active") : theme.fg("warning", "gated");
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 doToggle = (action: "enable" | "disable") => {
538
+ const setSelected = (next: ToolState | "cycle") => {
421
539
  const sel = list.getSelectedItem();
422
- if (!sel) return;
423
- const msg = toggleTool(action, sel.value, rows, ops);
424
- statusText = theme.fg("dim", msg);
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
- ? internal.items
552
+ ? items
440
553
  : fuzzyFilter(
441
- internal.items,
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("↑↓", "navigate") +
583
+ guide("tab", "switch tab") +
584
+ guideSep +
585
+ guide("↑↓", "navigate") +
460
586
  guideSep +
461
587
  guide("←→/PgUp/PgDn", "inspect") +
462
588
  guideSep +
463
- guide("e", "enable") +
589
+ guide("^E", "enable") +
590
+ guideSep +
591
+ guide("^F", "defer") +
464
592
  guideSep +
465
- guide("d", "disable") +
593
+ guide("^D", "disable") +
466
594
  guideSep +
467
- guide("space", "toggle") +
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: list.render(inner),
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.space) || matchesKey(data, Key.tab)) {
511
- flipSelected();
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
- const printable = decodeKittyPrintable(data);
514
- if (printable !== undefined) {
515
- if (printable === "e") {
516
- doToggle("enable");
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
- "Toggle tools on/off. ↑↓ navigate, e/d enable/disable, space toggle. " +
542
- "Headless: /toolbox enable|disable <names>, /toolbox list [query]",
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
- if (verb === "enable" || verb === "disable") {
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(verb, t, rows, ops)).join("\n");
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.isActive, query), "info");
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.isActive), "info");
704
+ ctx.ui.notify(renderList(getRows(), ops.stateOf), "info");
573
705
  }
574
706
  },
575
707
  });