@xynogen/pix-toolbox 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-toolbox",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Pi tool — gated tool toggle UI (/toolbox)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "src",
12
+ "!src/**/*.test.*",
12
13
  "README.md",
13
14
  "LICENSE"
14
15
  ],
package/src/toolbox.ts CHANGED
@@ -345,8 +345,8 @@ export default function registerToolbox(pi: ExtensionAPI): void {
345
345
  const accent = "accent";
346
346
  const mute = (s: string) => theme.fg("muted", s);
347
347
  const guide = (key: string, action: string) =>
348
- theme.fg("text", key) + theme.fg("dim", ` ${action}`);
349
- const guideSep = theme.fg("dim", " · ");
348
+ theme.fg("text", key) + theme.fg("muted", ` ${action}`);
349
+ const guideSep = theme.fg("muted", " · ");
350
350
 
351
351
  type RowState = "active" | "gated";
352
352
  const stateOf = (name: string): RowState => (ops.isActive(name) ? "active" : "gated");
@@ -354,7 +354,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
354
354
  const labelFor = (r: ToolRow): string => {
355
355
  const active = stateOf(r.name) === "active";
356
356
  const marker = active ? " " : theme.fg("warning", "#");
357
- const name = active ? theme.fg("success", r.name) : theme.fg("dim", r.name);
357
+ const name = active ? theme.fg("success", r.name) : theme.fg("muted", r.name);
358
358
  const kind = mute(`[${r.mcp ? "MCP" : "tool"}]`);
359
359
  return `${marker} ${name} ${kind}`;
360
360
  };
@@ -386,7 +386,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
386
386
  selectedPrefix: (t: string) => theme.fg(accent, t),
387
387
  selectedText: (t: string) => theme.fg(accent, t),
388
388
  description: (t: string) => t,
389
- scrollInfo: (t: string) => theme.fg("dim", t),
389
+ scrollInfo: (t: string) => theme.fg("muted", t),
390
390
  noMatch: (t: string) => theme.fg("warning", t),
391
391
  },
392
392
  {
@@ -395,6 +395,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
395
395
  },
396
396
  );
397
397
 
398
+ // SAFETY: SelectList exposes these stable fields internally for label refreshes.
398
399
  const internal = list as unknown as {
399
400
  items: SelectItem[];
400
401
  filteredItems: SelectItem[];
@@ -420,7 +421,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
420
421
  const sel = list.getSelectedItem();
421
422
  if (!sel) return;
422
423
  const msg = toggleTool(action, sel.value, rows, ops);
423
- statusText = theme.fg("muted", msg);
424
+ statusText = theme.fg("dim", msg);
424
425
  refreshLabels();
425
426
  };
426
427
 
@@ -473,7 +474,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
473
474
  minHeight: MIN_MODAL_HEIGHT,
474
475
  header: [
475
476
  theme.fg(accent, theme.bold("🧰 Toolbox")),
476
- theme.fg("muted", "Search:"),
477
+ theme.fg("dim", "Search:"),
477
478
  ...search.render(inner),
478
479
  "",
479
480
  ],
@@ -565,6 +566,7 @@ export default function registerToolbox(pi: ExtensionAPI): void {
565
566
  }
566
567
 
