@dungle-scrubs/tallow 0.8.26 → 0.8.28

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.
Files changed (114) hide show
  1. package/README.md +42 -1
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +1 -1
  6. package/dist/interactive-mode-patch.d.ts +1 -0
  7. package/dist/interactive-mode-patch.d.ts.map +1 -1
  8. package/dist/interactive-mode-patch.js +40 -1
  9. package/dist/interactive-mode-patch.js.map +1 -1
  10. package/dist/model-metadata-overrides.d.ts +2 -5
  11. package/dist/model-metadata-overrides.d.ts.map +1 -1
  12. package/dist/model-metadata-overrides.js +23 -12
  13. package/dist/model-metadata-overrides.js.map +1 -1
  14. package/dist/pid-manager.d.ts +2 -9
  15. package/dist/pid-manager.d.ts.map +1 -1
  16. package/dist/pid-manager.js +1 -58
  17. package/dist/pid-manager.js.map +1 -1
  18. package/dist/pid-schema.d.ts +51 -0
  19. package/dist/pid-schema.d.ts.map +1 -0
  20. package/dist/pid-schema.js +70 -0
  21. package/dist/pid-schema.js.map +1 -0
  22. package/dist/sdk.d.ts.map +1 -1
  23. package/dist/sdk.js +24 -17
  24. package/dist/sdk.js.map +1 -1
  25. package/dist/workspace-transition-interactive.d.ts.map +1 -1
  26. package/dist/workspace-transition-interactive.js +53 -3
  27. package/dist/workspace-transition-interactive.js.map +1 -1
  28. package/dist/workspace-transition.d.ts +2 -1
  29. package/dist/workspace-transition.d.ts.map +1 -1
  30. package/dist/workspace-transition.js +16 -4
  31. package/dist/workspace-transition.js.map +1 -1
  32. package/extensions/__integration__/audit-findings.test.ts +309 -0
  33. package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
  34. package/extensions/__integration__/tasks-runtime.test.ts +63 -12
  35. package/extensions/__integration__/welcome-screen.test.ts +240 -0
  36. package/extensions/_shared/lazy-init.ts +88 -3
  37. package/extensions/_shared/pid-registry.ts +8 -82
  38. package/extensions/background-task-tool/index.ts +1 -1
  39. package/extensions/cd-tool/index.ts +4 -1
  40. package/extensions/cheatsheet/__tests__/cheatsheet.test.ts +47 -0
  41. package/extensions/clear/__tests__/clear.test.ts +38 -0
  42. package/extensions/edit-tool-enhanced/index.ts +3 -1
  43. package/extensions/git-status/__tests__/git-status.test.ts +32 -0
  44. package/extensions/health/__tests__/diagnostics.test.ts +25 -0
  45. package/extensions/health/index.ts +61 -0
  46. package/extensions/loop/__tests__/loop.test.ts +365 -1
  47. package/extensions/loop/index.ts +213 -3
  48. package/extensions/mcp-adapter-tool/index.ts +1 -1
  49. package/extensions/minimal-skill-display/__tests__/minimal-skill-display.test.ts +20 -0
  50. package/extensions/permissions/__tests__/permissions.test.ts +213 -0
  51. package/extensions/progress-indicator/__tests__/progress-indicator.test.ts +104 -0
  52. package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
  53. package/extensions/prompt-suggestions/autocomplete.ts +23 -5
  54. package/extensions/prompt-suggestions/index.ts +62 -3
  55. package/extensions/random-spinner/__tests__/random-spinner.test.ts +35 -0
  56. package/extensions/read-tool-enhanced/index.ts +5 -1
  57. package/extensions/session-memory/index.ts +1 -1
  58. package/extensions/session-namer/index.ts +1 -1
  59. package/extensions/show-system-prompt/__tests__/show-system-prompt.test.ts +51 -0
  60. package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +9 -8
  61. package/extensions/subagent-tool/__tests__/process-liveness.test.ts +51 -0
  62. package/extensions/subagent-tool/__tests__/subprocess-args.test.ts +120 -0
  63. package/extensions/subagent-tool/formatting.ts +2 -0
  64. package/extensions/subagent-tool/index.ts +160 -97
  65. package/extensions/subagent-tool/process.ts +152 -40
  66. package/extensions/tasks/commands/register-tasks-extension.ts +64 -20
  67. package/extensions/tasks/extension.json +1 -0
  68. package/extensions/tasks/index.ts +2 -12
  69. package/extensions/tasks/state/index.ts +26 -0
  70. package/extensions/teams-tool/dashboard.ts +13 -1
  71. package/extensions/teams-tool/sessions/spawn.ts +2 -2
  72. package/extensions/teams-tool/tools/register-extension.ts +10 -2
  73. package/extensions/upstream-check/__tests__/upstream-check.test.ts +49 -0
  74. package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
  75. package/extensions/welcome-screen/extension.json +20 -0
  76. package/extensions/welcome-screen/index.ts +189 -0
  77. package/extensions/wezterm-notify/__tests__/index.test.ts +49 -11
  78. package/extensions/wezterm-notify/index.ts +5 -3
  79. package/extensions/write-tool-enhanced/__tests__/write-tool-enhanced.test.ts +296 -0
  80. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
  81. package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
  82. package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
  83. package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
  84. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
  85. package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
  86. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
  87. package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
  88. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
  89. package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
  90. package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
  91. package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
  92. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
  93. package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
  94. package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
  95. package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
  96. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +9 -0
  97. package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
  98. package/node_modules/@mariozechner/pi-tui/dist/tui.js +50 -1
  99. package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
  100. package/node_modules/@mariozechner/pi-tui/package.json +1 -1
  101. package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
  102. package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
  103. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
  104. package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
  105. package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
  106. package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
  107. package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
  108. package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
  109. package/node_modules/@mariozechner/pi-tui/src/tui.ts +64 -1
  110. package/package.json +11 -10
  111. package/runtime/config.ts +7 -0
  112. package/runtime/model-metadata-overrides.ts +7 -0
  113. package/runtime/pid-schema.ts +13 -0
  114. package/skills/tallow-expert/SKILL.md +7 -5
