pi-subagents-lite 1.4.1 → 1.4.3

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.
@@ -20,10 +20,8 @@ import type { AgentManager } from "../agents/agent-manager.js";
20
20
  import { CONFIG_AGENT_NON_MODEL_KEYS } from "./types.js";
21
21
  import type { SystemPromptMode } from "../agents/types.js";
22
22
  import type { ThinkingLevel } from "../types.js";
23
- import { DEFAULT_CONFIG, loadConfig, saveConfigAtomic } from "./config-io.js";
23
+ import { VALID_SYSTEM_PROMPT_MODES, DEFAULT_CONCURRENCY, loadConfig, saveConfigAtomic } from "./config-io.js";
24
24
 
25
- /** Valid values for systemPromptMode — checked once at module load. */
26
- const VALID_SYSTEM_PROMPT_MODES = new Set<string>(["replace", "inherit", "custom"]);
27
25
 
28
26
  /** Injected persistence adapter. Swap for an in-memory adapter in tests. */
29
27
  export interface ConfigIO {
@@ -76,6 +74,8 @@ export interface ResolvedAgentSettings {
76
74
  readonly showContext: boolean;
77
75
  /** Whether to show elapsed time in widget stats line. */
78
76
  readonly showTime: boolean;
77
+ /** Buffer size for streaming thinking blocks to output file. 0 = disabled. */
78
+ readonly outputThinkingBufferSize: number;
79
79
  }
80
80
 
81
81
  /** Side-effect targets, injected after construction. */
@@ -106,29 +106,22 @@ export class ConfigStore {
106
106
 
107
107
  get agent(): ResolvedAgentSettings {
108
108
  const a = this.config.agent;
109
- const widgetMaxLines = a.widgetMaxLines ?? DEFAULT_CONFIG.agent.widgetMaxLines ?? 12;
109
+ const widgetMaxLines = a.widgetMaxLines!; // guaranteed by loadConfig default merge
110
110
  const widgetMaxLinesCompact = a.widgetMaxLinesCompact ?? Math.floor(widgetMaxLines / 2);
111
- const widgetCompact = a.widgetCompact === true;
112
- const widgetShortcut = a.widgetShortcut === true;
113
- const widgetDescLengthFull = a.widgetDescLengthFull ?? DEFAULT_CONFIG.agent.widgetDescLengthFull ?? 50;
114
- const widgetDescLengthCompact = a.widgetDescLengthCompact ?? DEFAULT_CONFIG.agent.widgetDescLengthCompact ?? 30;
115
- const rawMode = a.systemPromptMode;
116
- const systemPromptMode = VALID_SYSTEM_PROMPT_MODES.has(rawMode as string) ? rawMode as SystemPromptMode : "replace";
117
- const includeContextFiles = a.includeContextFiles ?? DEFAULT_CONFIG.agent.includeContextFiles ?? true;
118
111
 
119
112
  return {
120
113
  defaultModel: a.default ?? null,
121
114
  forceBackground: a.forceBackground === true,
122
115
  showCost: this.sessionShowCost ?? (a.showCost === true),
123
- graceTurns: a.graceTurns ?? DEFAULT_CONFIG.agent.graceTurns ?? 6,
116
+ graceTurns: a.graceTurns ?? 6,
124
117
  widgetMaxLines,
125
118
  widgetMaxLinesCompact,
126
- widgetCompact,
127
- widgetShortcut,
128
- widgetDescLengthFull,
129
- widgetDescLengthCompact,
130
- systemPromptMode,
131
- includeContextFiles,
119
+ widgetCompact: a.widgetCompact === true,
120
+ widgetShortcut: a.widgetShortcut === true,
121
+ widgetDescLengthFull: a.widgetDescLengthFull ?? 50,
122
+ widgetDescLengthCompact: a.widgetDescLengthCompact ?? 30,
123
+ systemPromptMode: VALID_SYSTEM_PROMPT_MODES.has(a.systemPromptMode as string) ? (a.systemPromptMode as SystemPromptMode) : "replace",
124
+ includeContextFiles: a.includeContextFiles ?? true,
132
125
  defaultThinking: a.defaultThinking as ThinkingLevel | undefined,
133
126
  defaultMaxTurns: a.defaultMaxTurns,
134
127
  loadSkillsImplicitly: a.loadSkillsImplicitly !== false,
@@ -140,6 +133,7 @@ export class ConfigStore {
140
133
  showOutput: a.showOutput !== false,
141
134
  showContext: a.showContext !== false,
142
135
  showTime: a.showTime !== false,
136
+ outputThinkingBufferSize: a.outputThinkingBufferSize ?? 0,
143
137
  };
144
138
  }
145
139
 
@@ -271,6 +265,10 @@ export class ConfigStore {
271
265
  setShowOutput: (enabled: boolean) => this.setAgentVisibility("showOutput", enabled),
272
266
  setShowContext: (enabled: boolean) => this.setAgentVisibility("showContext", enabled),
273
267
  setShowTime: (enabled: boolean) => this.setAgentVisibility("showTime", enabled),
268
+ setOutputThinkingBufferSize: (size: number): void => {
269
+ this.config.agent.outputThinkingBufferSize = size;
270
+ this.persist();
271
+ },
274
272
  },
275
273
  widget: {
276
274
  setCompact: (enabled: boolean): void => {
@@ -337,7 +335,7 @@ export class ConfigStore {
337
335
  this.applyConcurrency();
338
336
  },
339
337
  reset: (): void => {
340
- this.config.concurrency = { ...DEFAULT_CONFIG.concurrency };
338
+ this.config.concurrency = { ...DEFAULT_CONCURRENCY };
341
339
  this.persist();
342
340
  this.applyConcurrency();
343
341
  },
@@ -23,4 +23,5 @@ export const CONFIG_AGENT_NON_MODEL_KEYS = [
23
23
  "loadSkillsImplicitly",
24
24
  "loadExtensionsImplicitly",
25
25
  "disableDefaultAgents",
26
+ "outputThinkingBufferSize",
26
27
  ];
package/src/events.ts CHANGED
@@ -39,13 +39,15 @@ export function ensureManagerAndWidget(): void {
39
39
  const newManager = new AgentManager(
40
40
  undefined, // onComplete wired below
41
41
  getStore().concurrency as unknown as ConstructorParameters<typeof AgentManager>[1],
42
+ undefined,
43
+ getStore().agent.outputThinkingBufferSize,
42
44
  );
43
45
  setManager(newManager);
44
46
  // Sync the manager as a config side-effect target (concurrency setters call setConcurrency).
45
47
  getStore().setDeps({ manager: newManager });
46
48
 
47
49
  // Now create coordinator with the real manager
48
- const coordinator = new SpawnCoordinator(newManager, getPiInstance());
50
+ const coordinator = new SpawnCoordinator(newManager);
49
51
  setCoordinator(coordinator);
50
52
 
51
53
  // Wire the manager's onComplete to the coordinator
@@ -56,6 +56,8 @@ export interface SubagentsConfig {
56
56
  widgetDescLengthFull?: number;
57
57
  /** Max description length in widget compact mode. Default: 30. */
58
58
  widgetDescLengthCompact?: number;
59
+ /** When > 0, thinking deltas stream to output file during message_update events. Default: 0 (disabled). */
60
+ outputThinkingBufferSize?: number;
59
61
  [agentType: string]: string | null | undefined | boolean | number;
60
62
  };
61
63
  concurrency: {
@@ -1,4 +1,5 @@
1
1
  import { getStatusNote } from "../status-note.js";
2
+ import { getPiInstance, getSessionCtx, getWidget } from "../shell.js";
2
3
  /**
3
4
  * spawn-coordinator.ts — Spawn-and-track coordination for subagents.
4
5
  *
@@ -14,7 +15,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
14
15
  import type { AgentRecord, SpawnConfig, ToolActivity } from "../types.js";
15
16
  import type { AgentManager, SpawnOptions } from "../agents/agent-manager.js";
16
17
  import { buildAgentDetails } from "../agents/tool-execution.js";
17
- import { getWidget } from "../shell.js";
18
18
 
19
19
  // ============================================================================
20
20
  // Types
@@ -64,12 +64,10 @@ export class SpawnCoordinator {
64
64
  /** Active nudge timer. */
65
65
  private nudgeTimer: ReturnType<typeof setTimeout> | null = null;
66
66
 
67
- /** The pi API reference for sending messages. */
68
- private pi: ExtensionAPI | null = null;
67
+ /** Set during dispose to prevent nudge emission after session replacement. */
68
+ private disposed = false;
69
69
 
70
- constructor(private manager: AgentManager, pi?: ExtensionAPI) {
71
- if (pi) this.pi = pi;
72
- }
70
+ constructor(private manager: AgentManager) {}
73
71
 
74
72
  /**
75
73
  * Spawn + wire tracking + (foreground) await.
@@ -80,9 +78,6 @@ export class SpawnCoordinator {
80
78
  ctx: ExtensionContext,
81
79
  intent: SpawnIntent,
82
80
  ): Promise<SpawnResult> {
83
- // Store pi for nudge emission
84
- this.pi = pi;
85
-
86
81
  // Create live view BEFORE spawn so callbacks can close over it
87
82
  const liveView: LiveView = {
88
83
  activeTools: new Map(),
@@ -116,6 +111,9 @@ export class SpawnCoordinator {
116
111
  this.backgroundAgentIds.add(agentId);
117
112
  }
118
113
 
114
+ // pi is read from shell at nudge time, not stored here
115
+ // (avoids stale ctx after session replacement or reload)
116
+
119
117
  const record = this.manager.getRecord(agentId)!;
120
118
 
121
119
  if (!intent.runInBackground) {
@@ -183,7 +181,7 @@ export class SpawnCoordinator {
183
181
  this.pendingNudges.clear();
184
182
  this.liveViews.clear();
185
183
  this.backgroundAgentIds.clear();
186
- this.pi = null;
184
+ this.disposed = true;
187
185
  }
188
186
 
189
187
  // ── Private ──
@@ -208,25 +206,45 @@ export class SpawnCoordinator {
208
206
 
209
207
  /** Emit an individual nudge for a completed background agent. */
210
208
  private emitIndividualNudge(agentId: string): void {
209
+ // Skip if disposed — prevents stale pi usage after session replacement
210
+ if (this.disposed) return;
211
+
212
+ // Read pi from shell at call time so we get a fresh reference after reload.
213
+ const pi = getPiInstance();
214
+ if (!pi) return;
215
+
211
216
  const record = this.manager.getRecord(agentId);
212
- if (!record || !this.pi) return;
217
+ if (!record) return;
213
218
 
214
219
  const details = buildAgentDetails(record, {
215
220
  includeStats: true,
216
221
  includeStatus: true,
217
222
  });
218
223
 
219
- this.pi.sendMessage(
220
- {
221
- customType: "subagent-result",
222
- content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""} ${getStatusNote(record.lifecycle.status)}`,
223
- details,
224
- display: true,
225
- },
226
- {
227
- deliverAs: "steer",
228
- triggerTurn: true,
229
- },
230
- );
224
+ try {
225
+ // Pick delivery mode based on parent session state:
226
+ // - steer: queues while running, delivers before next LLM call
227
+ // - followUp: waits for agent to finish, then delivers
228
+ const ctx = getSessionCtx();
229
+ const parentIdle = ctx?.isIdle?.() ?? true;
230
+ const deliverAs = parentIdle ? "followUp" : "steer";
231
+
232
+ pi.sendMessage(
233
+ {
234
+ customType: "subagent-result",
235
+ content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""} ${getStatusNote(record.lifecycle.status)}`,
236
+ details,
237
+ display: true,
238
+ },
239
+ {
240
+ deliverAs,
241
+ triggerTurn: true,
242
+ },
243
+ );
244
+ } catch (error) {
245
+ // pi may be stale after session replacement (newSession/fork/switchSession).
246
+ // PI invalidates the extension runner on session.dispose() and never
247
+ // clears the stale flag for non-reload replacements. Nothing we can do.
248
+ }
231
249
  }
232
250
  }
@@ -10,10 +10,7 @@
10
10
 
11
11
  import * as path from "node:path";
12
12
  import { existsSync, statSync, realpathSync } from "node:fs";
13
-
14
- /** Timeout for git commands (ms). */
15
- const GIT_EXEC_TIMEOUT_MS = 5000;
16
-
13
+ import { GIT_EXEC_TIMEOUT_MS } from "../utils.js";
17
14
  /** Specific error messages returned to the LLM for self-correction. */
18
15
  export const WORKTREE_VALIDATION_ERRORS = {
19
16
  PATH_DOES_NOT_EXIST: "worktree_path does not exist: the specified path was not found on disk",
@@ -60,6 +57,7 @@ async function getGitCommonDir(
60
57
  pi: PiExec,
61
58
  cwd: string,
62
59
  notInRepoError: string,
60
+ onWarning?: (msg: string) => void,
63
61
  ): Promise<{ ok: true; commonDir: string } | { ok: false; error: string }> {
64
62
  try {
65
63
  const result = await pi.exec("git", ["rev-parse", "--git-common-dir"], { cwd, timeout: GIT_EXEC_TIMEOUT_MS });
@@ -72,7 +70,11 @@ async function getGitCommonDir(
72
70
  if (msg.includes("ENOENT") || msg.includes("not found")) {
73
71
  return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_NOT_FOUND };
74
72
  }
75
- return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_TIMEOUT };
73
+ if (msg.includes("timed out") || msg.includes("timeout")) {
74
+ return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_TIMEOUT };
75
+ }
76
+ onWarning?.(`git rev-parse --git-common-dir failed in ${cwd}: ${msg}`);
77
+ return { ok: false, error: `worktree_path validation failed: git rev-parse failed: ${msg}` };
76
78
  }
77
79
  }
78
80
 
@@ -97,6 +99,7 @@ export async function validateWorktreePath(
97
99
  pi: PiExec,
98
100
  worktreePath: string,
99
101
  parentCwd: string,
102
+ onWarning?: (msg: string) => void,
100
103
  ): Promise<WorktreeValidationResult> {
101
104
  // Step 1: Empty / whitespace → treat as omitted
102
105
  if (!worktreePath || worktreePath.trim() === "") {
@@ -128,10 +131,10 @@ export async function validateWorktreePath(
128
131
  }
129
132
 
130
133
  // Step 5: Get and compare git-common-dir for parent and target
131
- const parentResult = await getGitCommonDir(pi, parentCwd, WORKTREE_VALIDATION_ERRORS.PARENT_NOT_IN_GIT_REPO);
134
+ const parentResult = await getGitCommonDir(pi, parentCwd, WORKTREE_VALIDATION_ERRORS.PARENT_NOT_IN_GIT_REPO, onWarning);
132
135
  if (!parentResult.ok) return parentResult;
133
136
 
134
- const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO);
137
+ const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO, onWarning);
135
138
  if (!targetResult.ok) return targetResult;
136
139
 
137
140
  // Compare common dirs — must share the same repo
@@ -14,7 +14,7 @@ import {
14
14
  import { formatMs, buildStatsParts, getDisplayName, truncateDesc, type StatsVisibility } from "./format.js";
15
15
  import type { LiveView } from "../spawn/spawn-coordinator.js";
16
16
 
17
- // Re-export Theme so existing consumers (model-selector, result-viewer) don't break
17
+ // Re-export Theme so existing consumers (searchable-select, result-viewer) don't break
18
18
  export type { Theme } from "./types.js";
19
19
 
20
20
  // ---- Constants ----
@@ -56,7 +56,6 @@ const TOOL_DISPLAY: Record<string, string> = {
56
56
  write: "writing",
57
57
  grep: "searching",
58
58
  find: "finding files",
59
- ls: "listing",
60
59
  };
61
60
 
62
61
  // ---- Types ----
@@ -136,6 +135,14 @@ function describeActivity(activeTools: Map<string, string>, responseText?: strin
136
135
  return THINKING_TEXT;
137
136
  }
138
137
 
138
+ /** Build the worktree/output continuation line parts for an agent record. */
139
+ function buildWorktreeOutputParts(a: AgentRecord): string[] {
140
+ const parts: string[] = [];
141
+ if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
142
+ if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
143
+ return parts;
144
+ }
145
+
139
146
  // ---- Widget manager ----
140
147
 
141
148
  export class AgentWidget {
@@ -375,10 +382,8 @@ export class AgentWidget {
375
382
  for (const a of finished) {
376
383
  const continuations: string[] = [];
377
384
  if (!this.isCompact()) {
378
- if (a.display.outputFile || a.display.worktreeLabel) {
379
- const parts: string[] = [];
380
- if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
381
- if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
385
+ const parts = buildWorktreeOutputParts(a);
386
+ if (parts.length > 0) {
382
387
  continuations.push(truncate(theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
383
388
  }
384
389
  }
@@ -418,10 +423,8 @@ export class AgentWidget {
418
423
  const fullDesc = truncateDesc(a.display.description, this.descLengthFull);
419
424
  const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${fullDesc} ${statsLine}`;
420
425
  const continuations: string[] = [];
421
- if (a.display.outputFile || a.display.worktreeLabel) {
422
- const parts: string[] = [];
423
- if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
424
- if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
426
+ const parts = buildWorktreeOutputParts(a);
427
+ if (parts.length > 0) {
425
428
  continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
426
429
  }
427
430
  continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)));
@@ -571,15 +574,14 @@ export class AgentWidget {
571
574
  }
572
575
 
573
576
  // Overflow summary line
574
- const overflowLine = hiddenRunning + hiddenFinished > 0
575
- ? (() => {
576
- const parts: string[] = [];
577
- if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
578
- if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
579
- const summary = `+${hiddenRunning + hiddenFinished} more (${parts.join(", ")})`;
580
- return `${theme.fg("dim", CORNER)} ${theme.fg("dim", summary)}`;
581
- })()
582
- : undefined;
577
+ let overflowLine: string | undefined;
578
+ if (hiddenRunning + hiddenFinished > 0) {
579
+ const parts: string[] = [];
580
+ if (hiddenRunning > 0) parts.push(`${hiddenRunning} running`);
581
+ if (hiddenFinished > 0) parts.push(`${hiddenFinished} finished`);
582
+ const summary = `+${hiddenRunning + hiddenFinished} more (${parts.join(", ")})`;
583
+ overflowLine = `${theme.fg("dim", CORNER)} ${theme.fg("dim", summary)}`;
584
+ }
583
585
 
584
586
  return { visible, overflowLine };
585
587
  }
package/src/ui/format.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  import { getConfig } from "../agents/agent-types.js";
12
12
  import type { SubagentType } from "../agents/types.js";
13
13
  import type { Theme } from "./types.js";
14
- import { formatTokens, formatTokensCompact, formatCost } from "../agents/usage.js";
14
+ import { formatTokens, formatCost } from "../agents/usage.js";
15
15
 
16
16
  /** Truncate a description string to `maxLen` characters, appending "..." if truncated. */
17
17
  export function truncateDesc(text: string, maxLen: number): string {
@@ -44,8 +44,8 @@ function formatSessionTokens(
44
44
  compactions = 0,
45
45
  ): string {
46
46
  const tokenParts: string[] = [];
47
- if (inputTokens > 0) tokenParts.push(`↑${formatTokensCompact(inputTokens)}`);
48
- if (outputTokens > 0) tokenParts.push(`↓${formatTokensCompact(outputTokens)}`);
47
+ if (inputTokens > 0) tokenParts.push(`↑${formatTokens(inputTokens, true)}`);
48
+ if (outputTokens > 0) tokenParts.push(`↓${formatTokens(outputTokens, true)}`);
49
49
  const tokenStr = tokenParts.join("");
50
50
  const annot: string[] = [];
51
51
  if (percent !== null) {
@@ -1,19 +1,19 @@
1
1
  /**
2
2
  * helpers.ts — Shared helpers for menu modules:
3
3
  * theme builders for SettingsList/SelectList, numeric validation,
4
- * model-option building, and a swappable delegating component.
4
+ * model-option building, a swappable delegating component, and a
5
+ * searchable pick-list submenu factory.
5
6
  */
6
-
7
7
  import type { Component, SettingsListTheme } from "@earendil-works/pi-tui";
8
- import type { ModelOption } from "../../models/model-selector.js";
8
+ import type { Theme } from "../types.js";
9
+ import { SearchableSelectDialog, type SelectOption } from "../searchable-select.js";
9
10
  import { parseModelKey } from "../../utils.js";
10
-
11
11
  /**
12
- * Build ModelOption[] from raw "provider/model-id" strings.
12
+ * Build SelectOption[] from raw "provider/model-id" strings.
13
13
  * Includes "(inherits parent)" as the first option.
14
14
  */
15
- export function buildModelOptions(rawOptions: string[]): ModelOption[] {
16
- const items: ModelOption[] = [
15
+ export function buildModelOptions(rawOptions: string[]): SelectOption[] {
16
+ const items: SelectOption[] = [
17
17
  { value: "(inherits parent)", label: "(inherits parent)", provider: "" },
18
18
  ];
19
19
 
@@ -33,7 +33,7 @@ export function buildSettingsListTheme(theme: { fg(color: string, text: string):
33
33
  return {
34
34
  label: (text, selected) => selected ? theme.fg("accent", text) : text,
35
35
  value: (text, selected) => selected ? theme.fg("accent", text) : theme.fg("muted", text),
36
- description: (text) => theme.fg("muted", text),
36
+ description: (text) => theme.fg("dim", text),
37
37
  // Use "→ " (2 chars) to match non-selected prefix " " (2 spaces)
38
38
  // This prevents menu items from shifting left/right when cursor moves
39
39
  cursor: theme.fg("accent", "→ "),
@@ -91,3 +91,33 @@ export function buildSelectListTheme(theme: { fg(color: string, text: string): s
91
91
  };
92
92
  }
93
93
 
94
+ /**
95
+ * Build a searchable pick-list submenu backed by SearchableSelectDialog.
96
+ *
97
+ * Hides the delegator-forward-declaration dance shared by every menu that
98
+ * needs "type to filter, Enter to pick" over a flat option list
99
+ * (provider/model/type/worktree selection). onSelect may return a Component
100
+ * to chain into next (e.g. a numeric-input submenu); returning void leaves
101
+ * the submenu as-is so the caller can close it via done().
102
+ */
103
+ export function createSearchableSelect(
104
+ items: SelectOption[],
105
+ callbacks: { onSelect: (value: string) => Component | void; onCancel: () => void },
106
+ theme: Theme,
107
+ ): Component {
108
+ let delegator: ReturnType<typeof createDelegatingComponent>;
109
+ const selector = new SearchableSelectDialog(
110
+ items,
111
+ null,
112
+ {
113
+ onSelect: (value) => {
114
+ const next = callbacks.onSelect(value);
115
+ if (next) delegator.setActive(next);
116
+ },
117
+ onCancel: callbacks.onCancel,
118
+ },
119
+ theme,
120
+ );
121
+ delegator = createDelegatingComponent(selector);
122
+ return delegator;
123
+ }
@@ -11,18 +11,26 @@
11
11
 
12
12
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import { SettingsList, SelectList, type SettingItem } from "@earendil-works/pi-tui";
14
- import { buildSettingsListTheme, buildSelectListTheme, createDelegatingComponent } from "./helpers.js";
14
+ import {
15
+ buildSettingsListTheme,
16
+ buildSelectListTheme,
17
+ buildModelOptions,
18
+ createDelegatingComponent,
19
+ createSearchableSelect,
20
+ } from "./helpers.js";
15
21
  import { createNumericSubmenu } from "./submenus/numeric-input.js";
16
22
  import { createConfirmSubmenu } from "./submenus/confirm.js";
17
23
  import { SettingsListWrapper } from "./wrappers/settings-list.js";
18
24
  import { getStore } from "../../shell.js";
25
+ import type { SelectOption } from "../searchable-select.js";
26
+ import type { Theme } from "../types.js";
19
27
 
20
28
  export async function showConcurrencySettingsMenu(
21
29
  ctx: ExtensionCommandContext,
22
30
  modelOptions: string[],
23
31
  ): Promise<void> {
24
32
  // Build menu items from current store state.
25
- const buildItems = (store: ReturnType<typeof getStore>, theme: any, modelOptions: string[], onRebuild?: () => void): SettingItem[] => {
33
+ const buildItems = (store: ReturnType<typeof getStore>, theme: Theme, modelOptions: string[], onRebuild?: () => void): SettingItem[] => {
26
34
  const providers = [...new Set(modelOptions.map((m) => m.split("/")[0]))].sort();
27
35
  const items: SettingItem[] = [];
28
36
 
@@ -52,28 +60,28 @@ export async function showConcurrencySettingsMenu(
52
60
  return delegator;
53
61
  };
54
62
 
55
- // Submenu factory: pick a key from `options`, then enter a value.
56
- const addLimitSubmenu = (
57
- options: string[],
63
+ // Submenu factory: searchable-pick an option, then enter a numeric value.
64
+ // Used for both per-provider and per-model limits; items differ by caller.
65
+ const addPickThenValueSubmenu = (
66
+ items: SelectOption[],
58
67
  onPick: (key: string, parsed: number) => void,
59
- ): SettingItem["submenu"] => (_currentValue, subDone) => {
60
- const list = new SelectList(
61
- options.map((o) => ({ value: o, label: o })),
62
- 10, buildSelectListTheme(theme),
68
+ ): SettingItem["submenu"] => (_currentValue, subDone) =>
69
+ createSearchableSelect(
70
+ items,
71
+ {
72
+ onSelect: (key) =>
73
+ createNumericSubmenu(ctx, { min: 1 }, (parsed) => onPick(key, parsed))("1", subDone),
74
+ onCancel: () => subDone(),
75
+ },
76
+ theme,
63
77
  );
64
- const delegator = createDelegatingComponent(list);
65
- list.onSelect = (item) => {
66
- delegator.setActive(createNumericSubmenu(ctx, { min: 1 }, (parsed) => onPick(item.value, parsed))("1", subDone));
67
- };
68
- list.onCancel = () => subDone();
69
- return delegator;
70
- };
71
78
 
72
79
  // Global default
73
80
  items.push({
74
81
  id: "defaultConcurrency",
75
82
  label: "Default concurrency limit",
76
83
  currentValue: String(store.concurrency.default),
84
+ description: "Concurrent agent slots when no per-provider or per-model limit applies.",
77
85
  submenu: createNumericSubmenu(ctx, (parsed) => {
78
86
  store.mutate.concurrency.setDefault(parsed);
79
87
  ctx.ui.notify(`Default concurrency set to ${parsed}`, "info");
@@ -90,6 +98,7 @@ export async function showConcurrencySettingsMenu(
90
98
  id: `provider:${provider}`,
91
99
  label: provider,
92
100
  currentValue: `${limit} slots`,
101
+ description: `Concurrent slots reserved for agents using the ${provider} provider.`,
93
102
  submenu: editOrRemoveSubmenu(
94
103
  limit,
95
104
  (parsed) => {
@@ -111,7 +120,8 @@ export async function showConcurrencySettingsMenu(
111
120
  id: "addProviderLimit",
112
121
  label: "Add per-provider limit...",
113
122
  currentValue: "",
114
- submenu: addLimitSubmenu(providers, (provider, parsed) => {
123
+ description: "Cap how many agents run at once for a single provider.",
124
+ submenu: addPickThenValueSubmenu(providers.map((o) => ({ value: o, label: o })), (provider, parsed) => {
115
125
  store.mutate.concurrency.setProvider(provider, parsed);
116
126
  ctx.ui.notify(`${provider} concurrency set to ${parsed}`, "info");
117
127
  }),
@@ -128,6 +138,7 @@ export async function showConcurrencySettingsMenu(
128
138
  id: `model:${modelKey}`,
129
139
  label: modelKey,
130
140
  currentValue: `${limit} slots`,
141
+ description: `Concurrent slots reserved for agents using the ${modelKey} model.`,
131
142
  submenu: editOrRemoveSubmenu(
132
143
  limit,
133
144
  (parsed) => {
@@ -149,7 +160,8 @@ export async function showConcurrencySettingsMenu(
149
160
  id: "addModelLimit",
150
161
  label: "Add per-model limit...",
151
162
  currentValue: "",
152
- submenu: addLimitSubmenu(modelOptions, (modelKey, parsed) => {
163
+ description: "Cap how many agents run at once for a single model.",
164
+ submenu: addPickThenValueSubmenu(buildModelOptions(modelOptions), (modelKey, parsed) => {
153
165
  store.mutate.concurrency.setModel(modelKey, parsed);
154
166
  ctx.ui.notify(`${modelKey} concurrency set to ${parsed}`, "info");
155
167
  }),
@@ -162,6 +174,7 @@ export async function showConcurrencySettingsMenu(
162
174
  id: "resetAll",
163
175
  label: "Reset all to defaults",
164
176
  currentValue: "",
177
+ description: "Restore the default limit and remove all per-provider and per-model limits.",
165
178
  submenu: createConfirmSubmenu({
166
179
  message: "Reset all concurrency limits to defaults?",
167
180
  theme,