pi-subagents-lite 1.4.1 → 1.4.2

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/README.md CHANGED
@@ -35,7 +35,7 @@ Names like `Agent`, `StopAgent`, `AgentStatus`, `run_in_background`, `worktree_p
35
35
  - **Live widget** — persistent status bar with running/completed agents, full and compact modes
36
36
  - **Result viewer** — fullscreen markdown with stats
37
37
  - **Worktrees** — run agents in a git worktree via `worktree_path`
38
- - **Output logs** — `tail -f` friendly, ISO-timestamped
38
+ - **Output logs** — `tail -f` friendly, ISO-timestamped with configurable thinking buffer (OFF, 80, 200, 500, 1000 chars). Flush rounds to sentence boundaries.
39
39
 
40
40
  ## Install
41
41
 
@@ -112,7 +112,7 @@ Stop a running agent by ID.
112
112
  |---|---|---|
113
113
  | `agent_id` | ✅ | The agent ID returned by `Agent` at spawn |
114
114
 
115
- IDs come from the `Agent` result, the `StopAgent` error (lists all running IDs), or `/agents` → **Running agents**.
115
+ IDs come from the `Agent` result, the `StopAgent` error (lists all running IDs), or `/agents` → **Running agents**. Display format is `id (type)` (e.g. `a1b2c3 (Explore)`).
116
116
 
117
117
  ### `AgentStatus`
118
118
 
@@ -244,13 +244,13 @@ When `includeContextFiles` is `true` (default), AGENTS.md files from the project
244
244
  Management menu with four sections:
245
245
 
246
246
  - **Running agents** — status and description; per-agent actions (view snapshot, result, error; steer; stop) and bulk stop
247
- - **Spawn agent** — manually spawn without the LLM. Pick a type, enter a prompt, tune options (model, thinking, max turns, max tokens, grace turns, background), then spawn. Options pre-fill from agent config.
247
+ - **Spawn agent** — manually spawn without the LLM. Pick a type (with search), enter a prompt, tune options (model, thinking, max turns, max tokens, grace turns, background), then spawn. Options pre-fill from agent config.
248
248
  - **Settings**
249
249
  - **Model settings** — global default, per-type overrides, session overrides, clear all
250
250
  - **Spawn options** — force background, grace turns, default max turns, default thinking, disable default agents
251
251
  - **System prompt** — mode, custom prompt file, include AGENTS.md, load skills/extensions implicitly
252
- - **Concurrency** — default limit, per-provider and per-model slots, reset to defaults
253
- - **Widget settings** — force compact, max lines, description length, ctrl+o shortcut, usage stats (toggle tools, turns, input/output tokens, context %, cost, time)
252
+ - **Concurrency** — default limit, per-provider and per-model slots (with search), reset to defaults
253
+ - **Widget settings** — force compact, max lines, description length, thinking buffer size, ctrl+o shortcut, usage stats (toggle tools, turns, input/output tokens, context %, cost, time)
254
254
 
255
255
  ## Interface
256
256
 
@@ -333,6 +333,7 @@ With **Cost display** ON, stats show dollar cost (`✓ Builder·2🛠 ·5⟳ ·
333
333
  | `widgetDescLengthCompact` | `30` | Max description length in compact mode. |
334
334
  | `widgetCompact` | `false` | Force compact mode regardless of ctrl+o state. |
335
335
  | `widgetShortcut` | `false` | When ON, ctrl+o (tool expansion toggle) syncs with widget compact mode. When OFF, compact is manual via `widgetCompact`. |
336
+ | `outputThinkingBufferSize` | `200` | Thinking buffer ring size in chars. `0` = OFF. Flushes to output log at sentence boundaries. |
336
337
 
337
338
  ### Stats visibility
338
339
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-lite",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "Lightweight sub-agents for pi — spawn specialized agents with isolated sessions, tools, and models.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,11 +28,12 @@
28
28
  "url": "https://github.com/AlexParamonov/pi-subagents-lite/issues"