@@ -10,7 +10,7 @@
10
10
  * - `until "<condition>"` — the model evaluates the condition each
11
11
  * iteration and calls the `loop_stop` tool when it's met
12
12
  *
13
- * Usage:
13
+ * Usage (strict syntax):
14
14
  * /loop 5m check the deploy status
15
15
  * /loop 1m x10 run the test suite
16
16
  * /loop 2m until "build is done" check fuse index progress
@@ -18,6 +18,11 @@
18
18
  * /loop 30s /stats
19
19
  * /loop stop
20
20
  * /loop status
21
+ *
22
+ * Natural language (auto-parsed → prefilled for confirmation):
23
+ * /loop check ci every 2 minutes until it passes
24
+ * /loop run tests every 30 seconds, stop when they pass
25
+ * /loop monitor deploy health every minute, max 20 tries
21
26
  */
22
27
 
23
28
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
@@ -277,6 +282,201 @@ export function parseLoopArgs(args: string): LoopArgs | { action: "error"; messa
277
282
  };
278
283
  }
279
284
 
285
+ // ── Natural Language Parsing ──────────────────────────────────────────────────
286
+
287
+ /** Map from human-readable unit names to short suffixes. */
288
+ const UNIT_ALIASES: Readonly<Record<string, string>> = {
289
+ s: "s",
290
+ sec: "s",
291
+ secs: "s",
292
+ second: "s",
293
+ seconds: "s",
294
+ m: "m",
295
+ min: "m",
296
+ mins: "m",
297
+ minute: "m",
298
+ minutes: "m",
299
+ h: "h",
300
+ hr: "h",
301
+ hrs: "h",
302
+ hour: "h",
303
+ hours: "h",
304
+ };
305
+
306
+ /**
307
+ * Attempt to parse a natural-language loop description into structured params.
308
+ *
309
+ * Extracts interval, condition, max iterations, and prompt from free-form
310
+ * text using regex heuristics. Returns null if no interval can be identified.
311
+ *
312
+ * This is a best-effort parser — the result is prefilled in the editor for
313
+ * the user to review, not executed directly.
314
+ *
315
+ * @param text - Free-form loop description
316
+ * @returns Parsed start params, or null if no interval was found
317
+ */
318
+ export function parseNaturalLanguageLoop(
319
+ text: string
320
+ ): Extract<LoopArgs, { action: "start" }> | null {
321
+ let work = text;
322
+
323
+ // ── Interval extraction ──────────────────────────────────────────
324
+
325
+ let intervalMs: number | null = null;
326
+ let intervalLabel: string | null = null;
327
+
328
+ // "every <N> <unit-word>" — e.g. "every 2 minutes", "every 30 seconds"
329
+ const everyLong = work.match(
330
+ /\bevery\s+(\d+)\s+(seconds?|secs?|sec|minutes?|mins?|min|hours?|hrs?|hr)\b/i
331
+ );
332
+ if (everyLong) {
333
+ const val = parseInt(everyLong[1], 10);
334
+ const unit = UNIT_ALIASES[everyLong[2].toLowerCase()];
335
+ if (unit && val > 0) {
336
+ intervalMs = val * UNIT_MS[unit];
337
+ intervalLabel = `${val}${unit}`;
338
+ work = work.replace(everyLong[0], " ");
339
+ }
340
+ }
341
+
342
+ // "every <unit-word>" without number → 1 unit. e.g. "every minute"
343
+ if (!intervalMs) {
344
+ const everyBare = work.match(/\bevery\s+(second|minute|hour)\b/i);
345
+ if (everyBare) {
346
+ const unit = UNIT_ALIASES[everyBare[1].toLowerCase()];
347
+ if (unit) {
348
+ intervalMs = UNIT_MS[unit];
349
+ intervalLabel = `1${unit}`;
350
+ work = work.replace(everyBare[0], " ");
351
+ }
352
+ }
353
+ }
354
+
355
+ // "every <N><short-unit>" — e.g. "every 2m"
356
+ if (!intervalMs) {
357
+ const everyShort = work.match(/\bevery\s+(\d+)(s|m|h)\b/i);
358
+ if (everyShort) {
359
+ const val = parseInt(everyShort[1], 10);
360
+ const unit = everyShort[2].toLowerCase();
361
+ if (val > 0) {
362
+ intervalMs = val * UNIT_MS[unit];
363
+ intervalLabel = `${val}${unit}`;
364
+ work = work.replace(everyShort[0], " ");
365
+ }
366
+ }
367
+ }
368
+
369
+ // Bare "<N><short-unit>" without "every" — e.g. "2m"
370
+ if (!intervalMs) {
371
+ const bare = work.match(/\b(\d+)(s|m|h)\b/);
372
+ if (bare) {
373
+ const val = parseInt(bare[1], 10);
374
+ const unit = bare[2].toLowerCase();
375
+ if (val > 0) {
376
+ intervalMs = val * UNIT_MS[unit];
377
+ intervalLabel = `${val}${unit}`;
378
+ work = work.replace(bare[0], " ");
379
+ }
380
+ }
381
+ }
382
+
383
+ if (!intervalMs || !intervalLabel) return null;
384
+
385
+ // ── Max iterations extraction ────────────────────────────────────
386
+
387
+ let maxIterations: number | null = null;
388
+
389
+ // "x<N>"
390
+ const xN = work.match(/\bx(\d+)\b/);
391
+ if (xN) {
392
+ const n = parseInt(xN[1], 10);
393
+ if (n > 0) {
394
+ maxIterations = n;
395
+ work = work.replace(xN[0], " ");
396
+ }
397
+ }
398
+
399
+ // "<N> times" / "max <N> [times|tries|iterations]" / "at most <N> [...]"
400
+ if (maxIterations === null) {
401
+ const alt = work.match(
402
+ /\b(?:(\d+)\s+times|max\s+(\d+)(?:\s+(?:times|tries|iterations))?|at\s+most\s+(\d+)(?:\s+(?:times|tries|iterations))?)\b/i
403
+ );
404
+ if (alt) {
405
+ const n = parseInt(alt[1] || alt[2] || alt[3], 10);
406
+ if (n > 0) {
407
+ maxIterations = n;
408
+ work = work.replace(alt[0], " ");
409
+ }
410
+ }
411
+ }
412
+
413
+ // ── Condition extraction ─────────────────────────────────────────
414
+ // Uses greedy match for the prompt prefix so multi-"until" text picks
415
+ // the last occurrence (the actual condition marker).
416
+
417
+ let untilCondition: string | null = null;
418
+ let promptText: string;
419
+
420
+ // Case 1: condition at end (most common) — "check ci until it passes"
421
+ const endMatch = work.match(/^(.*)\s*[,;]?\s*\b(until|stop\s+when)\s+(.+?)[\s,;.!]*$/i);
422
+
423
+ if (endMatch?.[1].trim()) {
424
+ promptText = endMatch[1];
425
+ untilCondition = endMatch[3].replace(/^["']|["']$/g, "");
426
+ } else {
427
+ // Case 2: condition at start with comma — "until it passes, check ci"
428
+ const startMatch = work.match(/^\s*\b(until|stop\s+when)\s+(.+?)\s*[,;]\s+(.+?)[\s,;.!]*$/i);
429
+
430
+ if (startMatch) {
431
+ untilCondition = startMatch[2].replace(/^["']|["']$/g, "");
432
+ promptText = startMatch[3];
433
+ } else {
434
+ // No condition marker, or condition-only with no prompt
435
+ promptText = work;
436
+ }
437
+ }
438
+
439
+ // ── Clean up ─────────────────────────────────────────────────────
440
+
441
+ const prompt = promptText.replace(/[,;]+/g, " ").replace(/\s+/g, " ").trim();
442
+
443
+ if (!prompt) return null;
444
+
445
+ if (untilCondition) {
446
+ untilCondition = untilCondition.replace(/\s+/g, " ").trim();
447
+ if (!untilCondition) untilCondition = null;
448
+ }
449
+
450
+ return {
451
+ action: "start",
452
+ intervalMs,
453
+ intervalLabel,
454
+ prompt,
455
+ maxIterations,
456
+ untilCondition,
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Build a `/loop` command string from structured params.
462
+ *
463
+ * Produces valid strict syntax that `parseLoopArgs` can parse.
464
+ *
465
+ * @param params - Parsed loop start parameters
466
+ * @returns Command string like `/loop 2m until "it passes" check ci`
467
+ */
468
+ export function buildLoopCommand(params: Extract<LoopArgs, { action: "start" }>): string {
469
+ let cmd = `/loop ${params.intervalLabel}`;
470
+ if (params.maxIterations !== null) {
471
+ cmd += ` x${params.maxIterations}`;
472
+ }
473
+ if (params.untilCondition) {
474
+ cmd += ` until "${params.untilCondition}"`;
475
+ }
476
+ cmd += ` ${params.prompt}`;
477
+ return cmd;
478
+ }
479
+
280
480
  // ── Loop Lifecycle ───────────────────────────────────────────────────────────
281
481
 
282
482
  /**
@@ -469,14 +669,24 @@ export default function loopExtension(pi: ExtensionAPI): void {
469
669
  pi.registerCommand("loop", {
470
670
  description:
471
671
  "Run a prompt on a recurring interval. " +
472
- 'Syntax: /loop <interval> [x<N>] [until "<condition>"] <prompt>',
672
+ 'Syntax: /loop <interval> [x<N>] [until "<condition>"] <prompt>' +
673
+ "or describe in natural language: /loop check ci every 2m until it passes",
473
674
  handler: async (args, ctx) => {
474
675
  const parsed = parseLoopArgs(args);
475
676
 
476
677
  switch (parsed.action) {
477
- case "error":
678
+ case "error": {
679
+ // Try natural-language fallback before showing the error
680
+ const nlResult = parseNaturalLanguageLoop(args);
681
+ if (nlResult) {
682
+ const command = buildLoopCommand(nlResult);
683
+ ctx.ui.setEditorText(command);
684
+ ctx.ui.notify("Loop command generated — review and press Enter to start", "info");
685
+ return;
686
+ }
478
687
  ctx.ui.notify(parsed.message, "error");
479
688
  return;
689
+ }
480
690
 
481
691
  case "stop":
482
692
  if (!activeLoop) {
@@ -1752,7 +1752,7 @@ export default function mcpAdapter(pi: ExtensionAPI) {
1752
1752
  return { systemPrompt: event.systemPrompt + lines.join("\n") };
1753
1753
  });
1754
1754
 
1755
- pi.on("session_shutdown" as never, async () => {
1755
+ pi.on("session_shutdown", async () => {
1756
1756
  resetRuntimeState();
1757
1757
  });
1758
1758
 
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import minimalSkillDisplay from "../index.js";
4
+
5
+ describe("minimal-skill-display extension", () => {
6
+ test("registers session_start and input handlers", () => {
7
+ const events: string[] = [];
8
+ const pi = {
9
+ on: (event: string) => {
10
+ events.push(event);
11
+ },
12
+ registerCommand: () => {},
13
+ registerMessageRenderer: () => {},
14
+ } as unknown as ExtensionAPI;
15
+
16
+ minimalSkillDisplay(pi);
17
+ expect(events).toContain("session_start");
18
+ expect(events).toContain("input");
19
+ });
20
+ });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Tests for the permissions extension registration and wiring.
3
+ *
4
+ * Uses the ExtensionHarness to avoid mock.module() — which contaminates
5
+ * other test files in the same Bun worker. Tests cover command/handler
6
+ * registration and event handler presence without mocking the _shared modules.
7
+ */
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
10
+ import { ExtensionHarness } from "../../../test-utils/extension-harness.js";
11
+ import registerPermissions from "../index.js";
12
+
13
+ // ── Helpers ───────────────────────────────────────────────────────────────────
14
+
15
+ type EventName = "session_start" | "tool_call";
16
+ type EventHandler = (event: unknown, ctx: ExtensionContext) => Promise<unknown>;
17
+ type CommandHandler = (args: string | undefined, ctx: unknown) => Promise<void>;
18
+
19
+ interface CapturedPi {
20
+ handlers: Partial<Record<EventName, EventHandler>>;
21
+ commands: Record<string, { description: string; handler: CommandHandler }>;
22
+ }
23
+
24
+ /**
25
+ * Run the extension against a spy pi to capture what it registers.
26
+ *
27
+ * @returns Captured event handlers and commands
28
+ */
29
+ function captureRegistrations(): CapturedPi {
30
+ const handlers: Partial<Record<EventName, EventHandler>> = {};
31
+ const commands: Record<string, { description: string; handler: CommandHandler }> = {};
32
+ const pi = {
33
+ on: (event: string, handler: EventHandler) => {
34
+ handlers[event as EventName] = handler;
35
+ },
36
+ registerCommand: (name: string, opts: { description: string; handler: CommandHandler }) => {
37
+ commands[name] = opts;
38
+ },
39
+ } as unknown as ExtensionAPI;
40
+
41
+ registerPermissions(pi);
42
+ return { handlers, commands };
43
+ }
44
+
45
+ // ════════════════════════════════════════════════════════════════
46
+ // Registration
47
+ // ════════════════════════════════════════════════════════════════
48
+
49
+ describe("permissions extension registration", () => {
50
+ test("registers session_start handler", () => {
51
+ const { handlers } = captureRegistrations();
52
+ expect(handlers.session_start).toBeDefined();
53
+ });
54
+
55
+ test("registers tool_call handler", () => {
56
+ const { handlers } = captureRegistrations();
57
+ expect(handlers.tool_call).toBeDefined();
58
+ });
59
+
60
+ test("registers /permissions command", () => {
61
+ const { commands } = captureRegistrations();
62
+ expect(commands.permissions).toBeDefined();
63
+ expect(commands.permissions.description).toBeTruthy();
64
+ });
65
+ });
66
+
67
+ // ════════════════════════════════════════════════════════════════
68
+ // tool_call handler wiring
69
+ // ════════════════════════════════════════════════════════════════
70
+
71
+ describe("tool_call handler", () => {
72
+ test("skips bash tool (handled by shell-policy)", async () => {
73
+ const { handlers } = captureRegistrations();
74
+ const result = await handlers.tool_call!(
75
+ { type: "tool_call", toolName: "bash", toolCallId: "t1", input: { command: "ls" } },
76
+ { cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
77
+ );
78
+ // No block/error — should be undefined (pass-through)
79
+ expect(result).toBeUndefined();
80
+ });
81
+
82
+ test("skips bg_bash tool (handled by shell-policy)", async () => {
83
+ const { handlers } = captureRegistrations();
84
+ const result = await handlers.tool_call!(
85
+ { type: "tool_call", toolName: "bg_bash", toolCallId: "t2", input: { command: "ls" } },
86
+ { cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
87
+ );
88
+ expect(result).toBeUndefined();
89
+ });
90
+
91
+ test("skips when no rules configured", async () => {
92
+ const { handlers } = captureRegistrations();
93
+ // Session-start hasn't been called yet, so currentCwd is "", and
94
+ // getPermissions("") returns empty rules → skip
95
+ const result = await handlers.tool_call!(
96
+ {
97
+ type: "tool_call",
98
+ toolName: "read",
99
+ toolCallId: "t3",
100
+ input: { path: "/etc/passwd" },
101
+ },
102
+ { cwd: "/tmp", hasUI: false, ui: {} } as unknown as ExtensionContext
103
+ );
104
+ expect(result).toBeUndefined();
105
+ });
106
+ });
107
+
108
+ // ════════════════════════════════════════════════════════════════
109
+ // /permissions command wiring
110
+ // ════════════════════════════════════════════════════════════════
111
+
112
+ describe("/permissions command", () => {
113
+ test("reload subcommand calls reloadPermissions", async () => {
114
+ const { commands } = captureRegistrations();
115
+ const notifications: Array<{ msg: string; type: string }> = [];
116
+ const ctx = {
117
+ cwd: "/tmp",
118
+ ui: {
119
+ notify: (msg: string, type: string) => {
120
+ notifications.push({ msg, type });
121
+ },
122
+ },
123
+ };
124
+
125
+ await commands.permissions.handler("reload", ctx);
126
+ // Should have notified about reload
127
+ expect(notifications.length).toBeGreaterThan(0);
128
+ expect(notifications[0].msg).toContain("Reloaded");
129
+ });
130
+
131
+ test("no args shows rules (or 'no rules' when none configured)", async () => {
132
+ const { commands } = captureRegistrations();
133
+ const notifications: Array<{ msg: string; type: string }> = [];
134
+ const ctx = {
135
+ cwd: "/tmp",
136
+ ui: {
137
+ notify: (msg: string, type: string) => {
138
+ notifications.push({ msg, type });
139
+ },
140
+ },
141
+ };
142
+
143
+ await commands.permissions.handler("", ctx);
144
+ expect(notifications.length).toBeGreaterThan(0);
145
+ // Should show either "No permission rules" or "Active Permission Rules"
146
+ const msg = notifications[0].msg;
147
+ expect(msg.includes("No permission rules") || msg.includes("Permission Rules")).toBe(true);
148
+ });
149
+
150
+ test("test subcommand evaluates Tool(specifier) format", async () => {
151
+ const { commands } = captureRegistrations();
152
+ const notifications: Array<{ msg: string; type: string }> = [];
153
+ const ctx = {
154
+ cwd: "/tmp",
155
+ ui: {
156
+ notify: (msg: string, type: string) => {
157
+ notifications.push({ msg, type });
158
+ },
159
+ },
160
+ };
161
+
162
+ await commands.permissions.handler("test Bash(ls)", ctx);
163
+ expect(notifications.length).toBeGreaterThan(0);
164
+ // Should contain action verdict info
165
+ expect(notifications[0].msg).toContain("Action:");
166
+ });
167
+
168
+ test("test subcommand handles bare tool name", async () => {
169
+ const { commands } = captureRegistrations();
170
+ const notifications: Array<{ msg: string; type: string }> = [];
171
+ const ctx = {
172
+ cwd: "/tmp",
173
+ ui: {
174
+ notify: (msg: string, type: string) => {
175
+ notifications.push({ msg, type });
176
+ },
177
+ },
178
+ };
179
+
180
+ await commands.permissions.handler("test read", ctx);
181
+ expect(notifications.length).toBeGreaterThan(0);
182
+ expect(notifications[0].msg).toContain("Action:");
183
+ });
184
+ });
185
+
186
+ // ════════════════════════════════════════════════════════════════
187
+ // ExtensionHarness integration
188
+ // ════════════════════════════════════════════════════════════════
189
+
190
+ describe("permissions via ExtensionHarness", () => {
191
+ test("tool_call event handler is registered", async () => {
192
+ const harness = ExtensionHarness.create();
193
+ await harness.loadExtension(registerPermissions);
194
+ // Verify the extension registered a tool_call handler by firing one
195
+ const results = await harness.fireEvent("tool_call", {
196
+ type: "tool_call",
197
+ toolName: "read",
198
+ toolCallId: "h-1",
199
+ input: { path: "foo.txt" },
200
+ });
201
+ // No rules configured → should not block
202
+ const blocked = results.some(
203
+ (r) => r && typeof r === "object" && (r as { block?: boolean }).block
204
+ );
205
+ expect(blocked).toBe(false);
206
+ });
207
+
208
+ test("/permissions command is registered", async () => {
209
+ const harness = ExtensionHarness.create();
210
+ await harness.loadExtension(registerPermissions);
211
+ expect(harness.commands.has("permissions")).toBe(true);
212
+ });
213
+ });
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
+ import progressIndicator from "../index.js";
4
+
5
+ describe("progress-indicator extension", () => {
6
+ let originalIsTTY: boolean;
7
+ let written: string[];
8
+ let originalWrite: typeof process.stdout.write;
9
+
10
+ beforeEach(() => {
11
+ originalIsTTY = process.stdout.isTTY;
12
+ originalWrite = process.stdout.write;
13
+ written = [];
14
+ Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
15
+ process.stdout.write = ((chunk: string) => {
16
+ written.push(chunk);
17
+ return true;
18
+ }) as typeof process.stdout.write;
19
+ });
20
+
21
+ afterEach(() => {
22
+ Object.defineProperty(process.stdout, "isTTY", {
23
+ value: originalIsTTY,
24
+ configurable: true,
25
+ });
26
+ process.stdout.write = originalWrite;
27
+ });
28
+
29
+ test("registers turn_start, turn_end, agent_end, session_shutdown handlers", () => {
30
+ const events: string[] = [];
31
+ const pi = {
32
+ on: (event: string) => {
33
+ events.push(event);
34
+ },
35
+ } as unknown as ExtensionAPI;
36
+
37
+ progressIndicator(pi);
38
+ expect(events).toContain("turn_start");
39
+ expect(events).toContain("turn_end");
40
+ expect(events).toContain("agent_end");
41
+ expect(events).toContain("session_shutdown");
42
+ });
43
+
44
+ test("turn_start writes indeterminate OSC sequence", () => {
45
+ const handlers: Record<string, () => void> = {};
46
+ const pi = {
47
+ on: (event: string, handler: () => void) => {
48
+ handlers[event] = handler;
49
+ },
50
+ } as unknown as ExtensionAPI;
51
+
52
+ progressIndicator(pi);
53
+ handlers.turn_start();
54
+
55
+ expect(written).toHaveLength(1);
56
+ expect(written[0]).toContain("9;4;3");
57
+ });
58
+
59
+ test("turn_end writes clear OSC sequence", () => {
60
+ const handlers: Record<string, () => void> = {};
61
+ const pi = {
62
+ on: (event: string, handler: () => void) => {
63
+ handlers[event] = handler;
64
+ },
65
+ } as unknown as ExtensionAPI;
66
+
67
+ progressIndicator(pi);
68
+ handlers.turn_end();
69
+
70
+ expect(written).toHaveLength(1);
71
+ expect(written[0]).toContain("9;4;0");
72
+ });
73
+
74
+ test("agent_end writes clear OSC sequence", () => {
75
+ const handlers: Record<string, () => void> = {};
76
+ const pi = {
77
+ on: (event: string, handler: () => void) => {
78
+ handlers[event] = handler;
79
+ },
80
+ } as unknown as ExtensionAPI;
81
+
82
+ progressIndicator(pi);
83
+ handlers.agent_end();
84
+
85
+ expect(written).toHaveLength(1);
86
+ expect(written[0]).toContain("9;4;0");
87
+ });
88
+
89
+ test("skips write when stdout is not a TTY", () => {
90
+ Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
91
+
92
+ const handlers: Record<string, () => void> = {};
93
+ const pi = {
94
+ on: (event: string, handler: () => void) => {
95
+ handlers[event] = handler;
96
+ },
97
+ } as unknown as ExtensionAPI;
98
+
99
+ progressIndicator(pi);
100
+ handlers.turn_start();
101
+
102
+ expect(written).toHaveLength(0);
103
+ });
104
+ });