pi-subagents-lite 1.2.0 → 1.4.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.
Files changed (53) hide show
  1. package/README.md +184 -225
  2. package/package.json +1 -1
  3. package/src/{agent-discovery.ts → agents/agent-discovery.ts} +8 -5
  4. package/src/{agent-manager.ts → agents/agent-manager.ts} +34 -74
  5. package/src/{agent-runner.ts → agents/agent-runner.ts} +115 -173
  6. package/src/agents/agent-status.ts +50 -0
  7. package/src/agents/agent-types.ts +339 -0
  8. package/src/{default-agents.ts → agents/default-agents.ts} +2 -5
  9. package/src/{output-file.ts → agents/output-file.ts} +68 -1
  10. package/src/{tool-execution.ts → agents/tool-execution.ts} +61 -223
  11. package/src/agents/types.ts +54 -0
  12. package/src/{usage.ts → agents/usage.ts} +7 -0
  13. package/src/{config-io.ts → config/config-io.ts} +20 -3
  14. package/src/config/config-store.ts +472 -0
  15. package/src/config/types.ts +26 -0
  16. package/src/events.ts +185 -0
  17. package/src/index.ts +8 -271
  18. package/src/{model-precedence.ts → models/model-precedence.ts} +33 -0
  19. package/src/{model-selector.ts → models/model-selector.ts} +1 -1
  20. package/src/{context.ts → prompt/context.ts} +1 -1
  21. package/src/prompt/prompts.ts +180 -0
  22. package/src/prompt/skill-loader.ts +195 -0
  23. package/src/registration.ts +101 -0
  24. package/src/shell.ts +101 -0
  25. package/src/spawn/spawn-coordinator.ts +232 -0
  26. package/src/status-note.ts +10 -0
  27. package/src/types.ts +47 -71
  28. package/src/ui/agent-widget.ts +61 -49
  29. package/src/{format.ts → ui/format.ts} +64 -26
  30. package/src/ui/menu/helpers.ts +93 -0
  31. package/src/ui/menu/menu-concurrency.ts +192 -0
  32. package/src/ui/menu/menu-debug.ts +125 -0
  33. package/src/ui/menu/menu-model-settings.ts +208 -0
  34. package/src/ui/menu/menu-running-agents.ts +224 -0
  35. package/src/ui/menu/menu-spawn-options.ts +87 -0
  36. package/src/ui/menu/menu-spawn-wizard.ts +418 -0
  37. package/src/ui/menu/menu-system-prompt.ts +109 -0
  38. package/src/ui/menu/menu-widget-settings.ts +130 -0
  39. package/src/ui/menu/menus.ts +101 -0
  40. package/src/ui/menu/submenus/confirm.ts +47 -0
  41. package/src/ui/menu/submenus/model-select.ts +70 -0
  42. package/src/ui/menu/submenus/numeric-input.ts +98 -0
  43. package/src/ui/menu/wrappers/settings-list.ts +205 -0
  44. package/src/{renderer.ts → ui/renderer.ts} +7 -6
  45. package/src/{result-viewer.ts → ui/result-viewer.ts} +7 -2
  46. package/src/ui/types.ts +11 -0
  47. package/src/agent-types.ts +0 -184
  48. package/src/config-mutator.ts +0 -183
  49. package/src/menus.ts +0 -1333
  50. package/src/prompts.ts +0 -94
  51. package/src/skill-loader.ts +0 -178
  52. package/src/state.ts +0 -83
  53. /package/src/{worktree-validator.ts → spawn/worktree-validator.ts} +0 -0