29
29
  },
30
30
  "peerDependencies": {
31
- "@earendil-works/pi-ai": ">=0.74.0",
32
- "@earendil-works/pi-coding-agent": ">=0.74.0",
33
- "@earendil-works/pi-tui": ">=0.74.0"
31
+ "@earendil-works/pi-ai": "^0.80.1",
32
+ "@earendil-works/pi-coding-agent": "^0.80.1",
33
+ "@earendil-works/pi-tui": "^0.80.1"
34
34
  },
35
35
  "dependencies": {
36
+ "@earendil-works/pi-agent-core": "^0.80.1",
36
37
  "@sinclair/typebox": "^0.34.49"
37
38
  },
38
39
  "files": [
@@ -98,6 +98,7 @@ export class AgentManager {
98
98
  onComplete?: OnAgentComplete,
99
99
  concurrency?: ConcurrencyConfig,
100
100
  onStart?: OnAgentStart,
101
+ private bufferSize: number = 0,
101
102
  ) {
102
103
  this.onComplete = onComplete;
103
104
  this.onStart = onStart;
@@ -258,7 +259,7 @@ export class AgentManager {
258
259
  record.lifecycle.startedAt = Date.now();
259
260
 
260
261
  // Create output log for this agent (creates file + writes [USER] entry)
261
- record.execution.outputLog = new AgentOutputLog(id, prompt);
262
+ record.execution.outputLog = new AgentOutputLog(id, prompt, undefined, this.bufferSize);
262
263
  record.display.outputFile = record.execution.outputLog.path;
263
264
 
264
265
  this.onStart?.(record);
@@ -12,11 +12,11 @@ import { SHORT_ID_LENGTH } from "../types.js";
12
12
  import { getManager } from "../shell.js";
13
13
 
14
14
  /**
15
- * Format a single agent record as "type·short_id·status".
15
+ * Format a single agent record as "short_id (type) status".
16
16
  */
17
17
  function formatAgent(record: AgentRecord): string {
18
18
  const shortId = record.id.slice(0, SHORT_ID_LENGTH);
19
- return `${record.display.type}·${shortId}·${record.lifecycle.status}`;
19
+ return `${shortId} (${record.display.type}) ${record.lifecycle.status}`;
20
20
  }
21
21
 
22
22
  /**
@@ -12,6 +12,19 @@ import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-
12
12
  import { formatTokens } from "./usage.js";
13
13
  import { summarizeToolArgs } from "../ui/format.js";
14
14
 
15
+
16
+ /** Find the last sentence boundary in text. Returns the index of the
17
+ * terminal punctuation character, or -1 if none found. */
18
+ function findLastSentenceBoundary(text: string): number {
19
+ // Search backward for the most recent sentence-ending punctuation
20
+ for (let i = text.length - 1; i >= 0; i--) {
21
+ const ch = text[i];
22
+ if ([".", "!", "?", ",", "\n"].includes(ch)) {
23
+ return i;
24
+ }
25
+ }
26
+ return -1;
27
+ }
15
28
  /** Max content length for full tool result display — longer results get a summary line. */
16
29
  const MAX_TOOL_RESULT_DISPLAY_LENGTH = 500;
17
30
 
@@ -111,12 +124,14 @@ function formatToolResult(toolName: string, content: ReadonlyArray<Record<string
111
124
  function formatMessageLine(
112
125
  role: "ASSISTANT" | "TOOL" | "USER",
113
126
  content: string | ReadonlyArray<Record<string, unknown>> | undefined,
127
+ skipThinkingCount: number = 0,
114
128
  ): string {
115
129
  if (typeof content === "string") {
116
130
  return splitAndPrefix(content, role);
117
131
  }
118
132
 
119
133
  if (Array.isArray(content)) {
134
+ let thinkingSkipped = 0;
120
135
  return content
121
136
  .map((item) => {
122
137
  if (item.type === "text" && typeof item.text === "string") {
@@ -126,6 +141,10 @@ function formatMessageLine(
126
141
  return formatToolItem(item);
127
142
  }
128
143
  if (item.type === "thinking" && typeof item.thinking === "string") {
144
+ if (thinkingSkipped < skipThinkingCount) {
145
+ thinkingSkipped++;
146
+ return ""; // Already streamed, skip
147
+ }
129
148
  const text = item.redacted ? "[redacted]" : item.thinking;
130
149
  return splitAndPrefix(text, "THINKING");
131
150
  }
@@ -136,7 +155,6 @@ function formatMessageLine(
136
155
 
137
156
  return "";
138
157
  }
139
-
140
158
  /**
141
159
  * Subscribe to session events and flush new messages to the output file
142
160
  * on each turn_end. Returns a cleanup function that writes the DONE line
@@ -148,15 +166,28 @@ export function streamToOutputFile(
148
166
  session: AgentSession,
149
167
  path: string,
150
168
  stats?: { turnCount: number; toolUseCount: number; totalTokens: number; cost: number },
169
+ bufferSize: number = 0,
151
170
  ): () => void {
152
171
  let writtenCount = 1; // initial user prompt already written
172
+ let thinkingBuffer = "";
173
+ let streamedThinkingBlocks = 0; // thinking blocks written live; skipped in the final flush
174
+ let streamedThinkingChars = 0; // track total chars streamed for deduplication
175
+ let thinkingBlockInProgress = false; // true between thinking_start and thinking_end
176
+
177
+ const flushThinkingBuffer = () => {
178
+ if (thinkingBuffer.length > 0) {
179
+ safeAppend(path, splitAndPrefix(thinkingBuffer, "THINKING"));
180
+ streamedThinkingChars += thinkingBuffer.length;
181
+ thinkingBuffer = "";
182
+ }
183
+ };
153
184
 
154
185
  const flush = () => {
155
186
  const messages = session.messages;
156
187
  while (writtenCount < messages.length) {
157
188
  const msg = messages[writtenCount];
158
189
  if (msg.role === "assistant") {
159
- const lines = formatMessageLine("ASSISTANT", msg.content as any);
190
+ const lines = formatMessageLine("ASSISTANT", msg.content as any, streamedThinkingBlocks);
160
191
  if (lines) safeAppend(path, lines);
161
192
  } else if (msg.role === "user") {
162
193
  const text = extractUserText(msg.content as any);
@@ -176,11 +207,60 @@ export function streamToOutputFile(
176
207
  };
177
208
 
178
209
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
179
- if (event.type === "turn_end") flush();
210
+ if (event.type === "turn_end") {
211
+ flushThinkingBuffer();
212
+ // If thinking_end never fired, treat this as if it did to avoid duplicates
213
+ if (thinkingBlockInProgress) {
214
+ streamedThinkingBlocks++;
215
+ thinkingBlockInProgress = false;
216
+ }
217
+ flush();
218
+ }
219
+
220
+ if (bufferSize > 0 && event.type === "message_update") {
221
+ const assistantEvent = event.assistantMessageEvent;
222
+ if (assistantEvent.type === "thinking_start") {
223
+ // Reset counter for new thinking block
224
+ streamedThinkingChars = 0;
225
+ thinkingBlockInProgress = true;
226
+ } else if (assistantEvent.type === "thinking_delta") {
227
+ thinkingBuffer += assistantEvent.delta;
228
+ if (thinkingBuffer.length >= bufferSize || thinkingBuffer.includes("\n")) {
229
+ // Round down to nearest sentence boundary when possible
230
+ if (thinkingBuffer.length >= bufferSize) {
231
+ const boundary = findLastSentenceBoundary(thinkingBuffer);
232
+ if (boundary >= 0) {
233
+ const flushText = thinkingBuffer.slice(0, boundary + 1);
234
+ thinkingBuffer = thinkingBuffer.slice(boundary + 1);
235
+ safeAppend(path, splitAndPrefix(flushText, "THINKING"));
236
+ streamedThinkingChars += flushText.length;
237
+ } else {
238
+ // No sentence boundary found, flush at buffer limit
239
+ flushThinkingBuffer();
240
+ }
241
+ } else {
242
+ // Newline found before buffer limit
243
+ flushThinkingBuffer();
244
+ }
245
+ }
246
+ } else if (assistantEvent.type === "thinking_end") {
247
+ // thinking_end carries the full block. Flush the buffered tail first
248
+ // (counted in streamedThinkingChars), then stream whatever remains.
249
+ flushThinkingBuffer();
250
+ if (assistantEvent.content && assistantEvent.content.length > streamedThinkingChars) {
251
+ const remaining = assistantEvent.content.slice(streamedThinkingChars);
252
+ safeAppend(path, splitAndPrefix(remaining, "THINKING"));
253
+ streamedThinkingChars = assistantEvent.content.length;
254
+ }
255
+ streamedThinkingBlocks++;
256
+ thinkingBlockInProgress = false;
257
+ }
258
+ }
180
259
  });
181
260
 
182
261
  return () => {
183
262
  // Final flush
263
+ flushThinkingBuffer();
184
264
  flush();
185
265
 
186
266
  // Write DONE line
@@ -219,10 +299,12 @@ export class AgentOutputLog {
219
299
  readonly path: string;
220
300
  private cleanup?: () => void;
221
301
  private statsRef?: OutputFinalStats;
302
+ private bufferSize: number;
222
303
 
223
- constructor(agentId: string, prompt: string, baseDir?: string) {
304
+ constructor(agentId: string, prompt: string, baseDir?: string, bufferSize: number = 0) {
224
305
  this.path = createOutputFilePath(agentId, baseDir);
225
306
  writeInitialEntry(this.path, prompt);
307
+ this.bufferSize = bufferSize;
226
308
  }
227
309
 
228
310
  /**
@@ -232,7 +314,7 @@ export class AgentOutputLog {
232
314
  */
233
315
  attach(session: AgentSession): void {
234
316
  this.statsRef = { turnCount: 0, toolUseCount: 0, totalTokens: 0, cost: 0 };
235
- this.cleanup = streamToOutputFile(session, this.path, this.statsRef);
317
+ this.cleanup = streamToOutputFile(session, this.path, this.statsRef, this.bufferSize);
236
318
  }
237
319
 
238
320
  /**
@@ -113,8 +113,13 @@ export async function executeAgentTool(
113
113
  if (rawWorktreePath && rawWorktreePath.trim() !== "") {
114
114
  try {
115
115
  const parentCwd = getSessionCtx()?.cwd ?? ctx.cwd;
116
- const validation = await validateWorktreePath(getPiInstance(), rawWorktreePath, parentCwd);
116
+ const warnings: string[] = [];
117
+ const onWarning = (msg: string) => { warnings.push(msg); };
118
+ const validation = await validateWorktreePath(getPiInstance(), rawWorktreePath, parentCwd, onWarning);
117
119
  if (!validation.ok) {
120
+ for (const msg of warnings) {
121
+ if (ctx.ui?.notify) ctx.ui.notify(`[pi-subagents-lite] ${msg}`, "warning");
122
+ }
118
123
  return errorResult(validation.error);
119
124
  }
120
125
  validatedWorktreePath = validation.resolvedPath;
@@ -198,7 +203,7 @@ export async function executeAgentTool(
198
203
 
199
204
  /**
200
205
  * Build a compact list of running (or queued) agents.
201
- * Format: "type·short_id, type·short_id" — one line, easy for LLM to parse.
206
+ * Format: "short_id (type), short_id (type)" — one line, easy for LLM to parse.
202
207
  */
203
208
  function formatRunningAgents(): string {
204
209
  const agents = getManager()!.listAgents().filter(
@@ -208,7 +213,7 @@ function formatRunningAgents(): string {
208
213
  if (agents.length === 0) return "none";
209
214
 
210
215
  return agents
211
- .map((a) => `${a.display.type}·${a.id.slice(0, SHORT_ID_LENGTH)}`)
216
+ .map((a) => `${a.id.slice(0, SHORT_ID_LENGTH)} (${a.display.type})`)
212
217
  .join(", ");
213
218
  }
214
219
 
@@ -76,6 +76,8 @@ export interface ResolvedAgentSettings {
76
76
  readonly showContext: boolean;
77
77
  /** Whether to show elapsed time in widget stats line. */
78
78
  readonly showTime: boolean;
79
+ /** Buffer size for streaming thinking blocks to output file. 0 = disabled. */
80
+ readonly outputThinkingBufferSize: number;
79
81
  }
80
82
 
81
83
  /** Side-effect targets, injected after construction. */
@@ -140,6 +142,7 @@ export class ConfigStore {
140
142
  showOutput: a.showOutput !== false,
141
143
  showContext: a.showContext !== false,
142
144
  showTime: a.showTime !== false,
145
+ outputThinkingBufferSize: a.outputThinkingBufferSize ?? 0,
143
146
  };
144
147
  }
145
148
 
@@ -271,6 +274,10 @@ export class ConfigStore {
271
274
  setShowOutput: (enabled: boolean) => this.setAgentVisibility("showOutput", enabled),
272
275
  setShowContext: (enabled: boolean) => this.setAgentVisibility("showContext", enabled),
273
276
  setShowTime: (enabled: boolean) => this.setAgentVisibility("showTime", enabled),
277
+ setOutputThinkingBufferSize: (size: number): void => {
278
+ this.config.agent.outputThinkingBufferSize = size;
279
+ this.persist();
280
+ },
274
281
  },
275
282
  widget: {
276
283
  setCompact: (enabled: boolean): void => {
@@ -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 { 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,13 @@ 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. */
67
+ /** Latest ExtensionAPI reference, refreshed on each spawn() call. */
68
68
  private pi: ExtensionAPI | null = null;
69
69
 
70
- constructor(private manager: AgentManager, pi?: ExtensionAPI) {
71
- if (pi) this.pi = pi;
72
- }
70
+ /** Set during dispose to prevent nudge emission after session replacement. */
71
+ private disposed = false;
72
+
73
+ constructor(private manager: AgentManager) {}
73
74
 
74
75
  /**
75
76
  * Spawn + wire tracking + (foreground) await.
@@ -80,9 +81,6 @@ export class SpawnCoordinator {
80
81
  ctx: ExtensionContext,
81
82
  intent: SpawnIntent,
82
83
  ): Promise<SpawnResult> {
83
- // Store pi for nudge emission
84
- this.pi = pi;
85
-
86
84
  // Create live view BEFORE spawn so callbacks can close over it
87
85
  const liveView: LiveView = {
88
86
  activeTools: new Map(),
@@ -116,6 +114,9 @@ export class SpawnCoordinator {
116
114
  this.backgroundAgentIds.add(agentId);
117
115
  }
118
116
 
117
+ // Store latest pi reference for nudge delivery
118
+ this.pi = pi;
119
+
119
120
  const record = this.manager.getRecord(agentId)!;
120
121
 
121
122
  if (!intent.runInBackground) {
@@ -183,7 +184,7 @@ export class SpawnCoordinator {
183
184
  this.pendingNudges.clear();
184
185
  this.liveViews.clear();
185
186
  this.backgroundAgentIds.clear();
186
- this.pi = null;
187
+ this.disposed = true;
187
188
  }
188
189
 
189
190
  // ── Private ──
@@ -208,25 +209,39 @@ export class SpawnCoordinator {
208
209
 
209
210
  /** Emit an individual nudge for a completed background agent. */
210
211
  private emitIndividualNudge(agentId: string): void {
212
+ // Skip if disposed — prevents stale pi usage after session replacement
213
+ if (this.disposed) return;
214
+
215
+ // Skip if no pi instance yet (no spawn has occurred)
216
+ if (!this.pi) {
217
+ console.warn(`subagent nudge skipped for ${agentId}: no pi instance available (no spawn has occurred yet)`);
218
+ return;
219
+ }
220
+
211
221
  const record = this.manager.getRecord(agentId);
212
- if (!record || !this.pi) return;
222
+ if (!record) return;
213
223
 
214
224
  const details = buildAgentDetails(record, {
215
225
  includeStats: true,
216
226
  includeStatus: true,
217
227
  });
218
228
 
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
- );
229
+ try {
230
+ this.pi.sendMessage(
231
+ {
232
+ customType: "subagent-result",
233
+ content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""} ${getStatusNote(record.lifecycle.status)}`,
234
+ details,
235
+ display: true,
236
+ },
237
+ {
238
+ deliverAs: "steer",
239
+ triggerTurn: true,
240
+ },
241
+ );
242
+ } catch (error) {
243
+ // Defense-in-depth: pi may be stale after session replacement/reload.
244
+ console.warn(`subagent nudge failed for ${agentId}:`, error);
245
+ }
231
246
  }
232
247
  }
@@ -60,6 +60,7 @@ async function getGitCommonDir(
60
60
  pi: PiExec,
61
61
  cwd: string,
62
62
  notInRepoError: string,
63
+ onWarning?: (msg: string) => void,
63
64
  ): Promise<{ ok: true; commonDir: string } | { ok: false; error: string }> {
64
65
  try {
65
66
  const result = await pi.exec("git", ["rev-parse", "--git-common-dir"], { cwd, timeout: GIT_EXEC_TIMEOUT_MS });
@@ -72,7 +73,11 @@ async function getGitCommonDir(
72
73
  if (msg.includes("ENOENT") || msg.includes("not found")) {
73
74
  return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_NOT_FOUND };
74
75
  }
75
- return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_TIMEOUT };
76
+ if (msg.includes("timed out") || msg.includes("timeout")) {
77
+ return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_TIMEOUT };
78
+ }
79
+ onWarning?.(`git rev-parse --git-common-dir failed in ${cwd}: ${msg}`);
80
+ return { ok: false, error: `worktree_path validation failed: git rev-parse failed: ${msg}` };
76
81
  }
77
82
  }
78
83
 
@@ -97,6 +102,7 @@ export async function validateWorktreePath(
97
102
  pi: PiExec,
98
103
  worktreePath: string,
99
104
  parentCwd: string,
105
+ onWarning?: (msg: string) => void,
100
106
  ): Promise<WorktreeValidationResult> {
101
107
  // Step 1: Empty / whitespace → treat as omitted
102
108
  if (!worktreePath || worktreePath.trim() === "") {
@@ -128,10 +134,10 @@ export async function validateWorktreePath(
128
134
  }
129
135
 
130
136
  // 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);
137
+ const parentResult = await getGitCommonDir(pi, parentCwd, WORKTREE_VALIDATION_ERRORS.PARENT_NOT_IN_GIT_REPO, onWarning);
132
138
  if (!parentResult.ok) return parentResult;
133
139
 
134
- const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO);
140
+ const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO, onWarning);
135
141
  if (!targetResult.ok) return targetResult;
136
142
 
137
143
  // 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 ----
@@ -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 { SearchableSelectDialog, type SelectOption } from "../searchable-select.js";
9
9
  import { parseModelKey } from "../../utils.js";
10
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: any,
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
+ }