pi-subagents-lite 1.0.2 → 1.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/src/menus.ts CHANGED
@@ -2,27 +2,51 @@
2
2
  * menus.ts — /agents command menu system.
3
3
  *
4
4
  * All menu-related functions extracted from index.ts.
5
- * Imports shared state (config, manager, piInstance) from index.ts.
5
+ * Imports shared state (config, manager, piInstance) from state.ts.
6
6
  */
7
7
 
8
8
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
- import { getAgentConfig, getAvailableTypes, getAllTypes } from "./agent-types.js";
10
- import type { AgentRecord } from "./types.js";
11
- import { SHORT_ID_LENGTH } from "./types.js";
9
+ import { getAgentConfig, getAvailableTypes, getAllTypes, resolveType, discoverNewAgents } from "./agent-types.js";
10
+ import type { AgentRecord, ThinkingLevel } from "./types.js";
11
+ import { SHORT_ID_LENGTH, CONFIG_AGENT_NON_MODEL_KEYS } from "./types.js";
12
+ import type { SpawnOptions } from "./agent-manager.js";
12
13
  import { ModelSelectorDialog, type ModelOption } from "./model-selector.js";
13
14
  import { ResultViewer, type ResultViewerStats } from "./result-viewer.js";
14
- import { getDisplayName } from "./ui/agent-widget.js";
15
+ import { getDisplayName } from "./format.js";
15
16
  import { buildSnapshotMarkdown } from "./context.js";
16
17
 
17
- import { parseModelKey } from "./utils.js";
18
+ import { parseModelKey, findModelInRegistry } from "./utils.js";
18
19
  import {
19
20
  __config,
20
21
  sessionOverrides,
21
- manager,
22
22
  piInstance,
23
- } from "./index.js";
23
+ sessionCtx,
24
+ agentActivity,
25
+ getManager,
26
+ getWidget,
27
+ } from "./state.js";
24
28
  import { resolveModel } from "./model-precedence.js";
25
- import { saveConfigAtomic, DEFAULT_CONFIG } from "./config-io.js";
29
+ import { createActivityTracker, backgroundAgentIds } from "./tool-execution.js";
30
+ import {
31
+ setModelOverride,
32
+ setDefaultModel,
33
+ clearModelOverride,
34
+ clearAllModelOverrides,
35
+ setForceBackground,
36
+ setShowCost,
37
+ setGraceTurns,
38
+ setWidgetCompact,
39
+ setWidgetMaxLines,
40
+ setWidgetMaxLinesCompact,
41
+ setWidgetShortcut,
42
+ setAgent,
43
+ setConcurrencyDefault,
44
+ setConcurrencyProvider,
45
+ setConcurrencyModel,
46
+ removeConcurrencyProvider,
47
+ removeConcurrencyModel,
48
+ resetConcurrency,
49
+ } from "./config-mutator.js";
26
50
 
27
51
  // ============================================================================
28
52
  // Helpers
@@ -113,45 +137,50 @@ async function applyModelOverride(
113
137
  }
114
138
 
115
139
  /**
116
- * Persist concurrency config to disk and apply to the running manager.
117
- */
118
- function applyConcurrencyConfig(): void {
119
- saveConfigAtomic(__config);
120
- manager?.setConcurrency(__config.concurrency);
121
- }
122
-
123
- /**
124
- * Parse a concurrency input: prompt, validate (integer ≥ 1), return parsed value or undefined.
140
+ * Prompt for numeric input, validate (integer min), return parsed value or undefined.
141
+ * Returns undefined if the user cancels or the value is invalid.
125
142
  */
