pi-orchestrator-config 4.2.0 → 4.3.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.
Files changed (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Settings TUI — interactive editor for pi-config settings.
3
+ *
4
+ * /pi-config-settings [scope] opens a fullscreen overlay with category tabs,
5
+ * themed header/footer, colored source glyphs, and fuzzy-searchable pickers.
6
+ * Left/Right arrows switch categories. Tab switches project/global scope.
7
+ */
8
+
9
+ import { createLogger } from "../shared/logger.js";
10
+ import type { ExtensionAPI, ExtensionCommandContext, ModelRegistry, Theme } from "@earendil-works/pi-coding-agent";
11
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ type SelectItem,
14
+ type SettingItem,
15
+ SettingsList,
16
+ matchesKey,
17
+ Key,
18
+ truncateToWidth,
19
+ visibleWidth,
20
+ } from "@earendil-works/pi-tui";
21
+ import type { Component, TUI } from "@earendil-works/pi-tui";
22
+ import { existsSync } from "node:fs";
23
+ import {
24
+ SETTINGS_KEYS,
25
+ type SettingsKeyDef,
26
+ getSetting,
27
+ clearSettingsCache,
28
+ } from "./project-settings.js";
29
+ import { discoverAgents } from "./agents.js";
30
+ import {
31
+ CATEGORIES,
32
+ detectSource,
33
+ formatValue,
34
+ parseRawValue,
35
+ getFilePathForScope,
36
+ readSettingsFile,
37
+ writeSettingsFile,
38
+ registerSettingsTuiCommand,
39
+ isSecretNoChange,
40
+ resolveSecretPrefill,
41
+ sourceGlyph,
42
+ resolveModelKeySubmenu,
43
+ type ModelSelectItem,
44
+ } from "./settings-tui-helpers.js";
45
+ import {
46
+ OVERLAY_OPTS,
47
+ borderSegment,
48
+ padAnsi,
49
+ } from "./overlay-dashboard.js";
50
+ import {
51
+ InputSubmenu,
52
+ NumberInputSubmenu,
53
+ PickerSubmenu,
54
+ MultiSelectSubmenu,
55
+ AgentOverridesSubmenu,
56
+ SelectSubmenu,
57
+ } from "./settings-tui-submenus.js";
58
+ import settingsSchema from "../../settings-keys.json" with { type: "json" };
59
+
60
+ // Re-export helpers for external consumers
61
+ const log = createLogger("settings_tui");
62
+ log.debug("settings-tui module loaded");
63
+
64
+ export {
65
+ CATEGORIES,
66
+ type CategoryDef,
67
+ type SettingSource,
68
+ detectSource,
69
+ formatValue,
70
+ parseRawValue,
71
+ getFilePathForScope,
72
+ readSettingsFile,
73
+ writeSettingsFile,
74
+ registerSettingsTuiCommand,
75
+ isSecretNoChange,
76
+ resolveSecretPrefill,
77
+ sourceGlyph,
78
+ filterModelsByProvider,
79
+ resolveModelPickerProvider,
80
+ resolveModelPickerItems,
81
+ resolveModelKeySubmenu,
82
+ } from "./settings-tui-helpers.js";
83
+
84
+ // ── Provider/Model data helpers ─────────────────────────────────────
85
+
86
+ interface ProviderModelInfo {
87
+ providers: SelectItem[];
88
+ models: ModelSelectItem[];
89
+ }
90
+
91
+ function getProviderModelInfo(modelRegistry: ModelRegistry | undefined): ProviderModelInfo {
92
+ if (!modelRegistry) return { providers: [], models: [] };
93
+
94
+ const models = modelRegistry.getAvailable?.() || modelRegistry.getAll?.() || [];
95
+ log.debug("getProviderModelInfo", { modelCount: models.length });
96
+
97
+ const providerSet = new Set<string>();
98
+ for (const m of models) {
99
+ if (!(m.provider || "").startsWith("acpx-")) {
100
+ providerSet.add(m.provider);
101
+ }
102
+ }
103
+
104
+ const providers: SelectItem[] = [...providerSet].sort().map((p) => ({
105
+ value: p,
106
+ label: p,
107
+ }));
108
+
109
+ const modelItems: ModelSelectItem[] = models
110
+ .filter((m) => !(m.provider || "").startsWith("acpx-"))
111
+ .map((m) => {
112
+ const extra = m as { input?: string[]; output?: string[] };
113
+ return {
114
+ value: m.id,
115
+ label: m.id,
116
+ description: m.provider,
117
+ input: Array.isArray(extra.input) ? extra.input : undefined,
118
+ output: Array.isArray(extra.output) ? extra.output : undefined,
119
+ };
120
+ });
121
+
122
+ return { providers, models: modelItems };
123
+ }
124
+
125
+ // ── Available agent names (for agent_overrides) ─────────────────────
126
+
127
+ function getAvailableAgentNames(cwd: string): string[] {
128
+ const { agents } = discoverAgents(cwd, "both");
129
+ return agents.map((a) => a.name).sort();
130
+ }
131
+
132
+ // ── Known CLI/ACPX provider names (for acpx_agents / cli_agents) ──
133
+
134
+ const KNOWN_CLI_PROVIDERS = ["cursor", "claude", "gemini"];
135
+
136
+ // ── Refresh a single item's label + value in-place ──────────────────
137
+
138
+ function refreshItem(item: SettingItem, cwd: string, theme: Theme): void {
139
+ const def = SETTINGS_KEYS[item.id];
140
+ if (!def) return;
141
+ const effectiveValue = getSetting(cwd, item.id as any);
142
+ const source = detectSource(item.id, def, cwd);
143
+ const displayValue = formatValue(item.id, effectiveValue, def);
144
+ const glyph = sourceGlyph(source, theme);
145
+ // Mutate in-place — SettingsList stores items array by reference (verified in pi-tui source)
146
+ item.label = `${glyph} ${item.id}`;
147
+ item.currentValue = displayValue;
148
+ }
149
+
150
+ // ── Build setting items for a single category ───────────────────────
151
+
152
+ export function buildCategoryItems(
153
+ categoryIndex: number,
154
+ cwd: string,
155
+ editScope: "project" | "global",
156
+ theme: Theme,
157
+ modelRegistry?: ModelRegistry,
158
+ ): SettingItem[] {
159
+ const category = CATEGORIES[categoryIndex];
160
+ if (!category) return [];
161
+
162
+ const items: SettingItem[] = [];
163
+ const pmInfo = getProviderModelInfo(modelRegistry);
164
+ const availableAgents = getAvailableAgentNames(cwd);
165
+
166
+ for (const key of category.keys) {
167
+ const def = SETTINGS_KEYS[key];
168
+ if (!def) continue;
169
+
170
+ const effectiveValue = getSetting(cwd, key as any);
171
+ const source = detectSource(key, def, cwd);
172
+ const displayValue = formatValue(key, effectiveValue, def);
173
+ const glyph = sourceGlyph(source, theme);
174
+
175
+ const item: SettingItem = {
176
+ id: key,
177
+ label: `${glyph} ${key}`,
178
+ currentValue: displayValue,
179
+ description: [def.description, def.env ? `env: ${def.env}` : ""].filter(Boolean).join(" · "),
180
+ };
181
+
182
+ // Configure interaction based on type and key
183
+ const isProviderKey = key === "agent_provider" || key === "internal_operations_provider";
184
+ const isModelKey =
185
+ key === "agent_model" || key === "internal_operations_model" || key === "image_model";
186
+
187
+ if (isProviderKey && pmInfo.providers.length > 0) {
188
+ item.submenu = (_current: string, done: (val?: string) => void): Component => {
189
+ return new PickerSubmenu(`Select provider for ${key}`, pmInfo.providers, theme, done);
190
+ };
191
+ } else if (isModelKey && pmInfo.models.length > 0) {
192
+ // Resolve provider/models inside the callback so refreshItem after
193
+ // agent_provider / internal_operations_provider changes does not leave a stale list.
194
+ item.submenu = (current: string, done: (val?: string) => void): Component => {
195
+ const submenu = resolveModelKeySubmenu(key, pmInfo.models, {
196
+ agent_provider: getSetting(cwd, "agent_provider"),
197
+ internal_operations_provider: getSetting(cwd, "internal_operations_provider"),
198
+ });
199
+ // image_model: provider-filtered list; if empty, free-text (do not show all models)
200
+ if (submenu.mode === "input") {
201
+ log.debug("image_model picker empty — free-text fallback", { provider: submenu.provider });
202
+ return new InputSubmenu(key, current, "Enter new value (empty to clear)", theme, done);
203
+ }
204
+ if (key === "image_model") {
205
+ log.debug("image_model picker open", {
206
+ provider: submenu.provider,
207
+ count: submenu.models.length,
208
+ });
209
+ }
210
+ return new PickerSubmenu(`Select model for ${key}`, submenu.models, theme, done);
211
+ };
212
+ } else {
213
+ switch (def.type) {
214
+ case "bool":
215
+ case "bool_enable":
216
+ item.values = ["true", "false"];
217
+ break;
218
+
219
+ case "bool_or_string":
220
+ item.submenu = (current: string, done: (val?: string) => void): Component => {
221
+ return new InputSubmenu(
222
+ `${key} (bool or custom string)`,
223
+ current === "true" || current === "false" ? current : current,
224
+ 'Enter "true", "false", or a custom string',
225
+ theme,
226
+ done,
227
+ );
228
+ };
229
+ break;
230
+
231
+ case "string": {
232
+ const schemaDef = settingsSchema[key as keyof typeof settingsSchema] as any;
233
+ const enumValues: string[] | undefined = schemaDef?.enum;
234
+
235
+ if (enumValues && enumValues.length > 0) {
236
+ item.submenu = (current: string, done: (val?: string) => void): Component => {
237
+ return new SelectSubmenu(key, enumValues, current, theme, done);
238
+ };
239
+ break;
240
+ }
241
+
242
+ const SECRET_PATTERN = /token|secret|password|auth/i;
243
+ const isSecret = SECRET_PATTERN.test(key) || (typeof def?.env === "string" && SECRET_PATTERN.test(def.env));
244
+ const secretInfo = isSecret ? resolveSecretPrefill(key, editScope, cwd) : null;
245
+ item.submenu = (current: string, done: (val?: string) => void): Component => {
246
+ const prefill = secretInfo ? secretInfo.prefill : current;
247
+ const hint = secretInfo ? secretInfo.hint : "Enter new value (empty to clear)";
248
+ return new InputSubmenu(key, prefill, hint, theme, (val?: string) => {
249
+ if (isSecret && secretInfo && isSecretNoChange(val, secretInfo.scopeValue !== null)) {
250
+ done(undefined);
251
+ return;
252
+ }
253
+ done(val);
254
+ });
255
+ };
256
+ break;
257
+ }
258
+
259
+ case "int":
260
+ case "port":
261
+ case "number":
262
+ item.submenu = (current: string, done: (val?: string) => void): Component => {
263
+ return new NumberInputSubmenu(key, current, def, theme, done);
264
+ };
265
+ break;
266
+
267
+ case "agent_list":
268
+ item.submenu = (_current: string, done: (val?: string) => void): Component => {
269
+ const currentAgents = Array.isArray(effectiveValue) ? effectiveValue as string[] : [];
270
+ return new MultiSelectSubmenu(
271
+ `${key} — select providers`,
272
+ KNOWN_CLI_PROVIDERS,
273
+ currentAgents,
274
+ theme,
275
+ done,
276
+ );
277
+ };
278
+ break;
279
+
280
+ case "agent_overrides":
281
+ item.submenu = (_current: string, done: (val?: string) => void): Component => {
282
+ const currentOverrides = (typeof effectiveValue === "object" && effectiveValue !== null && !Array.isArray(effectiveValue))
283
+ ? effectiveValue as Record<string, { provider?: string | null; model?: string | null }>
284
+ : {};
285
+ return new AgentOverridesSubmenu(
286
+ "Agent overrides — per-agent provider/model",
287
+ currentOverrides,
288
+ availableAgents,
289
+ theme,
290
+ done,
291
+ pmInfo.providers,
292
+ pmInfo.models,
293
+ );
294
+ };
295
+ break;
296
+ }
297
+ }
298
+
299
+ items.push(item);
300
+ }
301
+
302
+ return items;
303
+ }
304
+
305
+ // Keep legacy buildSettingItems for backward compat / tests
306
+ export function buildSettingItems(
307
+ cwd: string,
308
+ editScope: "project" | "global",
309
+ theme: Theme,
310
+ modelRegistry?: ModelRegistry,
311
+ ): SettingItem[] {
312
+ const all: SettingItem[] = [];
313
+ for (let i = 0; i < CATEGORIES.length; i++) {
314
+ all.push(...buildCategoryItems(i, cwd, editScope, theme, modelRegistry));
315
+ }
316
+ return all;
317
+ }
318
+
319
+ // ── Safe SettingsList state access ──────────────────────────────────
320
+ // SettingsList fields are TypeScript-private (compile-time only) — accessible at runtime.
321
+
322
+ function getSettingsListState(list: SettingsList): {
323
+ hasActiveSearch: boolean;
324
+ hasActiveSubmenu: boolean;
325
+ selectedIndex: number;
326
+ displayItems: SettingItem[];
327
+ } {
328
+ const raw = list as any;
329
+ const searchInput = raw.searchInput;
330
+ const hasActiveSearch = !!(searchInput && searchInput.getValue && searchInput.getValue().length > 0);
331
+ const hasActiveSubmenu = !!raw.submenuComponent;
332
+ const selectedIndex: number = raw.selectedIndex ?? 0;
333
+ const displayItems: SettingItem[] = raw.searchEnabled ? (raw.filteredItems ?? raw.items) : (raw.items ?? []);
334
+ return { hasActiveSearch, hasActiveSubmenu, selectedIndex, displayItems };
335
+ }
336
+
337
+ // ── Save a single change ────────────────────────────────────────
338
+
339
+ type SaveResult = { ok: true } | { ok: false; reason: "corrupt" | "write_error"; error?: unknown };
340
+
341
+ function saveChange(key: string, value: unknown, scope: "project" | "global", cwd: string): SaveResult {
342
+ const filePath = getFilePathForScope(scope, cwd);
343
+ const current = readSettingsFile(filePath);
344
+ if (current === null) return { ok: false, reason: "corrupt" };
345
+ if (value === undefined) {
346
+ if (!(key in current)) return { ok: true };
347
+ delete current[key];
348
+ } else {
349
+ current[key] = value;
350
+ }
351
+ if (Object.keys(current).length === 0 && !existsSync(filePath)) return { ok: true };
352
+ try {
353
+ writeSettingsFile(filePath, current);
354
+ } catch (e) {
355
+ return { ok: false, reason: "write_error", error: e };
356
+ }
357
+ clearSettingsCache();
358
+ return { ok: true };
359
+ }
360
+
361
+ // ── Delete a key from the current scope ─────────────────────────
362
+
363
+ function deleteFromScope(key: string, scope: "project" | "global", cwd: string): SaveResult {
364
+ const filePath = getFilePathForScope(scope, cwd);
365
+ const current = readSettingsFile(filePath);
366
+ if (current === null) return { ok: false, reason: "corrupt" };
367
+ if (!(key in current)) return { ok: true };
368
+ delete current[key];
369
+ try {
370
+ writeSettingsFile(filePath, current);
371
+ } catch (e) {
372
+ return { ok: false, reason: "write_error", error: e };
373
+ }
374
+ clearSettingsCache();
375
+ return { ok: true };
376
+ }
377
+
378
+ // ── Parse value for agent_overrides ─────────────────────────────
379
+
380
+ function parseOverridesValue(rawValue: string): Record<string, { provider?: string | null; model?: string | null }> {
381
+ try {
382
+ const parsed = JSON.parse(rawValue);
383
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
384
+ return parsed;
385
+ }
386
+ } catch {}
387
+ return {};
388
+ }
389
+
390
+ // ── Settings overlay component ──────────────────────────────────
391
+
392
+ class SettingsOverlay implements Component {
393
+ private tui: TUI;
394
+ private theme: Theme;
395
+ private cwd: string;
396
+ private modelRegistry: ModelRegistry | undefined;
397
+ private editScope: "project" | "global";
398
+ private categoryIndex: number;
399
+ private done: (value: undefined) => void;
400
+ private notify: (msg: string, level: "info" | "error") => void;
401
+ private settingsList!: SettingsList;
402
+ private currentItems: SettingItem[];
403
+ private cachedWidth?: number;
404
+ private cachedLines?: string[];
405
+
406
+ constructor(
407
+ tui: TUI,
408
+ theme: Theme,
409
+ cwd: string,
410
+ modelRegistry: ModelRegistry | undefined,
411
+ initialScope: "project" | "global",
412
+ done: (value: undefined) => void,
413
+ notify: (msg: string, level: "info" | "error") => void,
414
+ ) {
415
+ this.tui = tui;
416
+ this.theme = theme;
417
+ this.cwd = cwd;
418
+ this.modelRegistry = modelRegistry;
419
+ this.editScope = initialScope;
420
+ this.categoryIndex = 0;
421
+ this.done = done;
422
+ this.notify = notify;
423
+ this.currentItems = [];
424
+ this.rebuild();
425
+ }
426
+
427
+ private rebuild(): void {
428
+ this.currentItems = buildCategoryItems(this.categoryIndex, this.cwd, this.editScope, this.theme, this.modelRegistry);
429
+
430
+ this.settingsList = new SettingsList(
431
+ this.currentItems,
432
+ 20,
433
+ getSettingsListTheme(),
434
+ (id: string, newValue: string) => {
435
+ const def = SETTINGS_KEYS[id];
436
+ if (!def) return;
437
+
438
+ let parsed: unknown;
439
+ if (def.type === "agent_overrides") {
440
+ parsed = parseOverridesValue(newValue);
441
+ } else {
442
+ parsed = parseRawValue(id, newValue, def);
443
+ }
444
+
445
+ const filePath = getFilePathForScope(this.editScope, this.cwd);
446
+ const result = saveChange(id, parsed, this.editScope, this.cwd);
447
+ if (!result.ok) {
448
+ const msg = result.reason === "corrupt"
449
+ ? `Failed to save: settings file is corrupt (${filePath})`
450
+ : `Failed to write settings: ${(result.error as any)?.message || "unknown error"}`;
451
+ this.notify(msg, "error");
452
+ return;
453
+ }
454
+ log.info("settings_changed", id, String(parsed).slice(0, 60));
455
+ if (filePath.endsWith(".jsonc")) {
456
+ this.notify("Note: comments were stripped from .jsonc file", "info");
457
+ }
458
+
459
+ // When provider changes, auto-clear the paired model to prevent stale cross-provider model
460
+ if (id === "agent_provider") {
461
+ saveChange("agent_model", "", this.editScope, this.cwd);
462
+ const modelItem = this.currentItems.find((i) => i.id === "agent_model");
463
+ if (modelItem) refreshItem(modelItem, this.cwd, this.theme);
464
+ } else if (id === "internal_operations_provider") {
465
+ saveChange("internal_operations_model", "", this.editScope, this.cwd);
466
+ const modelItem = this.currentItems.find((i) => i.id === "internal_operations_model");
467
+ if (modelItem) refreshItem(modelItem, this.cwd, this.theme);
468
+ }
469
+
470
+ // Update label + value in-place (SettingsList stores items by reference, no render cache)
471
+ const item = this.currentItems.find((i) => i.id === id);
472
+ if (item) refreshItem(item, this.cwd, this.theme);
473
+ // SettingsOverlay render cache must be cleared; SettingsList has no render cache
474
+ this.invalidate();
475
+ this.tui.requestRender();
476
+ },
477
+ () => this.done(undefined),
478
+ { enableSearch: true },
479
+ );
480
+
481
+ this.invalidate();
482
+ }
483
+
484
+ handleInput(data: string): void {
485
+ // Tab to switch scope
486
+ if (matchesKey(data, Key.tab)) {
487
+ this.editScope = this.editScope === "project" ? "global" : "project";
488
+ this.rebuild();
489
+ this.tui.requestRender();
490
+ return;
491
+ }
492
+
493
+ // Left/Right to switch category tabs
494
+ if (matchesKey(data, Key.left)) {
495
+ this.categoryIndex = (this.categoryIndex - 1 + CATEGORIES.length) % CATEGORIES.length;
496
+ this.rebuild();
497
+ this.tui.requestRender();
498
+ return;
499
+ }
500
+ if (matchesKey(data, Key.right)) {
501
+ this.categoryIndex = (this.categoryIndex + 1) % CATEGORIES.length;
502
+ this.rebuild();
503
+ this.tui.requestRender();
504
+ return;
505
+ }
506
+
507
+ // Delete key: remove setting from current scope (skip when search active or submenu open)
508
+ if (matchesKey(data, Key.delete)) {
509
+ const state = getSettingsListState(this.settingsList);
510
+ if (!state.hasActiveSubmenu && !state.hasActiveSearch) {
511
+ const selectedItem = state.displayItems[state.selectedIndex];
512
+ if (selectedItem) {
513
+ const filePath = getFilePathForScope(this.editScope, this.cwd);
514
+ const delResult = deleteFromScope(selectedItem.id, this.editScope, this.cwd);
515
+ if (!delResult.ok) {
516
+ const msg = delResult.reason === "corrupt"
517
+ ? `Failed to delete: settings file is corrupt (${filePath})`
518
+ : `Failed to delete: ${(delResult.error as any)?.message || "unknown error"}`;
519
+ this.notify(msg, "error");
520
+ } else {
521
+ this.notify(`Deleted ${selectedItem.id} from ${this.editScope} scope`, "info");
522
+ refreshItem(selectedItem, this.cwd, this.theme);
523
+ }
524
+ this.invalidate();
525
+ this.tui.requestRender();
526
+ return;
527
+ }
528
+ }
529
+ }
530
+
531
+ // Forward all input to SettingsList (handles navigation, enter, space, escape, search)
532
+ this.settingsList.handleInput?.(data);
533
+ this.invalidate();
534
+ this.tui.requestRender();
535
+ }
536
+
537
+ render(width: number): string[] {
538
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
539
+
540
+ const theme = this.theme;
541
+ const rows = this.tui.terminal.rows || 30;
542
+ const innerWidth = Math.max(0, width - 2);
543
+ const lines: string[] = [];
544
+
545
+ const currentCategory = CATEGORIES[this.categoryIndex];
546
+ const scopeLabel = this.editScope === "project" ? "Project" : "Global";
547
+
548
+ // ── Header: category tabs ──
549
+ const tabs: string[] = [];
550
+ for (let i = 0; i < CATEGORIES.length; i++) {
551
+ const cat = CATEGORIES[i];
552
+ if (i === this.categoryIndex) {
553
+ tabs.push(theme.fg("accent", theme.bold(` ${cat.label} `)));
554
+ } else {
555
+ tabs.push(theme.fg("dim", ` ${cat.label} `));
556
+ }
557
+ }
558
+ const headerLeft = theme.fg("accent", theme.bold("Settings"));
559
+ lines.push(truncateToWidth(` ${headerLeft} ${tabs.join(theme.fg("border", "│"))}`, width));
560
+
561
+ // ── Top border with title ──
562
+ const borderTitle = `${currentCategory.label} · ${scopeLabel} · ${currentCategory.keys.length} keys`;
563
+ lines.push(
564
+ theme.fg("border", "╭") +
565
+ borderSegment(theme, innerWidth, borderTitle) +
566
+ theme.fg("border", "╮"),
567
+ );
568
+
569
+ // ── Settings list body ──
570
+ const divider = theme.fg("border", "│");
571
+ const bodyHeight = Math.max(6, rows - 6);
572
+ const settingsLines = this.settingsList.render(innerWidth);
573
+
574
+ for (let i = 0; i < bodyHeight; i++) {
575
+ const line = settingsLines[i] ?? "";
576
+ lines.push(divider + padAnsi(line, innerWidth) + divider);
577
+ }
578
+
579
+ // ── Bottom border ──
580
+ lines.push(
581
+ theme.fg("border", "╰") +
582
+ theme.fg("border", "─".repeat(innerWidth)) +
583
+ theme.fg("border", "╯"),
584
+ );
585
+
586
+ // ── Footer legend + hints ──
587
+ const legend = ` ${theme.fg("success", "P")}=${theme.fg("dim", "project")} ${theme.fg("accent", "G")}=${theme.fg("dim", "global")} ${theme.fg("warning", "E")}=${theme.fg("dim", "env")} ${theme.fg("dim", "D")}=${theme.fg("dim", "default")}`;
588
+ lines.push(truncateToWidth(legend, width));
589
+ lines.push(truncateToWidth(
590
+ theme.fg("dim", ` ←→ category · Tab: ${scopeLabel === "Project" ? "Global" : "Project"} scope · ↑↓ navigate · Enter edit · Del remove · Esc close`),
591
+ width,
592
+ ));
593
+
594
+ this.cachedLines = lines;
595
+ this.cachedWidth = width;
596
+ return lines;
597
+ }
598
+
599
+ invalidate(): void {
600
+ this.cachedWidth = undefined;
601
+ this.cachedLines = undefined;
602
+ }
603
+ }
604
+
605
+ // ── Main command handler ────────────────────────────────────────
606
+
607
+ async function openSettingsTui(ctx: ExtensionCommandContext, initialScope?: string): Promise<void> {
608
+ log.info("settings_opened");
609
+ if (!ctx.hasUI) return;
610
+ if (ctx.mode !== "tui") {
611
+ ctx.ui.notify("/pi-config-settings requires TUI mode", "error");
612
+ return;
613
+ }
614
+
615
+ const editScope: "project" | "global" = initialScope === "global" ? "global" : "project";
616
+ const cwd = ctx.cwd;
617
+ const modelRegistry = ctx.modelRegistry;
618
+
619
+ // Clear cached settings so TUI always shows fresh values
620
+ clearSettingsCache();
621
+
622
+ await ctx.ui.custom<undefined>(
623
+ (tui, theme, _kb, done) =>
624
+ new SettingsOverlay(tui, theme, cwd, modelRegistry, editScope, done, (msg, level) => ctx.ui.notify(msg, level)),
625
+ OVERLAY_OPTS,
626
+ );
627
+ }
628
+
629
+ // ── Registration ────────────────────────────────────────────
630
+
631
+ export function registerSettingsTui(pi: ExtensionAPI): void {
632
+ registerSettingsTuiCommand(pi, async (args, ctx) => {
633
+ const scope = args?.trim().toLowerCase();
634
+ await openSettingsTui(ctx, scope || undefined);
635
+ });
636
+ }