@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 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/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/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,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-toolbox",
3
- "version": "0.1.16",
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.11.2"
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.js";
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). 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
 
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
- isActive: (name: string) => boolean;
63
- onActivate: (name: string) => boolean;
64
- onDeactivate: (name: string) => boolean;
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
- export function buildRows(tools: ToolInfo[]): ToolRow[] {
74
- return tools
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
- .sort((a, b) => a.name.localeCompare(b.name));
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
- isActive: (name: string) => boolean,
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 = 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
- });
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
- action: "enable" | "disable",
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
- 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.`;
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
- enabledTools: string[];
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 enabledTools = new Set<string>();
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({ enabledTools: [...enabledTools] }, null, 2), "utf-8");
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
- /** Load previously persisted enabled tool names from disk. */
181
- function loadFromFile(): string[] | undefined {
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
- if (Array.isArray(raw?.enabledTools)) return raw.enabledTools;
304
+ return { raw, legacy: !isStringArray(raw?.disabledTools) };
187
305
  } catch {
188
- // File doesn't exist, is corrupt, or we're in a test env without getAgentDir
306
+ // corrupt, or a test env without getAgentDir
307
+ return undefined;
189
308
  }
190
- return undefined;
191
309
  }
192
310
 
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
- }
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
- 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
- }
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 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);
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
- persist();
336
+ // Migrate a legacy allow-list file to the disabled-list form.
337
+ if (file?.legacy && fromFile) persist();
253
338
  }
254
339
 
255
- function apply(): void {
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.setActiveTools([...enabledTools]);
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 isActive(name: string): boolean {
265
- return enabledTools.has(name);
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 onActivate(name: string): boolean {
269
- if (!initialized) return false;
270
- if (enabledTools.has(name)) return false;
271
- enabledTools.add(name);
272
- apply();
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
- isActive,
292
- onActivate,
293
- onDeactivate,
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
- type RowState = "active" | "gated";
352
- const stateOf = (name: string): RowState => (ops.isActive(name) ? "active" : "gated");
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 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}`;
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 active = stateOf(r.name) === "active";
364
- const tag = active ? theme.fg("success", "active") : theme.fg("warning", "gated");
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 doToggle = (action: "enable" | "disable") => {
536
+ const setSelected = (next: ToolState | "cycle") => {
421
537
  const sel = list.getSelectedItem();
422
- if (!sel) return;
423
- const msg = toggleTool(action, sel.value, rows, ops);
424
- statusText = theme.fg("dim", msg);
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
- ? internal.items
550
+ ? items
440
551
  : fuzzyFilter(
441
- internal.items,
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("↑↓", "navigate") +
581
+ guide("tab", "switch tab") +
582
+ guideSep +
583
+ guide("↑↓", "navigate") +
460
584
  guideSep +
461
585
  guide("←→/PgUp/PgDn", "inspect") +
462
586
  guideSep +
463
- guide("e", "enable") +
587
+ guide("^E", "enable") +
588
+ guideSep +
589
+ guide("^F", "defer") +
464
590
  guideSep +
465
- guide("d", "disable") +
591
+ guide("^D", "disable") +
466
592
  guideSep +
467
- guide("space", "toggle") +
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: list.render(inner),
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.space) || matchesKey(data, Key.tab)) {
511
- flipSelected();
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
- 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
- }
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
- "Toggle tools on/off. ↑↓ navigate, e/d enable/disable, space toggle. " +
542
- "Headless: /toolbox enable|disable <names>, /toolbox list [query]",
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
- if (verb === "enable" || verb === "disable") {
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(verb, t, rows, ops)).join("\n");
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.isActive, query), "info");
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.isActive), "info");
702
+ ctx.ui.notify(renderList(getRows(), ops.stateOf), "info");
573
703
  }
574
704
  },
575
705
  });