126
- async function parseConcurrencyInput(
143
+ async function parseNumericInput(
127
144
  ctx: ExtensionCommandContext,
128
145
  label: string,
129
146
  initialValue: string,
147
+ min: number,
148
+ minLabel: string,
130
149
  ): Promise<number | undefined> {
131
150
  const input = await ctx.ui.input(label, initialValue);
132
151
  if (input === undefined) return undefined;
133
152
  const parsed = parseInt(input.trim(), 10);
134
- if (isNaN(parsed) || parsed < 1) {
135
- ctx.ui.notify("Invalid value — must be a number 1", "error");
153
+ if (isNaN(parsed) || parsed < min) {
154
+ ctx.ui.notify(`Invalid value — must be a number ${minLabel}`, "error");
136
155
  return undefined;
137
156
  }
138
157
  return parsed;
139
158
  }
140
159
 
141
160
  /**
142
- * Prompt for a concurrency value, validate, save and apply.
143
- * Used for editing an existing concurrency limit.
161
+ * Parse a concurrency input: prompt, validate (integer ≥ 1), return parsed value or undefined.
162
+ */
163
+ async function parseConcurrencyInput(
164
+ ctx: ExtensionCommandContext,
165
+ label: string,
166
+ initialValue: string,
167
+ ): Promise<number | undefined> {
168
+ return parseNumericInput(ctx, label, initialValue, 1, "≥ 1");
169
+ }
170
+
171
+ /**
172
+ * Prompt for a concurrency value, validate, and apply via setter.
173
+ * The setter handles save + sync internally.
144
174
  */
145
175
  async function promptConcurrencyInput(
146
176
  ctx: ExtensionCommandContext,
147
177
  label: string,
148
178
  currentValue: number,
149
- apply: (value: number) => void,
179
+ setter: (value: number) => void,
150
180
  ): Promise<void> {
151
181
  const parsed = await parseConcurrencyInput(ctx, label, String(currentValue));
152
182
  if (parsed === undefined) return;
153
- apply(parsed);
154
- applyConcurrencyConfig();
183
+ setter(parsed);
155
184
  ctx.ui.notify(
156
185
  `${label.replace("Concurrency slots for ", "")} concurrency set to ${parsed}`,
157
186
  "info",
@@ -160,16 +189,16 @@ async function promptConcurrencyInput(
160
189
 
161
190
  /**
162
191
  * Prompt to add a new concurrency limit for a named entity.
192
+ * Calls the setter which handles save + sync internally.
163
193
  */
164
194
  async function promptAddConcurrencyLimit(
165
195
  ctx: ExtensionCommandContext,
166
196
  label: string,
167
- apply: (key: string, value: number) => void,
197
+ setter: (key: string, value: number) => void,
168
198
  ): Promise<void> {
169
199
  const parsed = await parseConcurrencyInput(ctx, "Concurrency slots", "1");
170
200
  if (parsed === undefined) return;
171
- apply(label, parsed);
172
- applyConcurrencyConfig();
201
+ setter(label, parsed);
173
202
  ctx.ui.notify(`${label} concurrency set to ${parsed}`, "info");
174
203
  }
175
204
 
@@ -218,6 +247,96 @@ async function runMenuLoop(
218
247
  }
219
248
  }
220
249
 
250
+ // ============================================================================
251
+ // Worktree picker helpers
252
+ // ============================================================================
253
+
254
+ /** Timeout for git worktree list command (ms). */
255
+ const WORKTREE_LIST_TIMEOUT_MS = 5000;
256
+
257
+ /** Max display length for a worktree path before truncation. */
258
+ const WORKTREE_PATH_TRUNCATE_LEN = 60;
259
+
260
+ interface WorktreeEntry {
261
+ path: string;
262
+ branch: string | null;
263
+ isDetached: boolean;
264
+ }
265
+
266
+ /**
267
+ * Parse `git worktree list --porcelain` output into structured entries.
268
+ *
269
+ * Format (one block per worktree, separated by blank lines):
270
+ * worktree /path/to/worktree
271
+ * HEAD <sha>
272
+ * branch refs/heads/<name> (or: (detached))
273
+ */
274
+ function parseWorktreeList(output: string): WorktreeEntry[] {
275
+ const entries: WorktreeEntry[] = [];
276
+ const blocks = output.split(/\n\n+/);
277
+ for (const block of blocks) {
278
+ if (!block.trim()) continue;
279
+ const lines = block.split("\n");
280
+ let path = "";
281
+ let branch: string | null = null;
282
+ let isDetached = false;
283
+ for (const line of lines) {
284
+ if (line.startsWith("worktree ")) {
285
+ path = line.slice("worktree ".length);
286
+ } else if (line.startsWith("branch refs/heads/")) {
287
+ branch = line.slice("branch refs/heads/".length);
288
+ } else if (line === "detached") {
289
+ isDetached = true;
290
+ }
291
+ }
292
+ if (path) {
293
+ entries.push({ path, branch, isDetached });
294
+ }
295
+ }
296
+ return entries;
297
+ }
298
+
299
+ /** Truncate a path for display, keeping the tail. */
300
+ function truncatePath(p: string): string {
301
+ if (p.length <= WORKTREE_PATH_TRUNCATE_LEN) return p;
302
+ return "..." + p.slice(p.length - WORKTREE_PATH_TRUNCATE_LEN + 3);
303
+ }
304
+
305
+ /**
306
+ * Fetch worktrees via `git worktree list --porcelain`.
307
+ * Returns null if git is unavailable or the command fails.
308
+ */
309
+ async function listWorktrees(cwd: string): Promise<WorktreeEntry[] | null> {
310
+ try {
311
+ const result = await piInstance.exec(
312
+ "git",
313
+ ["worktree", "list", "--porcelain"],
314
+ { cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
315
+ );
316
+ if (result.code !== 0) return null;
317
+ return parseWorktreeList(result.stdout);
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Check whether a directory is inside a git repository.
325
+ * Uses `git rev-parse --git-common-dir` — the same strategy as the worktree validator.
326
+ */
327
+ async function isInGitRepo(cwd: string): Promise<boolean> {
328
+ try {
329
+ const result = await piInstance.exec(
330
+ "git",
331
+ ["rev-parse", "--git-common-dir"],
332
+ { cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
333
+ );
334
+ return result.code === 0 && result.stdout.trim() !== "";
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
221
340
  // ============================================================================
222
341
  // /agents command handler
223
342
  // ============================================================================
@@ -246,13 +365,12 @@ export async function showModelSettingsMenu(
246
365
 
247
366
  // Handle "clear" — remove all overrides (session + config) and save
248
367
  if (mode === "clear") {
249
- delete __config.agent[targetKey];
368
+ clearModelOverride(targetKey);
250
369
  if (targetKey !== "default") {
251
370
  delete sessionOverrides[targetKey];
252
371
  } else {
253
372
  sessionOverrides.default = null;
254
373
  }
255
- saveConfigAtomic(__config);
256
374
  ctx.ui.notify(`${label} overrides cleared`, "info");
257
375
  return;
258
376
  }
@@ -264,12 +382,9 @@ export async function showModelSettingsMenu(
264
382
  isSession
265
383
  ? (chosen) => { sessionOverrides[targetKey] = chosen; }
266
384
  : (chosen) => {
267
- __config.agent[targetKey] = chosen;
385
+ setModelOverride(targetKey, chosen);
268
386
  },
269
387
  );
270
- if (!isSession) {
271
- saveConfigAtomic(__config);
272
- }
273
388
  };
274
389
 
275
390
  // Global default — show session value if present
@@ -293,31 +408,28 @@ export async function showModelSettingsMenu(
293
408
  : "Force background · OFF";
294
409
  items.push(forceBgLabel);
295
410
  actions.push(async () => {
296
- __config.agent.forceBackground = !__config.agent.forceBackground;
297
- saveConfigAtomic(__config);
411
+ setForceBackground(!__config.agent.forceBackground);
298
412
  ctx.ui.notify(
299
413
  `Force background ${__config.agent.forceBackground ? "ON" : "OFF"}`,
300
414
  "info",
301
415
  );
302
416
  });
303
417
 
418
+ // Cost display toggle
419
+ const showCost = __config.agent.showCost === true; // default false
420
+ items.push(`Cost display · ${showCost ? "ON" : "OFF"}`);
421
+ actions.push(async () => {
422
+ setShowCost(!showCost);
423
+ ctx.ui.notify(`Cost display ${showCost ? "OFF" : "ON"}`, "info");
424
+ });
425
+
304
426
  // Grace turns setting
305
427
  const graceTurns = __config.agent.graceTurns ?? 6;
306
428
  items.push(`Grace turns · ${graceTurns}`);
307
429
  actions.push(async () => {
308
- const input = await ctx.ui.input("Grace turns (≥ 0)", String(graceTurns));
309
- if (input === undefined) return;
310
- const parsed = parseInt(input.trim(), 10);
311
- if (isNaN(parsed)) {
312
- ctx.ui.notify("Invalid value — must be a number", "error");
313
- return;
314
- }
315
- if (parsed < 0) {
316
- ctx.ui.notify("Invalid value — must be ≥ 0", "error");
317
- return;
318
- }
319
- __config.agent.graceTurns = parsed;
320
- saveConfigAtomic(__config);
430
+ const parsed = await parseNumericInput(ctx, "Grace turns (≥ 0)", String(graceTurns), 0, "≥ 0");
431
+ if (parsed === undefined) return;
432
+ setGraceTurns(parsed);
321
433
  ctx.ui.notify(`Grace turns set to ${parsed}`, "info");
322
434
  });
323
435
 
@@ -395,21 +507,13 @@ export async function showModelSettingsMenu(
395
507
  items.push("Clear all overrides");
396
508
  actions.push(async () => {
397
509
  const hasOverrides = Object.entries(__config.agent).some(
398
- ([k, v]) => k !== "default" && k !== "forceBackground" && k !== "graceTurns" && v != null,
510
+ ([k, v]) => !CONFIG_AGENT_NON_MODEL_KEYS.includes(k) && v != null,
399
511
  );
400
512
  if (!hasOverrides && __config.agent.default === null) {
401
513
  ctx.ui.notify("No overrides to clear", "info");
402
514
  return;
403
515
  }
404
- const preserved: Record<string, unknown> = {
405
- default: __config.agent.default,
406
- forceBackground: __config.agent.forceBackground,
407
- };
408
- if (__config.agent.graceTurns != null) {
409
- preserved.graceTurns = __config.agent.graceTurns;
410
- }
411
- __config.agent = preserved as typeof __config.agent;
412
- saveConfigAtomic(__config);
516
+ clearAllModelOverrides();
413
517
  ctx.ui.notify("All model overrides cleared", "info");
414
518
  });
415
519
 
@@ -417,33 +521,340 @@ export async function showModelSettingsMenu(
417
521
  });
418
522
  }
419
523
 
420
- export async function showAgentsMainMenu(
524
+ /** Map menu choice to handler. Matches by number prefix or first word. */
525
+ function matchMenuChoice(
526
+ choice: string,
527
+ handlers: Record<string, () => Promise<void>>,
528
+ ): (() => Promise<void>) | undefined {
529
+ // Try number prefix first (e.g., "1." from "1. Running agents")
530
+ const numMatch = choice.match(/^(\d+)/);
531
+ if (numMatch) return handlers[numMatch[1]];
532
+ // Fall back to first word
533
+ const key = choice.split(" ")[0].toLowerCase();
534
+ return handlers[key];
535
+ }
536
+
537
+ // ============================================================================
538
+ // Spawn agent menu
539
+ // ============================================================================
540
+
541
+ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
542
+
543
+ /**
544
+ * Show the spawn agent flow: type selection → prompt → options sub-menu → spawn.
545
+ * Escape at any step aborts the flow and returns to the main menu.
546
+ */
547
+ export async function showSpawnAgentMenu(
548
+ ctx: ExtensionCommandContext,
549
+ modelOptions: string[],
550
+ ): Promise<void> {
551
+ // Step 1: Type selection loop (unknown type → error → retry)
552
+ let selectedType: string;
553
+ while (true) {
554
+ const types = getAvailableTypes();
555
+ if (types.length === 0) {
556
+ ctx.ui.notify("No agent types available", "error");
557
+ return;
558
+ }
559
+ const type = await ctx.ui.select("Select agent type", types);
560
+ if (type === undefined) return; // Escape → main menu
561
+
562
+ const config = getAgentConfig(type);
563
+ if (!config) {
564
+ ctx.ui.notify(`Unknown agent type: ${type}`, "error");
565
+ continue; // Loop back to type selection
566
+ }
567
+ selectedType = type;
568
+ break;
569
+ }
570
+
571
+ const agentConfig = getAgentConfig(selectedType)!;
572
+
573
+ // Step 2: Prompt entry loop (empty prompt → error → retry)
574
+ let prompt: string;
575
+ while (true) {
576
+ const input = await ctx.ui.input("Agent prompt");
577
+ if (input === undefined) return; // Escape → main menu
578
+
579
+ if (!input.trim()) {
580
+ ctx.ui.notify("Prompt cannot be empty", "error");
581
+ continue; // Loop back to prompt input
582
+ }
583
+ prompt = input.trim();
584
+ break;
585
+ }
586
+
587
+ // Step 3: Options sub-menu with spawn action
588
+ const autoDescription = prompt.length > 50 ? prompt.slice(0, 50) : prompt;
589
+ let description = autoDescription;
590
+
591
+ // Check if parent's cwd is inside a git repo (for worktree picker visibility)
592
+ const parentCwd = sessionCtx?.cwd ?? "";
593
+ const inGitRepo = parentCwd ? await isInGitRepo(parentCwd) : false;
594
+
595
+ // Worktree picker state
596
+ let currentWorktreePath: string | undefined;
597
+ let currentWorktreeLabel = "Inherits parent cwd";
598
+
599
+ // Pre-fill model from precedence chain
600
+ const parentModelId = sessionCtx?.model
601
+ ? `${sessionCtx.model.provider}/${sessionCtx.model.id}`
602
+ : "";
603
+ const effectiveModelStr = resolveModel({
604
+ subagentType: selectedType,
605
+ agentConfig,
606
+ config: __config,
607
+ parentModelId,
608
+ sessionOverrides,
609
+ });
610
+ let currentModelStr = effectiveModelStr || ""; // "" means inherit parent
611
+ let currentThinking: ThinkingLevel | undefined = agentConfig.thinking;
612
+ let currentMaxTurns: number | undefined = agentConfig.maxTurns;
613
+ let currentGraceTurns: number | undefined = __config.agent.graceTurns ?? 6;
614
+ let currentBackground: boolean = __config.agent.forceBackground ?? false;
615
+
616
+ while (true) {
617
+ const displayModel = currentModelStr || "(inherits parent)";
618
+ const displayThinking = currentThinking ?? "inherit";
619
+ const displayMaxTurns = currentMaxTurns != null ? String(currentMaxTurns) : "unlimited";
620
+ const displayGraceTurns = String(currentGraceTurns ?? 6);
621
+ const displayBackground = currentBackground ? "ON" : "OFF";
622
+
623
+ const items = [
624
+ "Spawn",
625
+ "",
626
+ `Model · ${displayModel}`,
627
+ `Background · ${displayBackground}`,
628
+ `Thinking · ${displayThinking}`,
629
+ `Max turns · ${displayMaxTurns}`,
630
+ `Grace turns · ${displayGraceTurns}`,
631
+ `Description · ${description}`,
632
+ ];
633
+
634
+ if (inGitRepo) {
635
+ items.push(`Worktree · ${currentWorktreeLabel}`);
636
+ };
637
+
638
+ const choice = await ctx.ui.select("Spawn Options", items);
639
+ if (choice === undefined) return; // Escape → main menu
640
+
641
+ if (choice === "Spawn") {
642
+ // Resolve model string to Model object
643
+ let model: ReturnType<typeof findModelInRegistry> = undefined;
644
+ let modelKey: string | undefined;
645
+
646
+ if (currentModelStr) {
647
+ const registry = sessionCtx?.modelRegistry ?? ctx.modelRegistry;
648
+ model = findModelInRegistry(currentModelStr, registry, undefined);
649
+ if (!model) {
650
+ ctx.ui.notify(`Model not found: ${currentModelStr}`, "error");
651
+ continue; // Return to options sub-menu
652
+ }
653
+ modelKey = `${model.provider}/${model.id}`;
654
+ }
655
+
656
+ // Discover worktree-local agent types before spawn
657
+ if (currentWorktreePath) {
658
+ await discoverNewAgents(`${currentWorktreePath}/.pi/agents`);
659
+ }
660
+ // Resolve type (may have been discovered from worktree)
661
+ const resolvedType = resolveType(selectedType) ?? selectedType;
662
+
663
+ const spawnOptions: SpawnOptions = {
664
+ description,
665
+ model,
666
+ maxTurns: currentMaxTurns,
667
+ thinkingLevel: currentThinking,
668
+ isBackground: currentBackground,
669
+ modelKey,
670
+ invocation: {
671
+ modelName: model?.id,
672
+ thinking: currentThinking,
673
+ maxTurns: currentMaxTurns,
674
+ runInBackground: currentBackground,
675
+ },
676
+ graceTurns: currentGraceTurns,
677
+ worktreePath: currentWorktreePath,
678
+ worktreeLabel: currentWorktreePath ? currentWorktreeLabel : undefined,
679
+ };
680
+
681
+ const { state: activityState, callbacks } = createActivityTracker(currentMaxTurns);
682
+
683
+ let agentId: string;
684
+ try {
685
+ agentId = getManager().spawn(piInstance, sessionCtx, resolvedType, prompt, {
686
+ ...spawnOptions,
687
+ ...callbacks,
688
+ });
689
+ } catch (err) {
690
+ ctx.ui.notify(
691
+ `Spawn failed: ${err instanceof Error ? err.message : String(err)}`,
692
+ "error",
693
+ );
694
+ return; // Return to main menu
695
+ }
696
+
697
+ // Wire activity tracking for widget
698
+ agentActivity.set(agentId, activityState);
699
+ // Set UI context so widget can render (same as tool_execution_start handler)
700
+ const widget = getWidget();
701
+ if (widget) {
702
+ widget.setUICtx(ctx.ui as unknown as import("./ui/agent-widget.js").UICtx);
703
+ widget.ensureTimer();
704
+ widget.update();
705
+ }
706
+
707
+ if (currentBackground) {
708
+ backgroundAgentIds.add(agentId);
709
+ return; // Background: return to main menu immediately
710
+ }
711
+
712
+ // Foreground: block until completion
713
+ const fgRecord = getManager().getRecord(agentId);
714
+ if (fgRecord?.execution?.promise) {
715
+ await fgRecord.execution.promise;
716
+ }
717
+
718
+ agentActivity.delete(agentId);
719
+ getWidget()?.markFinished(agentId);
720
+ getWidget()?.update();
721
+
722
+ return; // Return to main menu
723
+ }
724
+
725
+ // Handle option changes
726
+ if (choice.startsWith("Description")) {
727
+ const input = await ctx.ui.input("Description", description);
728
+ if (input !== undefined && input.trim()) {
729
+ description = input.trim();
730
+ }
731
+ } else if (choice.startsWith("Model")) {
732
+ const chosen = await promptModelSelection(
733
+ ctx, modelOptions, currentModelStr || "(inherits parent)",
734
+ );
735
+ if (chosen !== null) {
736
+ currentModelStr = chosen === "(inherits parent)" ? "" : chosen;
737
+ }
738
+ } else if (choice.startsWith("Thinking")) {
739
+ const allLevels = [...THINKING_LEVELS, "inherit"];
740
+ const chosen = await ctx.ui.select("Thinking level", allLevels);
741
+ if (chosen !== undefined) {
742
+ currentThinking = chosen === "inherit" ? undefined : (chosen as ThinkingLevel);
743
+ }
744
+ } else if (choice.startsWith("Max turns")) {
745
+ const initial = currentMaxTurns != null ? String(currentMaxTurns) : "unlimited";
746
+ const input = await ctx.ui.input("Max turns (number or 'unlimited')", initial);
747
+ if (input !== undefined) {
748
+ const trimmed = input.trim().toLowerCase();
749
+ if (trimmed === "unlimited" || trimmed === "") {
750
+ currentMaxTurns = undefined;
751
+ } else {
752
+ const parsed = parseInt(trimmed, 10);
753
+ if (isNaN(parsed) || parsed < 1) {
754
+ ctx.ui.notify("Invalid value — must be a number ≥ 1 or 'unlimited'", "error");
755
+ } else {
756
+ currentMaxTurns = parsed;
757
+ }
758
+ }
759
+ }
760
+ } else if (choice.startsWith("Grace turns")) {
761
+ const parsed = await parseNumericInput(ctx, "Grace turns (≥ 0)", String(currentGraceTurns ?? 6), 0, "≥ 0");
762
+ if (parsed !== undefined) currentGraceTurns = parsed;
763
+ } else if (choice.startsWith("Background")) {
764
+ currentBackground = !currentBackground;
765
+ } else if (choice.startsWith("Worktree") && inGitRepo) {
766
+ // Open worktree picker
767
+ const worktrees = await listWorktrees(parentCwd);
768
+ if (!worktrees || worktrees.length === 0) {
769
+ ctx.ui.notify(
770
+ "No worktrees found or git worktree list unavailable",
771
+ "error",
772
+ );
773
+ continue; // Return to options sub-menu
774
+ }
775
+
776
+ const pickerItems = [
777
+ "Inherits parent cwd",
778
+ ...worktrees.map(wt => {
779
+ const branchLabel = wt.isDetached ? "detached" : (wt.branch ?? "detached");
780
+ const truncPath = truncatePath(wt.path);
781
+ return `${branchLabel} · ${truncPath}`;
782
+ }),
783
+ ];
784
+
785
+ const picked = await ctx.ui.select("Select worktree", pickerItems);
786
+ if (picked === undefined) continue; // Escape → return to options sub-menu
787
+
788
+ if (picked === "Inherits parent cwd") {
789
+ currentWorktreePath = undefined;
790
+ currentWorktreeLabel = "Inherits parent cwd";
791
+ } else {
792
+ // Find the matching worktree by index (offset by "Inherits parent cwd")
793
+ const idx = pickerItems.indexOf(picked) - 1;
794
+ if (idx >= 0 && idx < worktrees.length) {
795
+ const wt = worktrees[idx];
796
+ currentWorktreePath = wt.path;
797
+ currentWorktreeLabel = wt.branch ?? "detached";
798
+ }
799
+ }
800
+ }
801
+ }
802
+ }
803
+
804
+ export async function showSettingsMenu(
421
805
  ctx: ExtensionCommandContext,
422
806
  modelOptions: string[],
423
807
  ): Promise<void> {
424
808
  const menuItems = [
425
809
  "1. Model settings — Set global default and per-type model overrides",
426
810
  "2. Concurrency settings — Set per-model slot limits",
427
- "3. Running agentsList running/queued agents",
811
+ "3. Widget settingsConfigure widget display options",
812
+ "",
813
+ "Back",
814
+ ];
815
+
816
+ const handlers: Record<string, () => Promise<void>> = {
817
+ "1": () => showModelSettingsMenu(ctx, modelOptions),
818
+ "2": () => showConcurrencySettingsMenu(ctx, modelOptions),
819
+ "3": () => showWidgetSettingsMenu(ctx),
820
+ };
821
+
822
+ while (true) {
823
+ const choice = await ctx.ui.select("Settings", menuItems);
824
+ if (choice === undefined || choice === "Back") return;
825
+
826
+ const action = matchMenuChoice(choice, handlers);
827
+ if (action) await action();
828
+ }
829
+ }
830
+
831
+ export async function showAgentsMainMenu(
832
+ ctx: ExtensionCommandContext,
833
+ modelOptions: string[],
834
+ ): Promise<void> {
835
+ const menuItems = [
836
+ "1. Running agents — List running/queued agents",
837
+ "2. Spawn agent — Manually spawn a new agent",
838
+ "3. Settings — Model, concurrency, and widget settings",
428
839
  "4. Debug — Agent types, briefing, diagnostics",
429
840
  "",
430
841
  "Press Escape to close",
431
842
  ];
432
843
 
844
+ const handlers: Record<string, () => Promise<void>> = {
845
+ "1": () => showRunningAgentsMenu(ctx),
846
+ "2": () => showSpawnAgentMenu(ctx, modelOptions),
847
+ "3": () => showSettingsMenu(ctx, modelOptions),
848
+ "4": () => showDebugMenu(ctx),
849
+ };
850
+
433
851
  // Loop so sub-menus navigate back to root; only Escape at root closes
434
852
  while (true) {
435
853
  const choice = await ctx.ui.select("Subagents Management", menuItems);
436
854
  if (choice === undefined || choice === "Press Escape to close") return;
437
855
 
438
- if (choice.startsWith("1.")) {
439
- await showModelSettingsMenu(ctx, modelOptions);
440
- } else if (choice.startsWith("2.")) {
441
- await showConcurrencySettingsMenu(ctx, modelOptions);
442
- } else if (choice.startsWith("3.")) {
443
- await showRunningAgentsMenu(ctx);
444
- } else if (choice.startsWith("4.")) {
445
- await showDebugMenu(ctx);
446
- }
856
+ const action = matchMenuChoice(choice, handlers);
857
+ if (action) await action();
447
858
  }
448
859
  }
449
860
 
@@ -453,18 +864,65 @@ async function showDebugMenu(ctx: ExtensionCommandContext): Promise<void> {
453
864
  "2. Agent briefing — Send agent types/capabilities info to LLM (Optional, if having issues)",
454
865
  ];
455
866
 
867
+ const handlers: Record<string, () => Promise<void>> = {
868
+ "1": () => showAgentTypes(ctx),
869
+ "2": () => handleAgentBriefing(ctx),
870
+ };
871
+
456
872
  while (true) {
457
873
  const choice = await ctx.ui.select("Debug", menuItems);
458
874
  if (choice === undefined) return;
459
875
 
460
- if (choice.startsWith("1.")) {
461
- await showAgentTypes(ctx);
462
- } else if (choice.startsWith("2.")) {
463
- await handleAgentBriefing(ctx);
464
- }
876
+ const action = matchMenuChoice(choice, handlers);
877
+ if (action) await action();
465
878
  }
466
879
  }
467
880
 
881
+ export async function showWidgetSettingsMenu(ctx: ExtensionCommandContext): Promise<void> {
882
+ return runMenuLoop(ctx, "Widget Settings", () => {
883
+ const items: string[] = [];
884
+ const actions: Array<() => Promise<void>> = [];
885
+
886
+ // Force compact mode toggle
887
+ const isForceCompact = __config.agent.widgetCompact === true;
888
+ items.push(`Force compact mode · ${isForceCompact ? "ON" : "OFF"}`);
889
+ actions.push(async () => {
890
+ setWidgetCompact(!isForceCompact);
891
+ ctx.ui.notify(`Force compact mode ${__config.agent.widgetCompact ? "ON" : "OFF"}`, "info");
892
+ });
893
+
894
+ // Max lines (full mode)
895
+ const maxLines = __config.agent.widgetMaxLines ?? 12;
896
+ items.push(`Max lines (full) · ${maxLines}`);
897
+ actions.push(async () => {
898
+ const parsed = await parseNumericInput(ctx, "Max lines (full mode, ≥ 2)", String(maxLines), 2, "≥ 2");
899
+ if (parsed === undefined) return;
900
+ setWidgetMaxLines(parsed);
901
+ ctx.ui.notify(`Max lines (full) set to ${parsed}`, "info");
902
+ });
903
+
904
+ // Max lines (compact mode)
905
+ const maxLinesCompact = __config.agent.widgetMaxLinesCompact ?? Math.floor(maxLines / 2);
906
+ items.push(`Max lines (compact) · ${maxLinesCompact}`);
907
+ actions.push(async () => {
908
+ const parsed = await parseNumericInput(ctx, "Max lines (compact mode, ≥ 1)", String(maxLinesCompact), 1, "≥ 1");
909
+ if (parsed === undefined) return;
910
+ setWidgetMaxLinesCompact(parsed);
911
+ ctx.ui.notify(`Max lines (compact) set to ${parsed}`, "info");
912
+ });
913
+
914
+ // Ctrl+o shortcut toggle
915
+ const shortcutEnabled = __config.agent.widgetShortcut === true;
916
+ items.push(`Ctrl+o shortcut · ${shortcutEnabled ? "ON" : "OFF"}`);
917
+ actions.push(async () => {
918
+ setWidgetShortcut(!shortcutEnabled);
919
+ ctx.ui.notify(`Ctrl+o shortcut ${__config.agent.widgetShortcut ? "ON" : "OFF"}`, "info");
920
+ });
921
+
922
+ return { items, actions };
923
+ });
924
+ }
925
+
468
926
  async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void> {
469
927
  const types = getAvailableTypes();
470
928
  const agents = types.map((t) => ({ name: t, config: getAgentConfig(t) }));
@@ -501,6 +959,7 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
501
959
  lines.push("| `agent` | Which agent type to use (default: general-purpose) |");
502
960
  lines.push("| `thinking` | Optional thinking mode override (e.g., `off`, `minimal`, `low`, `medium`, `high`, `xhigh`) |");
503
961
  lines.push("| `run_in_background` | When `true`, result is auto-delivered — do NOT poll. Continue working while waiting. |");
962
+ lines.push("| `worktree_path` | Optional path to a git worktree of the parent's repo. See below for details. |");
504
963
  lines.push("");
505
964
 
506
965
  // Usage guidelines
@@ -508,6 +967,15 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
508
967
  lines.push("- Agents start fresh with their config — they do NOT inherit the parent conversation");
509
968
  lines.push("- For parallel tasks, spawn multiple `run_in_background: true` agents in one turn");
510
969
  lines.push(" → Results are auto-delivered — do NOT poll, the result will arrive when ready");
970
+ lines.push("");
971
+ lines.push("## `worktree_path` Parameter\n");
972
+ lines.push("Use `worktree_path` to run a subagent in a different git worktree of the parent's repository.");
973
+ lines.push("");
974
+ lines.push("- **Optional.** Omit to run the subagent in the parent's working directory (default behavior).");
975
+ lines.push("- **Must be a path** inside a git worktree of the parent's repo, including the main checkout. Not a different repo, not a non-git directory.");
976
+ lines.push("- **Relative paths** are resolved against the parent's working directory.");
977
+ lines.push("- **On failure** the validator returns a specific reason (e.g., 'not a worktree of the parent's repository', 'path does not exist') — use this to self-correct.");
978
+ lines.push("- **Agent type discovery:** The worktree's `.pi/agents/` directory is scanned for agent types when this param is set, so worktree-local types become available to that spawn.");
511
979
  piInstance.sendUserMessage(lines.join("\n"));
512
980
  ctx.ui.notify("Agent briefing sent to LLM", "info");
513
981
  }
@@ -515,6 +983,7 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
515
983
  /**
516
984
  * Build a sub-menu for a single per-provider or per-model entry:
517
985
  * "Edit limit" to change the value, or "Remove limit" to delete it.
986
+ * Callers pass setter callbacks that handle save + sync internally.
518
987
  */
519
988
  async function editOrRemoveConcurrencyEntry(
520
989
  ctx: ExtensionCommandContext,
@@ -522,8 +991,8 @@ async function editOrRemoveConcurrencyEntry(
522
991
  entityType: "provider" | "model",
523
992
  entityKey: string,
524
993
  currentValue: number,
525
- applyUpdate: (value: number) => void,
526
- applyRemove: () => void,
994
+ setEntry: (key: string, value: number) => void,
995
+ removeEntry: () => void,
527
996
  ): Promise<void> {
528
997
  await runMenu(ctx, `${entityKey} concurrency`, [
529
998
  "Edit limit",
@@ -531,13 +1000,12 @@ async function editOrRemoveConcurrencyEntry(
531
1000
  ], [
532
1001
  async () => {
533
1002
  await promptConcurrencyInput(
534
- ctx, label, currentValue,
535
- applyUpdate,
1003
+ ctx, entityKey, currentValue,
1004
+ (value) => setEntry(entityKey, value),
536
1005
  );
537
1006
  },
538
1007
  async () => {
539
- applyRemove();
540
- applyConcurrencyConfig();
1008
+ removeEntry();
541
1009
  ctx.ui.notify(
542
1010
  `Removed per-${entityType} limit for ${entityKey}`,
543
1011
  "info",
@@ -561,15 +1029,14 @@ export async function showConcurrencySettingsMenu(
561
1029
  actions.push(async () => {
562
1030
  await promptConcurrencyInput(
563
1031
  ctx, "Default limit", __config.concurrency.default,
564
- (value) => { __config.concurrency.default = value; },
1032
+ (value) => setConcurrencyDefault(value),
565
1033
  );
566
1034
  });
567
1035
 
568
1036
  // Reset all to defaults
569
1037
  items.push("Reset all to defaults");
570
1038
  actions.push(async () => {
571
- __config.concurrency = { ...DEFAULT_CONFIG.concurrency };
572
- applyConcurrencyConfig();
1039
+ resetConcurrency();
573
1040
  ctx.ui.notify("Concurrency reset to defaults", "info");
574
1041
  });
575
1042
 
@@ -592,16 +1059,8 @@ export async function showConcurrencySettingsMenu(
592
1059
  "provider",
593
1060
  provider,
594
1061
  limit,
595
- (value) => {
596
- const current = __config.concurrency.providers ?? {};
597
- __config.concurrency.providers = { ...current, [provider]: value };
598
- },
599
- () => {
600
- const providers = __config.concurrency.providers;
601
- if (providers) {
602
- delete providers[provider];
603
- }
604
- },
1062
+ (key, value) => setConcurrencyProvider(key, value),
1063
+ () => removeConcurrencyProvider(provider),
605
1064
  );
606
1065
  });
607
1066
  }
@@ -614,10 +1073,7 @@ export async function showConcurrencySettingsMenu(
614
1073
  if (provider === undefined) return;
615
1074
  await promptAddConcurrencyLimit(
616
1075
  ctx, provider,
617
- (key, value) => {
618
- const current = __config.concurrency.providers ?? {};
619
- __config.concurrency.providers = { ...current, [key]: value };
620
- },
1076
+ (key, value) => setConcurrencyProvider(key, value),
621
1077
  );
622
1078
  });
623
1079
 
@@ -640,16 +1096,8 @@ export async function showConcurrencySettingsMenu(
640
1096
  "model",
641
1097
  modelKey,
642
1098
  limit,
643
- (value) => {
644
- const current = __config.concurrency.models ?? {};
645
- __config.concurrency.models = { ...current, [modelKey]: value };
646
- },
647
- () => {
648
- const models = __config.concurrency.models;
649
- if (models) {
650
- delete models[modelKey];
651
- }
652
- },
1099
+ (key, value) => setConcurrencyModel(key, value),
1100
+ () => removeConcurrencyModel(modelKey),
653
1101
  );
654
1102
  });
655
1103
  }
@@ -664,10 +1112,7 @@ export async function showConcurrencySettingsMenu(
664
1112
  if (modelKey === null) return;
665
1113
  await promptAddConcurrencyLimit(
666
1114
  ctx, modelKey.trim(),
667
- (key, value) => {
668
- const current = __config.concurrency.models ?? {};
669
- __config.concurrency.models = { ...current, [key]: value };
670
- },
1115
+ (key, value) => setConcurrencyModel(key, value),
671
1116
  );
672
1117
  });
673
1118
 
@@ -678,27 +1123,31 @@ export async function showConcurrencySettingsMenu(
678
1123
  async function showRunningAgentsMenu(
679
1124
  ctx: ExtensionCommandContext,
680
1125
  ): Promise<void> {
681
- const records = manager?.listAgents() ?? [];
1126
+ const records = getManager()?.listAgents() ?? [];
682
1127
  if (records.length === 0) {
683
1128
  ctx.ui.notify("No agents have been spawned this session", "info");
684
1129
  return;
685
1130
  }
686
1131
 
687
1132
  return runMenuLoop(ctx, "Running Agents", () => {
688
- const records = manager?.listAgents() ?? [];
689
- const running = records.filter((r) => r.status === "running" || r.status === "queued");
1133
+ const records = getManager()?.listAgents() ?? [];
1134
+ const running = records.filter((r) => r.lifecycle.status === "running" || r.lifecycle.status === "queued");
690
1135
 
691
1136
  const items: string[] = [];
692
1137
  const actions: Array<() => Promise<void>> = [];
693
1138
 
694
1139
  for (const record of records) {
695
- const elapsed = Math.round((Date.now() - record.startedAt) / 1000);
696
- const statusIcon = record.status === "running" ? "▶" :
697
- record.status === "completed" ? "✓" :
698
- record.status === "queued" ? "⏳" :
699
- record.status === "error" ? "✗" : "•";
1140
+ const elapsed = Math.round((Date.now() - record.lifecycle.startedAt) / 1000);
1141
+ const statusIcon = record.lifecycle.status === "running" ? "▶" :
1142
+ record.lifecycle.status === "completed" ? "✓" :
1143
+ record.lifecycle.status === "queued" ? "⏳" :
1144
+ record.lifecycle.status === "error" ? "✗" : "•";
1145
+ const headline = record.display.description
1146
+ ? (record.display.description.length > 50 ? record.display.description.slice(0, 47) + "..." : record.display.description)
1147
+ : "";
1148
+ const suffix = headline ? ` — ${headline}` : "";
700
1149
  items.push(
701
- `${statusIcon} ${record.id.slice(0, SHORT_ID_LENGTH)} ${record.type} ${record.status} ${elapsed}s`,
1150
+ `${statusIcon} ${record.id.slice(0, SHORT_ID_LENGTH)} ${record.display.type} ${record.lifecycle.status} ${elapsed}s${suffix}`,
702
1151
  );
703
1152
 
704
1153
  actions.push(async () => {
@@ -715,7 +1164,7 @@ async function showRunningAgentsMenu(
715
1164
  items.push(`Stop ${running.length} running agent(s)`);
716
1165
  actions.push(async () => {
717
1166
  for (const record of running) {
718
- manager?.abort(record.id);
1167
+ getManager()?.abort(record.id);
719
1168
  }
720
1169
  ctx.ui.notify(`Stopped ${running.length} agent(s)`, "info");
721
1170
  });
@@ -741,19 +1190,19 @@ async function showResultViewer(
741
1190
  ? `snapshot \u00b7 ${record.id.slice(0, SHORT_ID_LENGTH)}`
742
1191
  : "Error";
743
1192
  const stats: ResultViewerStats = {
744
- lifetimeUsage: record.lifetimeUsage,
745
- turnCount: record.turnCount,
746
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
1193
+ lifetimeUsage: record.stats.lifetimeUsage,
1194
+ turnCount: record.stats.turnCount,
1195
+ durationMs: (record.lifecycle.completedAt ?? Date.now()) - record.lifecycle.startedAt,
747
1196
  };
748
1197
  const refreshCallback =
749
- kind === "snapshot" && record.session
750
- ? () => buildSnapshotMarkdown(record.session!.messages)
1198
+ kind === "snapshot" && record.execution.session
1199
+ ? () => buildSnapshotMarkdown(record.execution.session!.messages)
751
1200
  : undefined;
752
1201
 
753
1202
  await ctx.ui.custom<void>(
754
1203
  (tui, theme, _kb, done) =>
755
1204
  new ResultViewer(
756
- `${getDisplayName(record.type)} · ${titleSuffix}`,
1205
+ `${getDisplayName(record.display.type)} · ${titleSuffix}`,
757
1206
  text,
758
1207
  { onClose: () => done(), onRefresh: refreshCallback },
759
1208
  theme,
@@ -770,16 +1219,16 @@ async function steerAgentById(
770
1219
  agentId: string,
771
1220
  ctx: ExtensionCommandContext,
772
1221
  ): Promise<void> {
773
- const record = manager?.getRecord(agentId);
1222
+ const record = getManager()?.getRecord(agentId);
774
1223
  if (!record) {
775
1224
  ctx.ui.notify("Agent not found", "error");
776
1225
  return;
777
1226
  }
778
1227
 
779
- const message = await ctx.ui.input(`Steer ${record.type}`);
1228
+ const message = await ctx.ui.input(`Steer ${record.display.type}`);
780
1229
  if (!message?.trim()) return;
781
1230
 
782
- const sent = await manager.steer(agentId, message.trim());
1231
+ const sent = await getManager().steer(agentId, message.trim());
783
1232
  if (sent) {
784
1233
  ctx.ui.notify(`Steer sent to ${record.id.slice(0, SHORT_ID_LENGTH)}…`, "info");
785
1234
  } else {
@@ -798,16 +1247,16 @@ export async function showAgentActions(
798
1247
  const items: string[] = [];
799
1248
  const actions: Array<() => Promise<void>> = [];
800
1249
 
801
- const isRunning = record.status === "running" || record.status === "queued";
802
- const hasSession = !!record.session;
1250
+ const isRunning = record.lifecycle.status === "running" || record.lifecycle.status === "queued";
1251
+ const hasSession = !!record.execution.session;
803
1252
  const hasResult = !!record.result && record.result.length > 0;
804
1253
  const hasError = !!record.error && record.error.length > 0;
805
1254
 
806
1255
  // View actions first
807
- if (record.status === "running" && hasSession) {
1256
+ if (record.lifecycle.status === "running" && hasSession) {
808
1257
  items.push("View snapshot");
809
1258
  actions.push(async () => {
810
- const messages = record.session!.messages;
1259
+ const messages = record.execution.session!.messages;
811
1260
  const markdown = buildSnapshotMarkdown(messages);
812
1261
  await showResultViewer(ctx, record, "snapshot", markdown);
813
1262
  });
@@ -836,7 +1285,7 @@ export async function showAgentActions(
836
1285
 
837
1286
  items.push("Stop");
838
1287
  actions.push(async () => {
839
- manager?.abort(record.id);
1288
+ getManager()?.abort(record.id);
840
1289
  ctx.ui.notify(`Stopped ${record.id.slice(0, SHORT_ID_LENGTH)}`, "info");
841
1290
  });
842
1291
  }