@@ -0,0 +1,418 @@
1
+ /**
2
+ * menu-spawn-wizard.ts — Spawn agent wizard and worktree picker.
3
+ *
4
+ * Extracted from menus.ts to own the multi-step spawn composition flow:
5
+ * type selection → prompt → options sub-menu → spawn.
6
+ *
7
+ * The worktree picker (listWorktrees, isInGitRepo, parseWorktreeList, truncatePath)
8
+ * is co-located here because it exists solely to feed the spawn wizard's worktree_path.
9
+ */
10
+
11
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
+ import { SettingsList, SelectList, type SettingItem } from "@earendil-works/pi-tui";
13
+ import type { ThinkingLevel } from "../../types.js";
14
+ import { getAgentConfig, getAvailableTypes, resolveType, discoverNewAgents } from "../../agents/agent-types.js";
15
+ import { findModelInRegistry } from "../../utils.js";
16
+ import { buildSettingsListTheme, buildSelectListTheme } from "./helpers.js";
17
+ import { DEFAULT_GRACE_TURNS } from "../../config/config-io.js";
18
+ import { createModelSelectSubmenu } from "./submenus/model-select.js";
19
+ import { createNumericSubmenu, createInputSubmenu } from "./submenus/numeric-input.js";
20
+ import { SettingsListWrapper } from "./wrappers/settings-list.js";
21
+ import {
22
+ getPiInstance,
23
+ getSessionCtx,
24
+ getWidget,
25
+ getStore,
26
+ getCoordinator,
27
+ } from "../../shell.js";
28
+
29
+ // ============================================================================
30
+ // Worktree picker helpers
31
+ // ============================================================================
32
+
33
+ /** Timeout for git worktree list command (ms). */
34
+ const WORKTREE_LIST_TIMEOUT_MS = 5000;
35
+
36
+ /** Max display length for a worktree path before truncation. */
37
+ const WORKTREE_PATH_TRUNCATE_LEN = 60;
38
+
39
+ interface WorktreeEntry {
40
+ path: string;
41
+ branch: string | null;
42
+ isDetached: boolean;
43
+ }
44
+
45
+ /**
46
+ * Parse `git worktree list --porcelain` output into structured entries.
47
+ *
48
+ * Format (one block per worktree, separated by blank lines):
49
+ * worktree /path/to/worktree
50
+ * HEAD <sha>
51
+ * branch refs/heads/<name> (or: (detached))
52
+ */
53
+ function parseWorktreeList(output: string): WorktreeEntry[] {
54
+ const entries: WorktreeEntry[] = [];
55
+ const blocks = output.split(/\n\n+/);
56
+ for (const block of blocks) {
57
+ if (!block.trim()) continue;
58
+ const lines = block.split("\n");
59
+ let path = "";
60
+ let branch: string | null = null;
61
+ let isDetached = false;
62
+ for (const line of lines) {
63
+ if (line.startsWith("worktree ")) {
64
+ path = line.slice("worktree ".length);
65
+ } else if (line.startsWith("branch refs/heads/")) {
66
+ branch = line.slice("branch refs/heads/".length);
67
+ } else if (line === "detached") {
68
+ isDetached = true;
69
+ }
70
+ }
71
+ if (path) {
72
+ entries.push({ path, branch, isDetached });
73
+ }
74
+ }
75
+ return entries;
76
+ }
77
+
78
+ /** Truncate a path for display, keeping the tail. */
79
+ function truncatePath(p: string): string {
80
+ if (p.length <= WORKTREE_PATH_TRUNCATE_LEN) return p;
81
+ return "..." + p.slice(p.length - WORKTREE_PATH_TRUNCATE_LEN + 3);
82
+ }
83
+
84
+ /**
85
+ * Fetch worktrees via `git worktree list --porcelain`.
86
+ * Returns null if git is unavailable or the command fails.
87
+ */
88
+ async function listWorktrees(cwd: string): Promise<WorktreeEntry[] | null> {
89
+ try {
90
+ const result = await getPiInstance().exec(
91
+ "git",
92
+ ["worktree", "list", "--porcelain"],
93
+ { cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
94
+ );
95
+ if (result.code !== 0) return null;
96
+ return parseWorktreeList(result.stdout);
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Check whether a directory is inside a git repository.
104
+ * Uses `git rev-parse --git-common-dir` — the same strategy as the worktree validator.
105
+ */
106
+ async function isInGitRepo(cwd: string): Promise<boolean> {
107
+ try {
108
+ const result = await getPiInstance().exec(
109
+ "git",
110
+ ["rev-parse", "--git-common-dir"],
111
+ { cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
112
+ );
113
+ return result.code === 0 && result.stdout.trim() !== "";
114
+ } catch {
115
+ return false;
116
+ }
117
+ }
118
+
119
+ // ============================================================================
120
+ // Spawn agent wizard
121
+ // ============================================================================
122
+
123
+ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
124
+
125
+ /**
126
+ * Show the spawn agent flow as a multi-step wizard:
127
+ * Step 1: type selection (SelectList)
128
+ * Step 2: prompt entry (Input)
129
+ * Step 3: options sub-menu with spawn (SettingsList with submenus)
130
+ */
131
+ export async function showSpawnAgentMenu(
132
+ ctx: ExtensionCommandContext,
133
+ modelOptions: string[],
134
+ ): Promise<void> {
135
+ // ---- Step 1: Type selection ----
136
+ let selectedType: string;
137
+ {
138
+ const types = getAvailableTypes();
139
+ if (types.length === 0) {
140
+ ctx.ui.notify("No agent types available", "error");
141
+ return;
142
+ }
143
+
144
+ const result = await ctx.ui.custom<string | undefined>((_tui, theme, _kb, done) => {
145
+ const items: SettingItem[] = types.map(t => ({
146
+ id: t,
147
+ label: t,
148
+ currentValue: t,
149
+ submenu: (_v: string, _subDone: (value?: string) => void) => {
150
+ done(t);
151
+ return undefined as any;
152
+ },
153
+ }));
154
+ const list = new SettingsList(
155
+ items,
156
+ 10,
157
+ buildSettingsListTheme(theme),
158
+ (id, value) => { done(value); },
159
+ () => done(undefined),
160
+ { enableSearch: true },
161
+ );
162
+ return new SettingsListWrapper(list, { title: "Select Agent Type", theme, passthroughKeys: true });
163
+ });
164
+ if (result === undefined) return;
165
+
166
+ const config = getAgentConfig(result);
167
+ if (!config) {
168
+ ctx.ui.notify(`Unknown agent type: ${result}`, "error");
169
+ return;
170
+ }
171
+ selectedType = result;
172
+ }
173
+
174
+ const agentConfig = getAgentConfig(selectedType)!;
175
+
176
+ // ---- Step 2: Prompt entry ----
177
+ let prompt: string;
178
+ {
179
+ const result = await ctx.ui.custom<string | undefined>((_tui, theme, _kb, done) => {
180
+ const input = createInputSubmenu(ctx, { required: true })("", done);
181
+ return new SettingsListWrapper(input, { title: "Agent Prompt", theme, passthroughKeys: true });
182
+ });
183
+ if (result === undefined) return;
184
+ prompt = result;
185
+ }
186
+
187
+ // ---- Step 3: Options sub-menu with spawn ----
188
+ const session = getSessionCtx();
189
+ const parentCwd = session?.cwd ?? "";
190
+ const inGitRepo = parentCwd ? await isInGitRepo(parentCwd) : false;
191
+ const worktrees = inGitRepo ? (await listWorktrees(parentCwd)) ?? [] : [];
192
+
193
+ const store = getStore();
194
+ const parentModelId = session?.model
195
+ ? `${session.model.provider}/${session.model.id}`
196
+ : "";
197
+ const effectiveModelStr = store.modelFor(selectedType, parentModelId, agentConfig);
198
+
199
+ let currentModelStr = effectiveModelStr || "";
200
+ let currentThinking: ThinkingLevel | undefined = agentConfig.thinkingLevel ?? store.agent.defaultThinking;
201
+ let currentMaxTurns: number | undefined = agentConfig.maxTurns ?? store.agent.defaultMaxTurns;
202
+ let currentMaxTokens: number | undefined = agentConfig.maxTokens;
203
+ let currentGraceTurns: number = store.agent.graceTurns ?? DEFAULT_GRACE_TURNS;
204
+ let currentBackground: boolean = store.agent.forceBackground;
205
+ let currentWorktreePath: string | undefined;
206
+ let currentWorktreeLabel = "Inherits parent cwd";
207
+ let currentDescription = prompt.length > 50 ? prompt.slice(0, 50) : prompt;
208
+
209
+ const buildItems = (): SettingItem[] => {
210
+ const fmtNum = (v: number | undefined) => v != null ? String(v) : "(not set)";
211
+ const displayModel = currentModelStr || "(inherits parent)";
212
+ const items: SettingItem[] = [
213
+ {
214
+ id: "spawn",
215
+ label: "Spawn",
216
+ currentValue: "",
217
+ submenu: (_v, done) => {
218
+ const gtItem = items.find(i => i.id === "graceTurns");
219
+ const bgItem = items.find(i => i.id === "background");
220
+ const descItem = items.find(i => i.id === "description");
221
+ const promptItem = items.find(i => i.id === "prompt");
222
+
223
+ const thinking = currentThinking;
224
+ const maxTurns = currentMaxTurns;
225
+ const maxTokens = currentMaxTokens;
226
+ const graceTurns = Number(gtItem?.currentValue ?? DEFAULT_GRACE_TURNS);
227
+ const background = bgItem?.currentValue === "ON";
228
+ const description = descItem?.currentValue ?? currentDescription;
229
+ const spawnPrompt = promptItem?.currentValue ?? prompt;
230
+
231
+ // Resolve model
232
+ let model: ReturnType<typeof findModelInRegistry> = undefined;
233
+ let modelKey: string | undefined;
234
+ if (currentModelStr) {
235
+ const registry = session?.modelRegistry ?? ctx.modelRegistry;
236
+ model = findModelInRegistry(currentModelStr, registry, undefined);
237
+ if (!model) {
238
+ ctx.ui.notify(`Model not found: ${currentModelStr}`, "error");
239
+ done();
240
+ return undefined as any;
241
+ }
242
+ modelKey = `${model.provider}/${model.id}`;
243
+ }
244
+
245
+ const doSpawn = async () => {
246
+ if (currentWorktreePath) {
247
+ await discoverNewAgents(`${currentWorktreePath}/.pi/agents`);
248
+ }
249
+ const resolvedType = resolveType(selectedType) ?? selectedType;
250
+
251
+ const widget = getWidget();
252
+ if (widget) {
253
+ widget.setUICtx(ctx.ui as unknown as import("../agent-widget.js").UICtx);
254
+ widget.ensureTimer();
255
+ }
256
+
257
+ const coordinator = getCoordinator()!;
258
+ try {
259
+ const result = await coordinator.spawn(getPiInstance(), session!, {
260
+ type: resolvedType,
261
+ prompt: spawnPrompt,
262
+ description,
263
+ model,
264
+ modelKey,
265
+ maxTurns,
266
+ maxTokens,
267
+ thinkingLevel: thinking,
268
+ graceTurns,
269
+ worktreePath: currentWorktreePath,
270
+ worktreeLabel: currentWorktreePath ? currentWorktreeLabel : undefined,
271
+ invocation: {
272
+ modelName: model?.id,
273
+ thinkingLevel: thinking,
274
+ maxTurns,
275
+ runInBackground: background,
276
+ },
277
+ runInBackground: background,
278
+ });
279
+
280
+ if (!background) {
281
+ getWidget()?.markFinished(result.agentId);
282
+ getWidget()?.update();
283
+ }
284
+ } catch (err) {
285
+ ctx.ui.notify(
286
+ `Spawn failed: ${err instanceof Error ? err.message : String(err)}`,
287
+ "error",
288
+ );
289
+ }
290
+ };
291
+
292
+ done();
293
+ doneRef();
294
+ doSpawn().catch(() => {});
295
+ return undefined as any;
296
+ },
297
+ },
298
+ {
299
+ id: "__sep__",
300
+ label: " ",
301
+ currentValue: "",
302
+ },
303
+ {
304
+ id: "model",
305
+ label: "Model",
306
+ currentValue: displayModel,
307
+ submenu: createModelSelectSubmenu({
308
+ modelOptions,
309
+ showClear: false,
310
+ theme,
311
+ onSelect: (_mode, model) => {
312
+ currentModelStr = model === "(inherits parent)" || model === null ? "" : model;
313
+ },
314
+ }),
315
+ },
316
+ {
317
+ id: "background",
318
+ label: "Background",
319
+ currentValue: currentBackground ? "ON" : "OFF",
320
+ values: ["ON", "OFF"],
321
+ },
322
+ ...(inGitRepo
323
+ ? [{
324
+ id: "worktree",
325
+ label: "Worktree",
326
+ currentValue: currentWorktreeLabel,
327
+ submenu: (_v: string, done: (v?: string) => void) => {
328
+ const pickerItems = [
329
+ { value: "Inherits parent cwd", label: "Inherits parent cwd" },
330
+ ...worktrees.map(wt => {
331
+ const branchLabel = wt.isDetached ? "detached" : (wt.branch ?? "detached");
332
+ const truncPath = truncatePath(wt.path);
333
+ return { value: wt.path, label: `${branchLabel} · ${truncPath}` };
334
+ }),
335
+ ];
336
+ const list = new SelectList(pickerItems, 10, buildSelectListTheme(theme));
337
+ list.onSelect = (item) => {
338
+ if (item.value === "Inherits parent cwd") {
339
+ currentWorktreePath = undefined;
340
+ done("Inherits parent cwd");
341
+ } else {
342
+ const wt = worktrees.find(w => w.path === item.value);
343
+ currentWorktreePath = wt?.path;
344
+ done(wt?.branch ?? "detached");
345
+ }
346
+ };
347
+ list.onCancel = () => done();
348
+ return list;
349
+ },
350
+ } as SettingItem]
351
+ : []),
352
+ {
353
+ id: "thinkingLevel",
354
+ label: "Thinking level",
355
+ currentValue: currentThinking ?? "inherit",
356
+ values: [...THINKING_LEVELS, "inherit"],
357
+ },
358
+ {
359
+ id: "maxTokens",
360
+ label: "Max tokens",
361
+ currentValue: fmtNum(currentMaxTokens),
362
+ submenu: createNumericSubmenu(ctx, (parsed) => { currentMaxTokens = parsed; }, () => { currentMaxTokens = undefined; }),
363
+ },
364
+ {
365
+ id: "maxTurns",
366
+ label: "Max turns",
367
+ currentValue: fmtNum(currentMaxTurns),
368
+ submenu: createNumericSubmenu(ctx, (parsed) => { currentMaxTurns = parsed; }, () => { currentMaxTurns = undefined; }),
369
+ },
370
+ {
371
+ id: "graceTurns",
372
+ label: "Grace turns",
373
+ currentValue: String(currentGraceTurns),
374
+ submenu: createNumericSubmenu(ctx, { min: 0, default: DEFAULT_GRACE_TURNS }, (parsed) => { currentGraceTurns = parsed; }),
375
+ },
376
+ { id: "__sep__", label: " ", currentValue: "" },
377
+ {
378
+ id: "description",
379
+ label: "Description",
380
+ currentValue: currentDescription,
381
+ submenu: createInputSubmenu(ctx),
382
+ },
383
+ {
384
+ id: "prompt",
385
+ label: "Prompt",
386
+ currentValue: prompt,
387
+ submenu: createInputSubmenu(ctx, { required: true }),
388
+ }
389
+ ];
390
+
391
+ return items;
392
+ };
393
+
394
+ let theme: any;
395
+ let doneRef: () => void;
396
+
397
+ await ctx.ui.custom((_tui, t, _kb, done) => {
398
+ theme = t;
399
+ doneRef = () => done(undefined);
400
+
401
+ const items = buildItems();
402
+ const onChange = (id: string, newValue: string) => {
403
+ switch (id) {
404
+ case "thinkingLevel":
405
+ currentThinking = newValue === "inherit" ? undefined : newValue as ThinkingLevel;
406
+ break;
407
+ case "background":
408
+ currentBackground = newValue === "ON";
409
+ break;
410
+ case "prompt":
411
+ prompt = newValue;
412
+ break;
413
+ }
414
+ };
415
+ const settingsList = new SettingsList(items, 15, buildSettingsListTheme(theme), onChange, doneRef);
416
+ return new SettingsListWrapper(settingsList, { title: "Spawn Options", theme, onCancel: () => doneRef() });
417
+ });
418
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * menu-system-prompt.ts — System prompt settings menu concern.
3
+ *
4
+ * Uses SettingsList from @earendil-works/pi-tui via ctx.ui.custom.
5
+ * SettingsList maintains internal cursor state, fixing the cursor-position
6
+ * reset bug that occurred with ctx.ui.select.
7
+ *
8
+ * Exports:
9
+ * - showSystemPromptMenu: system prompt mode, create prompt file, include AGENTS.md
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
15
+ import { SettingsList, type SettingItem } from "@earendil-works/pi-tui";
16
+ import { buildSettingsListTheme } from "./helpers.js";
17
+ import { SettingsListWrapper } from "./wrappers/settings-list.js";
18
+ import type { SystemPromptMode } from "../../agents/types.js";
19
+ import { getStore } from "../../shell.js";
20
+ import { CUSTOM_PROMPT_PATH } from "../../agents/agent-runner.js";
21
+
22
+ export async function showSystemPromptMenu(ctx: ExtensionCommandContext): Promise<void> {
23
+ const store = getStore();
24
+
25
+ const buildItems = (): SettingItem[] => {
26
+ const items: SettingItem[] = [
27
+ {
28
+ id: "systemPromptMode",
29
+ label: "System prompt mode",
30
+ currentValue: store.agent.systemPromptMode,
31
+ values: ["replace", "inherit", "custom"],
32
+ },
33
+ ];
34
+
35
+ // Create prompt file (only when mode is custom and file doesn't exist)
36
+ if (store.agent.systemPromptMode === "custom" && !fs.existsSync(CUSTOM_PROMPT_PATH)) {
37
+ items.push({
38
+ id: "createPromptFile",
39
+ label: "Create prompt file",
40
+ currentValue: CUSTOM_PROMPT_PATH,
41
+ values: ["Create"],
42
+ });
43
+ }
44
+
45
+ items.push(
46
+ {
47
+ id: "includeContextFiles",
48
+ label: "Include AGENTS.md",
49
+ currentValue: store.agent.includeContextFiles ? "ON" : "OFF",
50
+ values: ["ON", "OFF"],
51
+ },
52
+ {
53
+ id: "loadSkillsImplicitly",
54
+ label: "Load skills implicitly",
55
+ currentValue: store.agent.loadSkillsImplicitly ? "ON" : "OFF",
56
+ values: ["ON", "OFF"],
57
+ },
58
+ {
59
+ id: "loadExtensionsImplicitly",
60
+ label: "Load extensions implicitly",
61
+ currentValue: store.agent.loadExtensionsImplicitly ? "ON" : "OFF",
62
+ values: ["ON", "OFF"],
63
+ },
64
+ );
65
+
66
+ return items;
67
+ };
68
+
69
+ let items = buildItems();
70
+ let rebuild: ((newItems: SettingItem[]) => void) | null = null;
71
+
72
+ const onChange = (id: string, newValue: string) => {
73
+ switch (id) {
74
+ case "systemPromptMode":
75
+ store.mutate.agent.setSystemPromptMode(newValue as SystemPromptMode);
76
+ ctx.ui.notify(`System prompt mode set to ${newValue}`, "info");
77
+ // Rebuild: "custom" adds the create prompt file item, other modes remove it.
78
+ items = buildItems();
79
+ rebuild?.(items);
80
+ break;
81
+ case "createPromptFile":
82
+ try {
83
+ fs.mkdirSync(path.dirname(CUSTOM_PROMPT_PATH), { recursive: true });
84
+ fs.writeFileSync(CUSTOM_PROMPT_PATH, "You are a Pi, an expert coding sub-agent.\nYou have been invoked to handle a specific task autonomously", "utf-8");
85
+ ctx.ui.notify(`Created prompt file: ${CUSTOM_PROMPT_PATH}`, "info");
86
+ } catch (err: any) {
87
+ ctx.ui.notify(`Failed to create prompt file: ${err.message}`, "error");
88
+ }
89
+ return;
90
+ case "includeContextFiles":
91
+ store.mutate.agent.setIncludeContextFiles(newValue === "ON");
92
+ ctx.ui.notify(`Include AGENTS.md set to ${newValue}`, "info");
93
+ break;
94
+ case "loadSkillsImplicitly":
95
+ store.mutate.agent.setLoadSkillsImplicitly(newValue === "ON");
96
+ ctx.ui.notify(`Load skills implicitly set to ${newValue}`, "info");
97
+ break;
98
+ case "loadExtensionsImplicitly":
99
+ store.mutate.agent.setLoadExtensionsImplicitly(newValue === "ON");
100
+ ctx.ui.notify(`Load extensions implicitly set to ${newValue}`, "info");
101
+ break;
102
+ }
103
+ };
104
+
105
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
106
+ const settingsList = new SettingsList(items, 10, buildSettingsListTheme(theme), onChange, () => done(undefined));
107
+ return new SettingsListWrapper(settingsList, { title: "System Prompt", theme, onCancel: () => done(undefined), onRebuild: (r) => { rebuild = r; } });
108
+ });
109
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * menu-widget-settings.ts — Widget settings menu concern.
3
+ *
4
+ * Uses SettingsList from @earendil-works/pi-tui via ctx.ui.custom.
5
+ * SettingsList maintains internal cursor state, fixing the cursor-position
6
+ * reset bug that occurred with ctx.ui.select.
7
+ *
8
+ * Structure:
9
+ * Main list: compact, maxLines, descLengthFull, maxLinesCompact, descLengthCompact, shortcut, usageStats
10
+ * Usage stats submenu: 7 stat visibility toggles
11
+ *
12
+ * Exports:
13
+ * - showWidgetSettingsMenu
14
+ */
15
+
16
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
17
+ import { SettingsList, type SettingItem } from "@earendil-works/pi-tui";
18
+ import { buildSettingsListTheme } from "./helpers.js";
19
+ import { createNumericSubmenu } from "./submenus/numeric-input.js";
20
+ import { SettingsListWrapper } from "./wrappers/settings-list.js";
21
+ import { getStore } from "../../shell.js";
22
+
23
+ /** Stat visibility config — label and store accessors keyed by stat id. */
24
+ function buildStatConfig(store: ReturnType<typeof getStore>) {
25
+ return new Map<string, { label: string; get: () => boolean; set: (v: boolean) => void }>([
26
+ ["showTools", { label: "Show tools", get: () => store.agent.showTools, set: (v) => store.mutate.agent.setShowTools(v) }],
27
+ ["showTurns", { label: "Show turns", get: () => store.agent.showTurns, set: (v) => store.mutate.agent.setShowTurns(v) }],
28
+ ["showInput", { label: "Show input tokens", get: () => store.agent.showInput, set: (v) => store.mutate.agent.setShowInput(v) }],
29
+ ["showOutput", { label: "Show output tokens", get: () => store.agent.showOutput, set: (v) => store.mutate.agent.setShowOutput(v) }],
30
+ ["showContext", { label: "Show context %", get: () => store.agent.showContext, set: (v) => store.mutate.agent.setShowContext(v) }],
31
+ ["showCost", { label: "Show cost", get: () => store.agent.showCost, set: (v) => store.mutate.agent.setShowCost(v) }],
32
+ ["showTime", { label: "Show time", get: () => store.agent.showTime, set: (v) => store.mutate.agent.setShowTime(v) }],
33
+ ]);
34
+ }
35
+
36
+ export async function showWidgetSettingsMenu(ctx: ExtensionCommandContext): Promise<void> {
37
+ const store = getStore();
38
+ const statConfig = buildStatConfig(store);
39
+
40
+ const onChange = (id: string, newValue: string) => {
41
+ const stat = statConfig.get(id);
42
+ if (stat) {
43
+ stat.set(newValue === "ON");
44
+ ctx.ui.notify(`${stat.label} ${newValue}`, "info");
45
+ return;
46
+ }
47
+
48
+ switch (id) {
49
+ case "compact":
50
+ store.mutate.widget.setCompact(newValue === "ON");
51
+ ctx.ui.notify(`Force compact mode ${newValue}`, "info");
52
+ break;
53
+ case "shortcut":
54
+ store.mutate.widget.setShortcut(newValue === "ON");
55
+ ctx.ui.notify(`Ctrl+o shortcut ${newValue}`, "info");
56
+ break;
57
+ }
58
+ };
59
+
60
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
61
+ const statItems: SettingItem[] = [...statConfig.entries()].map(([id, cfg]) => ({
62
+ id,
63
+ label: cfg.label,
64
+ currentValue: cfg.get() ? "ON" : "OFF",
65
+ values: ["ON", "OFF"],
66
+ }));
67
+
68
+ const items: SettingItem[] = [
69
+ {
70
+ id: "compact",
71
+ label: "Force compact mode",
72
+ currentValue: store.agent.widgetCompact ? "ON" : "OFF",
73
+ values: ["ON", "OFF"],
74
+ },
75
+ {
76
+ id: "maxLines",
77
+ label: "Max lines (full)",
78
+ currentValue: String(store.agent.widgetMaxLines),
79
+ submenu: createNumericSubmenu(ctx, { min: 2 }, (parsed) => {
80
+ store.mutate.widget.setMaxLines(parsed);
81
+ ctx.ui.notify(`Max lines (full) set to ${parsed}`, "info");
82
+ }),
83
+ },
84
+ {
85
+ id: "descLengthFull",
86
+ label: "Description length (full)",
87
+ currentValue: String(store.agent.widgetDescLengthFull),
88
+ submenu: createNumericSubmenu(ctx, { min: 5 }, (parsed) => {
89
+ store.mutate.widget.setDescLengthFull(parsed);
90
+ ctx.ui.notify(`Description length (full) set to ${parsed}`, "info");
91
+ }),
92
+ },
93
+ {
94
+ id: "maxLinesCompact",
95
+ label: "Max lines (compact)",
96
+ currentValue: String(store.agent.widgetMaxLinesCompact),
97
+ submenu: createNumericSubmenu(ctx, (parsed) => {
98
+ store.mutate.widget.setMaxLinesCompact(parsed);
99
+ ctx.ui.notify(`Max lines (compact) set to ${parsed}`, "info");
100
+ }),
101
+ },
102
+ {
103
+ id: "descLengthCompact",
104
+ label: "Description length (compact)",
105
+ currentValue: String(store.agent.widgetDescLengthCompact),
106
+ submenu: createNumericSubmenu(ctx, { min: 5 }, (parsed) => {
107
+ store.mutate.widget.setDescLengthCompact(parsed);
108
+ ctx.ui.notify(`Description length (compact) set to ${parsed}`, "info");
109
+ }),
110
+ },
111
+ {
112
+ id: "shortcut",
113
+ label: "Ctrl+o shortcut",
114
+ currentValue: store.agent.widgetShortcut ? "ON" : "OFF",
115
+ values: ["ON", "OFF"],
116
+ },
117
+ { id: "__sep__", label: " ", currentValue: "" },
118
+ {
119
+ id: "usageStats",
120
+ label: "Usage stats",
121
+ currentValue: "→",
122
+ submenu: (_currentValue, done2) =>
123
+ new SettingsList(statItems, 7, buildSettingsListTheme(theme), onChange, () => done2()),
124
+ },
125
+ ];
126
+
127
+ const settingsList = new SettingsList(items, 15, buildSettingsListTheme(theme), onChange, () => done(undefined));
128
+ return new SettingsListWrapper(settingsList, { title: "Widget Settings", theme, onCancel: () => done(undefined) });
129
+ });
130
+ }