567
568
  if (typeof ctx.ui.custom === "function") {
569
+ // SAFETY: The runtime command context satisfies showPicker's narrowed UI contract.
568
570
  await showPicker(ctx as unknown as Parameters<typeof showPicker>[0]);
569
571
  } else {
570
572
  ctx.ui.notify(renderList(getRows(), ops.isActive), "info");
@@ -1,319 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import registerToolbox, {
6
- buildRows,
7
- parseTargets,
8
- renderList,
9
- type ToggleOps,
10
- type ToolRow,
11
- toggleTool,
12
- } from "./toolbox.ts";
13
-
14
- // ─── Fixtures ───────────────────────────────────────────────────────────────
15
-
16
- const toolInfo = (name: string, source = "builtin") =>
17
- ({
18
- name,
19
- description: `${name} does things.`,
20
- parameters: {},
21
- sourceInfo: { source, path: "", scope: "user", origin: "package" },
22
- }) as never;
23
-
24
- const mcpToolInfo = (name: string) => toolInfo(name, "mcp:context7");
25
-
26
- // ─── buildRows ──────────────────────────────────────────────────────────────
27
-
28
- describe("buildRows", () => {
29
- test("excludes core tools (bash, edit, read, write)", () => {
30
- const rows = buildRows([
31
- toolInfo("bash"),
32
- toolInfo("edit"),
33
- toolInfo("read"),
34
- toolInfo("write"),
35
- toolInfo("grep"),
36
- toolInfo("ls"),
37
- ]);
38
- expect(rows.map((r) => r.name)).toEqual(["grep", "ls"]);
39
- expect(rows.every((r) => !r.mcp)).toBe(true);
40
- });
41
-
42
- test("flags MCP tools", () => {
43
- const rows = buildRows([mcpToolInfo("ctx_search")]);
44
- expect((rows[0] as ToolRow).mcp).toBe(true);
45
- });
46
-
47
- test("empty input returns empty", () => {
48
- expect(buildRows([])).toEqual([]);
49
- });
50
- });
51
-
52
- // ─── parseTargets ───────────────────────────────────────────────────────────
53
-
54
- describe("parseTargets", () => {
55
- test("splits on commas, spaces, newlines", () => {
56
- expect(parseTargets("ls, find grep\nfetch")).toEqual(["ls", "find", "grep", "fetch"]);
57
- });
58
-
59
- test("dedupes", () => {
60
- expect(parseTargets("ls, ls")).toEqual(["ls"]);
61
- });
62
-
63
- test("empty yields empty", () => {
64
- expect(parseTargets("")).toEqual([]);
65
- expect(parseTargets(" , ")).toEqual([]);
66
- });
67
- });
68
-
69
- // ─── renderList ─────────────────────────────────────────────────────────────
70
-
71
- describe("renderList", () => {
72
- const rows: ToolRow[] = [
73
- { name: "read", description: "Read files.", mcp: false },
74
- { name: "grep", description: "Search files.", mcp: false },
75
- { name: "ctx", description: "MCP search.", mcp: true },
76
- ];
77
- const isActive = (n: string) => n === "read";
78
-
79
- test("shows status for each tool", () => {
80
- const out = renderList(rows, isActive);
81
- expect(out).toContain("✓ active read");
82
- expect(out).toContain("# gated grep");
83
- expect(out).toContain("# gated ctx");
84
- expect(out).toContain("[MCP]");
85
- });
86
-
87
- test("filters by query", () => {
88
- const out = renderList(rows, isActive, "grep");
89
- expect(out).toContain("grep");
90
- expect(out).not.toContain("read");
91
- });
92
-
93
- test("no match message", () => {
94
- const out = renderList(rows, isActive, "zzz");
95
- expect(out).toContain('No tools matched "zzz"');
96
- });
97
-
98
- test("empty rows", () => {
99
- expect(renderList([], isActive)).toContain("No tools registered");
100
- });
101
- });
102
-
103
- // ─── toggleTool ─────────────────────────────────────────────────────────────
104
-
105
- describe("toggleTool", () => {
106
- const rows: ToolRow[] = [
107
- { name: "read", description: "Read.", mcp: false },
108
- { name: "grep", description: "Search.", mcp: false },
109
- ];
110
-
111
- test("enable calls onActivate", () => {
112
- let called = "";
113
- const ops: ToggleOps = {
114
- onActivate: (n) => {
115
- called = n;
116
- return true;
117
- },
118
- onDeactivate: () => false,
119
- isActive: () => false,
120
- };
121
- const msg = toggleTool("enable", "grep", rows, ops);
122
- expect(called).toBe("grep");
123
- expect(msg).toContain("Enabled grep");
124
- });
125
-
126
- test("disable calls onDeactivate", () => {
127
- let called = "";
128
- const ops: ToggleOps = {
129
- onActivate: () => false,
130
- onDeactivate: (n) => {
131
- called = n;
132
- return true;
133
- },
134
- isActive: () => true,
135
- };
136
- const msg = toggleTool("disable", "read", rows, ops);
137
- expect(called).toBe("read");
138
- expect(msg).toContain("Disabled read");
139
- });
140
-
141
- test("unknown tool returns error", () => {
142
- const ops: ToggleOps = {
143
- onActivate: () => false,
144
- onDeactivate: () => false,
145
- isActive: () => false,
146
- };
147
- expect(toggleTool("enable", "nope", rows, ops)).toContain("Unknown");
148
- });
149
-
150
- test("already active returns already message", () => {
151
- const ops: ToggleOps = {
152
- onActivate: () => false,
153
- onDeactivate: () => false,
154
- isActive: () => true,
155
- };
156
- expect(toggleTool("enable", "read", rows, ops)).toContain("already");
157
- });
158
-
159
- test("already gated returns already message", () => {
160
- const ops: ToggleOps = {
161
- onActivate: () => false,
162
- onDeactivate: () => false,
163
- isActive: () => false,
164
- };
165
- expect(toggleTool("disable", "read", rows, ops)).toContain("already");
166
- });
167
- });
168
-
169
- // ─── Integration: /toolbox command ──────────────────────────────────────────
170
-
171
- // Isolate from real ~/.pi/agent/toolbox.json on disk
172
- let tmpAgentDir: string;
173
- beforeAll(() => {
174
- tmpAgentDir = mkdtempSync(join(tmpdir(), "toolbox-test-"));
175
- process.env.PI_CODING_AGENT_DIR = tmpAgentDir;
176
- });
177
- afterAll(() => {
178
- delete process.env.PI_CODING_AGENT_DIR;
179
- try {
180
- rmSync(tmpAgentDir, { recursive: true });
181
- } catch {
182
- // temp dir may already be gone — safe to ignore
183
- }
184
- });
185
-
186
- function makeHost(toolNames: string[]) {
187
- const handlers: Record<string, Array<(p: unknown) => unknown>> = {};
188
- let active: string[] = [...toolNames];
189
- const commands: Array<{
190
- name: string;
191
- handler: (...args: unknown[]) => unknown;
192
- }> = [];
193
- const pi = {
194
- on(ev: string, fn: (p: unknown) => unknown) {
195
- if (!handlers[ev]) handlers[ev] = [];
196
- handlers[ev].push(fn);
197
- },
198
- emit(ev: string, payload: unknown, ctx?: unknown) {
199
- return Promise.all(
200
- (handlers[ev] ?? []).map((f) => (f as (...args: unknown[]) => unknown)(payload, ctx)),
201
- );
202
- },
203
- getAllTools() {
204
- return toolNames.map((name) => ({
205
- name,
206
- description: `${name} does things.`,
207
- parameters: {},
208
- sourceInfo: { source: "builtin" },
209
- }));
210
- },
211
- getActiveTools() {
212
- return active;
213
- },
214
- setActiveTools(names: string[]) {
215
- active = [...names];
216
- },
217
- getCommands() {
218
- return [];
219
- },
220
- appendEntry() {},
221
- registerTool() {},
222
- registerCommand(name: string, def: { handler: (...args: unknown[]) => unknown }) {
223
- commands.push({ name, handler: def.handler });
224
- },
225
- } as never;
226
- const emit = (ev: string, payload: unknown, ctx?: unknown) =>
227
- Promise.all(
228
- (handlers[ev] ?? []).map((f) => (f as (...args: unknown[]) => unknown)(payload, ctx)),
229
- );
230
- return {
231
- pi,
232
- emit,
233
- getActive: () => active,
234
- command: (name: string) => commands.find((c) => c.name === name),
235
- };
236
- }
237
-
238
- function makeCtx() {
239
- const notes: Array<{ text: string; level?: string }> = [];
240
- const ctx = {
241
- ui: {
242
- notify(text: string, level?: string) {
243
- notes.push({ text, level });
244
- },
245
- },
246
- } as never;
247
- return { ctx, notes };
248
- }
249
-
250
- describe("/toolbox command", () => {
251
- type Note = { text: string; level?: string };
252
- const ALL = ["read", "write", "bash", "grep", "find"];
253
-
254
- async function boot() {
255
- const host = makeHost(ALL);
256
- registerToolbox(host.pi);
257
- // session_start triggers init
258
- await host.emit("session_start", {}, {});
259
- return host;
260
- }
261
-
262
- test("registers a /toolbox command", async () => {
263
- const host = await boot();
264
- expect(host.command("toolbox")).toBeDefined();
265
- });
266
-
267
- test("bare /toolbox falls back to listing when no custom UI", async () => {
268
- const host = await boot();
269
- const { ctx, notes } = makeCtx();
270
- await host.command("toolbox")?.handler("", ctx);
271
- expect(notes.length).toBe(1);
272
- // only non-core tools shown, all start active
273
- const n0 = notes[0] as Note;
274
- expect(n0.text).toContain("✓ active grep");
275
- expect(n0.text).toContain("✓ active find");
276
- // core tools excluded from toolbox
277
- expect(n0.text).not.toContain(" read");
278
- expect(n0.text).not.toContain(" bash");
279
- });
280
-
281
- test("/toolbox list shows non-core tools with status", async () => {
282
- const host = await boot();
283
- const { ctx, notes } = makeCtx();
284
- await host.command("toolbox")?.handler("list", ctx);
285
- const ln0 = notes[0] as Note;
286
- expect(ln0.text).toContain("grep");
287
- expect(ln0.text).toContain("find");
288
- expect(ln0.text).not.toContain(" bash");
289
- });
290
-
291
- test("/toolbox list <query> filters", async () => {
292
- const host = await boot();
293
- const { ctx, notes } = makeCtx();
294
- await host.command("toolbox")?.handler("list fin", ctx);
295
- const fn0 = notes[0] as Note;
296
- expect(fn0.text).toContain("find");
297
- expect(fn0.text).not.toContain("✓ active read");
298
- });
299
-
300
- test("opens interactive picker when ctx.ui.custom exists", async () => {
301
- const host = await boot();
302
- let customCalled = 0;
303
- const notes: Array<{ text: string; level?: string }> = [];
304
- const ctx = {
305
- ui: {
306
- notify(text: string, level?: string) {
307
- notes.push({ text, level });
308
- },
309
- async custom() {
310
- customCalled++;
311
- return null;
312
- },
313
- },
314
- } as never;
315
- await host.command("toolbox")?.handler("", ctx);
316
- expect(customCalled).toBe(1);
317
- expect(notes.length).toBe(0);
318
- });
319
- });