@pi-agents/orchid 0.1.0-beta.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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,2208 @@
1
+ /**
2
+ * Settings TUI — interactive configuration viewer and editor.
3
+ *
4
+ * Provides a `/orchid-settings` command that renders a two-level navigation:
5
+ * 1. Section selector (14 sections)
6
+ * 2. Per-section SettingsList with field display, source badges,
7
+ * and inline editing for enum/boolean/string/number fields
8
+ *
9
+ * Source detection reads raw project config to determine whether each
10
+ * field is explicitly overridden in project config (`(project)`) or
11
+ * inherited from global baseline (`(global)`).
12
+ *
13
+ * Write-back defaults to global preferences for all editable fields.
14
+ * "Save to project override" and "Remove project override" are
15
+ * explicit actions in the destination picker.
16
+ *
17
+ * @module settings/tui
18
+ */
19
+
20
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { DynamicBorder, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ Container,
24
+ type SelectItem,
25
+ SelectList,
26
+ type SettingItem,
27
+ SettingsList,
28
+ Text,
29
+ } from "@earendil-works/pi-tui";
30
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
31
+ import { join, dirname } from "path";
32
+ import { parse as yamlParse } from "yaml";
33
+
34
+ import {
35
+ CONFIG_VERSION,
36
+ DEFAULT_PROJECT_CONFIG,
37
+ PROJECT_CONFIG_FILENAME,
38
+ type TaskplaneConfig,
39
+ type GlobalPreferences,
40
+ } from "./config-schema.ts";
41
+ import {
42
+ loadGlobalPreferences,
43
+ loadProjectConfig,
44
+ loadProjectOverrides,
45
+ resolveConfigRoot,
46
+ resolveGlobalPreferencesPath,
47
+ } from "./config-loader.ts";
48
+ import { loadPiSettingsPackages } from "./settings-loader.ts";
49
+
50
+ // ── Types ────────────────────────────────────────────────────────────
51
+
52
+ /** Source of a field's current value */
53
+ export type FieldSource = "project" | "global";
54
+
55
+ /** Layer assignment for a field */
56
+ export type FieldLayer = "L1" | "L2" | "L1+L2";
57
+
58
+ /** UI control type for a field */
59
+ export type FieldControl = "toggle" | "input" | "picker";
60
+
61
+ /** Field definition for the settings TUI */
62
+ export interface FieldDef {
63
+ /** Dot-separated config path (e.g., "orchestrator.orchestrator.maxLanes") */
64
+ configPath: string;
65
+ /** Human-readable label */
66
+ label: string;
67
+ /** UI control type */
68
+ control: FieldControl;
69
+ /** Layer assignment */
70
+ layer: FieldLayer;
71
+ /** For toggle fields: list of allowed values */
72
+ values?: string[];
73
+ /** Field type for validation */
74
+ fieldType: "string" | "number" | "boolean" | "enum";
75
+ /** Whether the field is optional (can be unset) */
76
+ optional?: boolean;
77
+ /** For L1+L2 fields: the global preferences key */
78
+ prefsKey?: keyof GlobalPreferences;
79
+ /** Description shown when selected */
80
+ description?: string;
81
+ }
82
+
83
+ /** Section definition */
84
+ export interface SectionDef {
85
+ /** Section display name */
86
+ name: string;
87
+ /** Fields in this section */
88
+ fields: FieldDef[];
89
+ /** Whether this section is read-only (Advanced) */
90
+ readOnly?: boolean;
91
+ }
92
+
93
+ // ── Section & Field Definitions ──────────────────────────────────────
94
+
95
+ /**
96
+ * Canonical navigation map — 14 sections.
97
+ * Order matches the Step 1 design in STATUS.md.
98
+ */
99
+ export const SECTIONS: SectionDef[] = [
100
+ {
101
+ name: "Orchestrator",
102
+ fields: [
103
+ {
104
+ configPath: "orchestrator.orchestrator.maxLanes",
105
+ label: "Max Lanes",
106
+ control: "input",
107
+ layer: "L1",
108
+ fieldType: "number",
109
+ description: "Maximum parallel execution lanes",
110
+ },
111
+ {
112
+ configPath: "orchestrator.orchestrator.worktreeLocation",
113
+ label: "Worktree Location",
114
+ control: "toggle",
115
+ layer: "L1",
116
+ fieldType: "enum",
117
+ values: ["sibling", "subdirectory"],
118
+ description: "Where lane worktree directories are created",
119
+ },
120
+ {
121
+ configPath: "orchestrator.orchestrator.worktreePrefix",
122
+ label: "Worktree Prefix",
123
+ control: "input",
124
+ layer: "L1",
125
+ fieldType: "string",
126
+ description: "Prefix for worktree directory names",
127
+ },
128
+ {
129
+ configPath: "orchestrator.orchestrator.batchIdFormat",
130
+ label: "Batch ID Format",
131
+ control: "toggle",
132
+ layer: "L1",
133
+ fieldType: "enum",
134
+ values: ["timestamp", "sequential"],
135
+ description: "Batch ID format for logs/branch naming",
136
+ },
137
+ {
138
+ configPath: "orchestrator.orchestrator.sessionPrefix",
139
+ label: "Session Prefix",
140
+ control: "input",
141
+ layer: "L1+L2",
142
+ fieldType: "string",
143
+ prefsKey: "sessionPrefix",
144
+ description: "Prefix for orchestrator session names",
145
+ },
146
+ {
147
+ configPath: "orchestrator.orchestrator.operatorId",
148
+ label: "Operator ID",
149
+ control: "input",
150
+ layer: "L1+L2",
151
+ fieldType: "string",
152
+ prefsKey: "operatorId",
153
+ description: "Operator identifier (empty = auto-detect)",
154
+ },
155
+ {
156
+ configPath: "orchestrator.orchestrator.integration",
157
+ label: "Integration",
158
+ control: "picker",
159
+ layer: "L1",
160
+ fieldType: "enum",
161
+ values: ["manual", "supervised", "auto"],
162
+ description:
163
+ "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking.",
164
+ },
165
+ ],
166
+ },
167
+ {
168
+ name: "Agent: Supervisor",
169
+ fields: [
170
+ {
171
+ configPath: "orchestrator.supervisor.model",
172
+ label: "Supervisor Model",
173
+ control: "input",
174
+ layer: "L1+L2",
175
+ fieldType: "string",
176
+ prefsKey: "supervisorModel",
177
+ description: "Supervisor model (inherit = use session model)",
178
+ },
179
+ {
180
+ configPath: "orchestrator.supervisor.autonomy",
181
+ label: "Autonomy Level",
182
+ control: "picker",
183
+ layer: "L1",
184
+ fieldType: "enum",
185
+ values: ["interactive", "supervised", "autonomous"],
186
+ description: "Recovery action confirmation behavior",
187
+ },
188
+ ],
189
+ },
190
+ {
191
+ name: "Agent: Worker",
192
+ fields: [
193
+ {
194
+ configPath: "taskRunner.worker.model",
195
+ label: "Worker Model",
196
+ control: "input",
197
+ layer: "L1+L2",
198
+ fieldType: "string",
199
+ prefsKey: "workerModel",
200
+ description: "Worker model (inherit = use session model)",
201
+ },
202
+ {
203
+ configPath: "taskRunner.worker.tools",
204
+ label: "Worker Tools",
205
+ control: "input",
206
+ layer: "L1",
207
+ fieldType: "string",
208
+ description: "Worker tool allowlist",
209
+ },
210
+ {
211
+ configPath: "taskRunner.worker.thinking",
212
+ label: "Worker Thinking",
213
+ control: "picker",
214
+ layer: "L1",
215
+ fieldType: "string",
216
+ description: "Worker thinking mode",
217
+ },
218
+ ],
219
+ },
220
+ {
221
+ name: "Agent: Reviewer",
222
+ fields: [
223
+ {
224
+ configPath: "taskRunner.reviewer.model",
225
+ label: "Reviewer Model",
226
+ control: "input",
227
+ layer: "L1+L2",
228
+ fieldType: "string",
229
+ prefsKey: "reviewerModel",
230
+ description: "Reviewer model (inherit = use session model)",
231
+ },
232
+ {
233
+ configPath: "taskRunner.reviewer.tools",
234
+ label: "Reviewer Tools",
235
+ control: "input",
236
+ layer: "L1",
237
+ fieldType: "string",
238
+ description: "Reviewer tool allowlist",
239
+ },
240
+ {
241
+ configPath: "taskRunner.reviewer.thinking",
242
+ label: "Reviewer Thinking",
243
+ control: "picker",
244
+ layer: "L1",
245
+ fieldType: "string",
246
+ description: "Reviewer thinking mode",
247
+ },
248
+ ],
249
+ },
250
+ {
251
+ name: "Agent: Merge",
252
+ fields: [
253
+ {
254
+ configPath: "orchestrator.merge.model",
255
+ label: "Merge Model",
256
+ control: "input",
257
+ layer: "L1+L2",
258
+ fieldType: "string",
259
+ prefsKey: "mergeModel",
260
+ description: "Merge-agent model (inherit = use session model)",
261
+ },
262
+ {
263
+ configPath: "orchestrator.merge.tools",
264
+ label: "Merge Tools",
265
+ control: "input",
266
+ layer: "L1",
267
+ fieldType: "string",
268
+ description: "Merge-agent tool allowlist",
269
+ },
270
+ {
271
+ configPath: "orchestrator.merge.thinking",
272
+ label: "Merge Thinking",
273
+ control: "picker",
274
+ layer: "L1+L2",
275
+ fieldType: "string",
276
+ prefsKey: "mergeThinking",
277
+ description: "Merge-agent thinking mode",
278
+ },
279
+ {
280
+ configPath: "orchestrator.merge.order",
281
+ label: "Merge Order",
282
+ control: "toggle",
283
+ layer: "L1",
284
+ fieldType: "enum",
285
+ values: ["fewest-files-first", "sequential"],
286
+ description: "Lane merge ordering policy",
287
+ },
288
+ {
289
+ configPath: "orchestrator.merge.timeoutMinutes",
290
+ label: "Merge Timeout (minutes)",
291
+ control: "input",
292
+ layer: "L1",
293
+ fieldType: "number",
294
+ description: "Max time for merge agent to complete. Increase for large batches (default: 10)",
295
+ },
296
+ ],
297
+ },
298
+ {
299
+ name: "Agent Extensions",
300
+ readOnly: true, // Dynamically handled — no fixed fields
301
+ fields: [],
302
+ },
303
+ {
304
+ name: "Context Limits",
305
+ fields: [
306
+ {
307
+ configPath: "taskRunner.context.workerContextWindow",
308
+ label: "Context Window",
309
+ control: "input",
310
+ layer: "L1",
311
+ fieldType: "number",
312
+ description: "Worker context window size",
313
+ },
314
+ {
315
+ configPath: "taskRunner.context.warnPercent",
316
+ label: "Warn %",
317
+ control: "input",
318
+ layer: "L1",
319
+ fieldType: "number",
320
+ description: "Context utilization warn threshold (%)",
321
+ },
322
+ {
323
+ configPath: "taskRunner.context.killPercent",
324
+ label: "Kill %",
325
+ control: "input",
326
+ layer: "L1",
327
+ fieldType: "number",
328
+ description: "Context utilization hard-stop threshold (%)",
329
+ },
330
+ {
331
+ configPath: "taskRunner.context.maxWorkerIterations",
332
+ label: "Max Iterations",
333
+ control: "input",
334
+ layer: "L1",
335
+ fieldType: "number",
336
+ description: "Max worker iterations per step",
337
+ },
338
+ {
339
+ configPath: "taskRunner.context.maxReviewCycles",
340
+ label: "Max Review Cycles",
341
+ control: "input",
342
+ layer: "L1",
343
+ fieldType: "number",
344
+ description: "Max revise loops per review stage",
345
+ },
346
+ {
347
+ configPath: "taskRunner.context.noProgressLimit",
348
+ label: "No Progress Limit",
349
+ control: "input",
350
+ layer: "L1",
351
+ fieldType: "number",
352
+ description: "Max no-progress iterations before failure",
353
+ },
354
+ {
355
+ configPath: "taskRunner.context.maxWorkerMinutes",
356
+ label: "Max Worker Min (ctx)",
357
+ control: "input",
358
+ layer: "L1",
359
+ fieldType: "number",
360
+ optional: true,
361
+ description: "Per-worker wall-clock cap (minutes, empty = no cap)",
362
+ },
363
+ ],
364
+ },
365
+ {
366
+ name: "Failure Policy",
367
+ fields: [
368
+ {
369
+ configPath: "orchestrator.failure.onTaskFailure",
370
+ label: "On Task Failure",
371
+ control: "toggle",
372
+ layer: "L1",
373
+ fieldType: "enum",
374
+ values: ["skip-dependents", "stop-wave", "stop-all"],
375
+ description: "Batch behavior when a task fails",
376
+ },
377
+ {
378
+ configPath: "orchestrator.failure.onMergeFailure",
379
+ label: "On Merge Failure",
380
+ control: "toggle",
381
+ layer: "L1",
382
+ fieldType: "enum",
383
+ values: ["pause", "abort"],
384
+ description: "Behavior when a merge step fails",
385
+ },
386
+ {
387
+ configPath: "orchestrator.failure.stallTimeout",
388
+ label: "Stall Timeout (min)",
389
+ control: "input",
390
+ layer: "L1",
391
+ fieldType: "number",
392
+ description: "Stall detection threshold (minutes)",
393
+ },
394
+ {
395
+ configPath: "orchestrator.failure.maxWorkerMinutes",
396
+ label: "Max Worker Min",
397
+ control: "input",
398
+ layer: "L1",
399
+ fieldType: "number",
400
+ description: "Max worker runtime budget per task (minutes)",
401
+ },
402
+ {
403
+ configPath: "orchestrator.failure.abortGracePeriod",
404
+ label: "Abort Grace (sec)",
405
+ control: "input",
406
+ layer: "L1",
407
+ fieldType: "number",
408
+ description: "Graceful abort wait time (seconds)",
409
+ },
410
+ ],
411
+ },
412
+ {
413
+ name: "Dependencies",
414
+ fields: [
415
+ {
416
+ configPath: "orchestrator.dependencies.source",
417
+ label: "Dep Source",
418
+ control: "toggle",
419
+ layer: "L1",
420
+ fieldType: "enum",
421
+ values: ["prompt", "agent"],
422
+ description: "Dependency extraction source",
423
+ },
424
+ {
425
+ configPath: "orchestrator.dependencies.cache",
426
+ label: "Dep Cache",
427
+ control: "toggle",
428
+ layer: "L1",
429
+ fieldType: "boolean",
430
+ values: ["true", "false"],
431
+ description: "Cache dependency analysis results",
432
+ },
433
+ ],
434
+ },
435
+ {
436
+ name: "Assignment",
437
+ fields: [
438
+ {
439
+ configPath: "orchestrator.assignment.strategy",
440
+ label: "Strategy",
441
+ control: "toggle",
442
+ layer: "L1",
443
+ fieldType: "enum",
444
+ values: ["affinity-first", "round-robin", "load-balanced"],
445
+ description: "Lane assignment strategy",
446
+ },
447
+ ],
448
+ },
449
+ {
450
+ name: "Pre-Warm",
451
+ fields: [
452
+ {
453
+ configPath: "orchestrator.preWarm.autoDetect",
454
+ label: "Auto-Detect",
455
+ control: "toggle",
456
+ layer: "L1",
457
+ fieldType: "boolean",
458
+ values: ["true", "false"],
459
+ description: "Enable automatic pre-warm command detection",
460
+ },
461
+ ],
462
+ },
463
+ {
464
+ name: "Monitoring",
465
+ fields: [
466
+ {
467
+ configPath: "orchestrator.monitoring.pollInterval",
468
+ label: "Poll Interval (sec)",
469
+ control: "input",
470
+ layer: "L1",
471
+ fieldType: "number",
472
+ description: "Poll interval for lane/task monitoring (seconds)",
473
+ },
474
+ ],
475
+ },
476
+ {
477
+ name: "Global Preferences",
478
+ fields: [
479
+ {
480
+ configPath: "preferences.dashboardPort",
481
+ label: "Dashboard Port",
482
+ control: "input",
483
+ layer: "L2",
484
+ fieldType: "number",
485
+ prefsKey: "dashboardPort",
486
+ optional: true,
487
+ description: "Dashboard server port",
488
+ },
489
+ ],
490
+ },
491
+ {
492
+ name: "Advanced (JSON Only)",
493
+ readOnly: true,
494
+ fields: [], // Populated dynamically in getAdvancedItems()
495
+ },
496
+ ];
497
+
498
+ // ── Raw Config Readers (Source Detection) ────────────────────────────
499
+
500
+ /**
501
+ * Resolve the path to a config file under the given root.
502
+ *
503
+ * Supports both standard layout (`<root>/.pi/<file>`) and flat layout
504
+ * (`<root>/<file>`) used by pointer-resolved `.orchid/` config roots.
505
+ */
506
+ function resolveConfigFilePath(configRoot: string, filename: string): string {
507
+ const standardPath = join(configRoot, ".pi", filename);
508
+ if (existsSync(standardPath)) return standardPath;
509
+ const flatPath = join(configRoot, filename);
510
+ if (existsSync(flatPath)) return flatPath;
511
+ return standardPath;
512
+ }
513
+
514
+ /**
515
+ * Read the raw project config JSON as a plain object (no defaults merge).
516
+ * Returns null if no JSON config exists. Does not throw on parse errors.
517
+ */
518
+ export function readRawProjectJson(configRoot: string): Record<string, any> | null {
519
+ const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
520
+ if (!existsSync(jsonPath)) return null;
521
+ try {
522
+ const raw = readFileSync(jsonPath, "utf-8");
523
+ const parsed = JSON.parse(raw);
524
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
525
+ } catch {
526
+ return null;
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Read raw YAML config files and merge into a single raw object
532
+ * using the same path structure as the JSON config.
533
+ * Returns null if no YAML files exist.
534
+ */
535
+ export function readRawYamlConfigs(configRoot: string): Record<string, any> | null {
536
+ const trPath = resolveConfigFilePath(configRoot, "task-runner.yaml");
537
+ const orchPath = resolveConfigFilePath(configRoot, "task-orchestrator.yaml");
538
+ const hasTr = existsSync(trPath);
539
+ const hasOrch = existsSync(orchPath);
540
+ if (!hasTr && !hasOrch) return null;
541
+
542
+ const result: Record<string, any> = {};
543
+
544
+ if (hasTr) {
545
+ try {
546
+ const raw = readFileSync(trPath, "utf-8");
547
+ const parsed = yamlParse(raw);
548
+ if (parsed && typeof parsed === "object") {
549
+ result.taskRunner = convertYamlKeys(parsed, "taskRunner");
550
+ }
551
+ } catch {
552
+ /* ignore */
553
+ }
554
+ }
555
+
556
+ if (hasOrch) {
557
+ try {
558
+ const raw = readFileSync(orchPath, "utf-8");
559
+ const parsed = yamlParse(raw);
560
+ if (parsed && typeof parsed === "object") {
561
+ result.orchestrator = convertYamlKeys(parsed, "orchestrator");
562
+ }
563
+ } catch {
564
+ /* ignore */
565
+ }
566
+ }
567
+
568
+ return Object.keys(result).length > 0 ? result : null;
569
+ }
570
+
571
+ /**
572
+ * Simple snake_case to camelCase conversion for YAML key lookup.
573
+ * Only converts top-level section keys we need for source detection.
574
+ */
575
+ function convertYamlKeys(raw: any, section: "taskRunner" | "orchestrator"): Record<string, any> {
576
+ const result: Record<string, any> = {};
577
+ if (section === "taskRunner") {
578
+ if (raw.worker) result.worker = snakeKeysToCamel(raw.worker);
579
+ if (raw.reviewer) result.reviewer = snakeKeysToCamel(raw.reviewer);
580
+ if (raw.context) result.context = snakeKeysToCamel(raw.context);
581
+ if (raw.project) result.project = snakeKeysToCamel(raw.project);
582
+ if (raw.paths) result.paths = snakeKeysToCamel(raw.paths);
583
+ if (raw.testing) result.testing = raw.testing;
584
+ if (raw.standards) result.standards = raw.standards;
585
+ if (raw.standards_overrides) result.standardsOverrides = raw.standards_overrides;
586
+ if (raw.task_areas) result.taskAreas = raw.task_areas;
587
+ if (raw.reference_docs) result.referenceDocs = raw.reference_docs;
588
+ if (raw.never_load) result.neverLoad = raw.never_load;
589
+ if (raw.self_doc_targets) result.selfDocTargets = raw.self_doc_targets;
590
+ if (raw.protected_docs) result.protectedDocs = raw.protected_docs;
591
+ } else {
592
+ if (raw.orchestrator) result.orchestrator = snakeKeysToCamel(raw.orchestrator);
593
+ if (raw.dependencies) result.dependencies = snakeKeysToCamel(raw.dependencies);
594
+ if (raw.assignment) {
595
+ result.assignment = {};
596
+ if (raw.assignment.strategy !== undefined) result.assignment.strategy = raw.assignment.strategy;
597
+ if (raw.assignment.size_weights) result.assignment.sizeWeights = raw.assignment.size_weights;
598
+ }
599
+ if (raw.pre_warm) {
600
+ result.preWarm = {};
601
+ if (raw.pre_warm.auto_detect !== undefined) result.preWarm.autoDetect = raw.pre_warm.auto_detect;
602
+ if (raw.pre_warm.commands) result.preWarm.commands = raw.pre_warm.commands;
603
+ if (raw.pre_warm.always) result.preWarm.always = raw.pre_warm.always;
604
+ }
605
+ if (raw.merge) result.merge = snakeKeysToCamel(raw.merge);
606
+ if (raw.failure) result.failure = snakeKeysToCamel(raw.failure);
607
+ if (raw.monitoring) result.monitoring = snakeKeysToCamel(raw.monitoring);
608
+ }
609
+ return result;
610
+ }
611
+
612
+ /** Convert snake_case keys in a flat object to camelCase */
613
+ function snakeKeysToCamel(obj: Record<string, any>): Record<string, any> {
614
+ const result: Record<string, any> = {};
615
+ for (const [key, val] of Object.entries(obj)) {
616
+ const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
617
+ result[camelKey] = val;
618
+ }
619
+ return result;
620
+ }
621
+
622
+ /**
623
+ * Read the raw global preferences JSON.
624
+ */
625
+ function readRawPreferences(): Record<string, any> | null {
626
+ const prefsPath = resolveGlobalPreferencesPath();
627
+ if (!existsSync(prefsPath)) return null;
628
+ try {
629
+ const raw = readFileSync(prefsPath, "utf-8");
630
+ const parsed = JSON.parse(raw);
631
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
632
+ } catch {
633
+ return null;
634
+ }
635
+ }
636
+
637
+ // ── Write-Back ───────────────────────────────────────────────────────
638
+
639
+ /**
640
+ * Set a nested value in an object by dot-path, creating intermediate
641
+ * objects as needed. If `value` is undefined, deletes the leaf key
642
+ * (for clearing optional fields).
643
+ */
644
+ function setNestedValue(obj: Record<string, any>, path: string, value: any): void {
645
+ const parts = path.split(".");
646
+ let current = obj;
647
+ for (let i = 0; i < parts.length - 1; i++) {
648
+ const part = parts[i];
649
+ if (current[part] === undefined || current[part] === null || typeof current[part] !== "object") {
650
+ current[part] = {};
651
+ }
652
+ current = current[part];
653
+ }
654
+ const leafKey = parts[parts.length - 1];
655
+ if (value === undefined) {
656
+ delete current[leafKey];
657
+ } else {
658
+ current[leafKey] = value;
659
+ }
660
+ }
661
+
662
+ function pruneEmptyObjects(node: unknown): boolean {
663
+ if (!node || typeof node !== "object" || Array.isArray(node)) return false;
664
+ const obj = node as Record<string, any>;
665
+ for (const key of Object.keys(obj)) {
666
+ const child = obj[key];
667
+ if (child && typeof child === "object" && !Array.isArray(child)) {
668
+ if (pruneEmptyObjects(child)) {
669
+ delete obj[key];
670
+ }
671
+ }
672
+ }
673
+ return Object.keys(obj).length === 0;
674
+ }
675
+
676
+ function toGlobalPreferencePath(field: FieldDef): string {
677
+ if (field.configPath.startsWith("preferences.")) {
678
+ return field.configPath.slice("preferences.".length);
679
+ }
680
+ return field.configPath;
681
+ }
682
+
683
+ /**
684
+ * Write a single project override field to `orchid-config.json`.
685
+ *
686
+ * This performs sparse writes only: when no JSON file exists yet, a new
687
+ * file is created with `{ configVersion }` plus the specific override path.
688
+ * It does NOT bootstrap full config values from YAML/global/default layers.
689
+ */
690
+ export function writeProjectConfigField(
691
+ configRoot: string,
692
+ configPath: string,
693
+ value: any,
694
+ pointerConfigRoot?: string,
695
+ ): void {
696
+ const resolvedRoot = resolveConfigRoot(configRoot, pointerConfigRoot);
697
+
698
+ const hasStandardLayout =
699
+ existsSync(join(resolvedRoot, ".pi", PROJECT_CONFIG_FILENAME)) ||
700
+ existsSync(join(resolvedRoot, ".pi", "task-runner.yaml")) ||
701
+ existsSync(join(resolvedRoot, ".pi", "task-orchestrator.yaml"));
702
+ const hasFlatLayout =
703
+ existsSync(join(resolvedRoot, PROJECT_CONFIG_FILENAME)) ||
704
+ existsSync(join(resolvedRoot, "task-runner.yaml")) ||
705
+ existsSync(join(resolvedRoot, "task-orchestrator.yaml"));
706
+ const useFlatLayout = !hasStandardLayout && hasFlatLayout;
707
+
708
+ const jsonPath = useFlatLayout
709
+ ? join(resolvedRoot, PROJECT_CONFIG_FILENAME)
710
+ : join(resolvedRoot, ".pi", PROJECT_CONFIG_FILENAME);
711
+ const tmpPath = jsonPath + ".tmp";
712
+
713
+ mkdirSync(dirname(jsonPath), { recursive: true });
714
+
715
+ let configObj: Record<string, any>;
716
+ if (existsSync(jsonPath)) {
717
+ try {
718
+ const raw = readFileSync(jsonPath, "utf-8");
719
+ configObj = JSON.parse(raw);
720
+ } catch (e: any) {
721
+ throw new Error(
722
+ `Cannot write settings: ${jsonPath} contains malformed JSON. ` +
723
+ `Please fix or delete the file and try again. ` +
724
+ `(Parse error: ${e.message ?? "unknown"})`,
725
+ );
726
+ }
727
+ } else {
728
+ const yamlSeed = loadProjectOverrides(resolvedRoot);
729
+ configObj = {
730
+ configVersion: CONFIG_VERSION,
731
+ ...JSON.parse(JSON.stringify(yamlSeed)),
732
+ };
733
+ }
734
+
735
+ setNestedValue(configObj, configPath, value);
736
+ pruneEmptyObjects(configObj);
737
+ if (configObj.configVersion === undefined) {
738
+ configObj.configVersion = CONFIG_VERSION;
739
+ }
740
+
741
+ const json = JSON.stringify(configObj, null, 2) + "\n";
742
+ writeFileSync(tmpPath, json, "utf-8");
743
+ try {
744
+ renameSync(tmpPath, jsonPath);
745
+ } catch {
746
+ writeFileSync(jsonPath, json, "utf-8");
747
+ try {
748
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
749
+ } catch {
750
+ /* cleanup best-effort */
751
+ }
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Write a global preference at a dot-path (e.g. `taskRunner.worker.model`).
757
+ * Uses sparse JSON updates and prunes empty objects on delete.
758
+ */
759
+ export function writeGlobalPreference(path: string, value: any): void {
760
+ const prefsPath = resolveGlobalPreferencesPath();
761
+ const tmpPath = prefsPath + ".tmp";
762
+
763
+ const prefsDir = dirname(prefsPath);
764
+ if (!existsSync(prefsDir)) {
765
+ mkdirSync(prefsDir, { recursive: true });
766
+ }
767
+
768
+ let prefsObj: Record<string, any> = {};
769
+ if (existsSync(prefsPath)) {
770
+ try {
771
+ const raw = readFileSync(prefsPath, "utf-8");
772
+ const parsed = JSON.parse(raw);
773
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
774
+ prefsObj = parsed;
775
+ }
776
+ } catch {
777
+ prefsObj = {};
778
+ }
779
+ }
780
+
781
+ setNestedValue(prefsObj, path, value);
782
+ pruneEmptyObjects(prefsObj);
783
+
784
+ const json = JSON.stringify(prefsObj, null, 2) + "\n";
785
+ writeFileSync(tmpPath, json, "utf-8");
786
+ try {
787
+ renameSync(tmpPath, prefsPath);
788
+ } catch {
789
+ writeFileSync(prefsPath, json, "utf-8");
790
+ try {
791
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
792
+ } catch {
793
+ /* cleanup best-effort */
794
+ }
795
+ }
796
+ }
797
+
798
+ /**
799
+ * Convert a raw string value from the TUI into the appropriate typed
800
+ * value for writing to config JSON.
801
+ *
802
+ * - Numbers: parse to number
803
+ * - Booleans: parse "true"/"false" to boolean
804
+ * - "(not set)" / "(inherit)": returns undefined (delete key)
805
+ * - Strings: return as-is
806
+ */
807
+ export function coerceValueForWrite(field: FieldDef, rawValue: string): any {
808
+ // Strip source badge if present
809
+ const cleaned = rawValue.replace(/\s+\((?:default|project|global)\)$/, "").trim();
810
+
811
+ // Unset / inherit → undefined (delete key)
812
+ if (cleaned === "(not set)" || cleaned === "(inherit)") {
813
+ return undefined;
814
+ }
815
+
816
+ switch (field.fieldType) {
817
+ case "number": {
818
+ const num = Number(cleaned);
819
+ return Number.isFinite(num) ? num : undefined;
820
+ }
821
+ case "boolean":
822
+ return cleaned === "true";
823
+ case "enum":
824
+ case "string":
825
+ default:
826
+ return cleaned;
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Write destinations for settings edits.
832
+ *
833
+ * - `prefs`: write to global preferences (default)
834
+ * - `project`: write a project-specific override
835
+ * - `remove-project`: delete an existing project override (revert to global)
836
+ */
837
+ export type WriteDestination = "project" | "prefs" | "remove-project";
838
+
839
+ export function getDefaultWriteDestination(_field: FieldDef): WriteDestination {
840
+ return "prefs";
841
+ }
842
+
843
+ /**
844
+ * Resolve the write action for a field change.
845
+ */
846
+ export function resolveWriteAction(
847
+ field: FieldDef,
848
+ destinationChoice: string | null,
849
+ projectConfirmed: boolean,
850
+ ): WriteDestination | "skip" {
851
+ const defaultDest = getDefaultWriteDestination(field);
852
+
853
+ // L2-only fields have no project layer
854
+ if (field.layer === "L2") return "prefs";
855
+
856
+ if (!destinationChoice || destinationChoice === "Cancel") return "skip";
857
+ if (destinationChoice.startsWith("Global") || destinationChoice.startsWith("User")) return "prefs";
858
+ if (destinationChoice.startsWith("Remove project override")) return "remove-project";
859
+ if (destinationChoice.startsWith("Project") && !projectConfirmed) return "skip";
860
+ if (destinationChoice.startsWith("Project")) return "project";
861
+
862
+ return defaultDest;
863
+ }
864
+
865
+ // ── Source Detection ─────────────────────────────────────────────────
866
+
867
+ /**
868
+ * Get a nested value from an object by dot-path.
869
+ * e.g., getNestedValue(obj, "orchestrator.orchestrator.maxLanes")
870
+ */
871
+ function getNestedValue(obj: any, path: string): any {
872
+ const parts = path.split(".");
873
+ let current = obj;
874
+ for (const part of parts) {
875
+ if (current === null || current === undefined || typeof current !== "object") return undefined;
876
+ current = current[part];
877
+ }
878
+ return current;
879
+ }
880
+
881
+ /**
882
+ * Determine the source of a field's current value.
883
+ *
884
+ * Source badge policy:
885
+ * - `(project)` when the field is explicitly present in project config JSON/YAML
886
+ * - `(global)` otherwise (global preferences baseline + schema defaults)
887
+ */
888
+ export function detectFieldSource(
889
+ field: FieldDef,
890
+ rawProjectConfig: Record<string, any> | null,
891
+ _rawPrefs: Record<string, any> | null,
892
+ ): FieldSource {
893
+ if (field.layer !== "L2" && rawProjectConfig) {
894
+ const val = getNestedValue(rawProjectConfig, field.configPath);
895
+ if (val !== undefined) return "project";
896
+ }
897
+
898
+ return "global";
899
+ }
900
+
901
+ // ── Value Formatting ─────────────────────────────────────────────────
902
+
903
+ /**
904
+ * Get the display value for a field from the merged config.
905
+ */
906
+ export function getFieldDisplayValue(
907
+ field: FieldDef,
908
+ mergedConfig: TaskplaneConfig,
909
+ prefs: GlobalPreferences,
910
+ ): string {
911
+ // Special case: dashboardPort (L2-only, not in merged config)
912
+ if (field.configPath === "preferences.dashboardPort") {
913
+ const val = prefs.dashboardPort;
914
+ return val !== undefined ? String(val) : "(not set)";
915
+ }
916
+
917
+ const val = getNestedValue(mergedConfig, field.configPath);
918
+
919
+ // Optional fields may be undefined
920
+ if (val === undefined) {
921
+ return "(not set)";
922
+ }
923
+
924
+ // Boolean fields: show "true"/"false"
925
+ if (field.fieldType === "boolean") {
926
+ return String(val);
927
+ }
928
+
929
+ return String(val);
930
+ }
931
+
932
+ // ── Validation ───────────────────────────────────────────────────────
933
+
934
+ export interface ValidationResult {
935
+ valid: boolean;
936
+ error?: string;
937
+ }
938
+
939
+ /**
940
+ * Validate a user-entered value for a field.
941
+ */
942
+ export function validateFieldInput(field: FieldDef, input: string): ValidationResult {
943
+ // Empty input for optional fields = unset
944
+ if (input.trim() === "" && field.optional) {
945
+ return { valid: true };
946
+ }
947
+
948
+ // Empty input for required fields
949
+ if (input.trim() === "" && !field.optional) {
950
+ // String fields allow empty (e.g., model = "" means inherit)
951
+ if (field.fieldType === "string") return { valid: true };
952
+ return { valid: false, error: "Value required" };
953
+ }
954
+
955
+ switch (field.fieldType) {
956
+ case "number": {
957
+ const num = Number(input.trim());
958
+ if (!Number.isFinite(num) || num <= 0) {
959
+ return { valid: false, error: "Must be a positive integer" };
960
+ }
961
+ // Integer check for most number fields
962
+ if (!Number.isInteger(num)) {
963
+ return { valid: false, error: "Must be a whole number" };
964
+ }
965
+ return { valid: true };
966
+ }
967
+ case "enum": {
968
+ if (field.values && !field.values.includes(input.trim())) {
969
+ return { valid: false, error: `Must be one of: ${field.values.join(", ")}` };
970
+ }
971
+ return { valid: true };
972
+ }
973
+ case "string":
974
+ return { valid: true };
975
+ case "boolean": {
976
+ if (input.trim() !== "true" && input.trim() !== "false") {
977
+ return { valid: false, error: "Must be true or false" };
978
+ }
979
+ return { valid: true };
980
+ }
981
+ default:
982
+ return { valid: true };
983
+ }
984
+ }
985
+
986
+ // ── Advanced Section Items ───────────────────────────────────────────
987
+
988
+ export interface AdvancedItem {
989
+ label: string;
990
+ value: string;
991
+ configPath: string;
992
+ }
993
+
994
+ /**
995
+ * Build a Set of all config paths that are covered by editable sections.
996
+ * Used to detect "uncovered" paths for the Advanced section.
997
+ */
998
+ function buildCoveredPaths(): Set<string> {
999
+ const covered = new Set<string>();
1000
+ for (const section of SECTIONS) {
1001
+ for (const field of section.fields) {
1002
+ covered.add(field.configPath);
1003
+ }
1004
+ }
1005
+ // Also mark the preferences-only path
1006
+ covered.add("preferences.dashboardPort");
1007
+ return covered;
1008
+ }
1009
+
1010
+ /** Cached set of editable config paths */
1011
+ const COVERED_PATHS = buildCoveredPaths();
1012
+
1013
+ /**
1014
+ * Convert a dot-path to a human-readable label.
1015
+ * e.g., "taskRunner.project.name" → "Project Name"
1016
+ * "orchestrator.preWarm.commands" → "Pre-Warm Commands"
1017
+ */
1018
+ function pathToLabel(path: string): string {
1019
+ const parts = path.split(".");
1020
+ // Take the last 1-2 meaningful segments (skip top-level "taskRunner"/"orchestrator")
1021
+ const meaningful = parts.slice(1); // Drop "taskRunner"/"orchestrator"/"configVersion"
1022
+ if (meaningful.length === 0) {
1023
+ // Top-level like "configVersion"
1024
+ return camelToTitle(parts[parts.length - 1]);
1025
+ }
1026
+ // For nested paths like "project.name", use last 2 segments if parent is a grouping
1027
+ if (meaningful.length >= 2) {
1028
+ return `${camelToTitle(meaningful[meaningful.length - 2])} ${camelToTitle(meaningful[meaningful.length - 1])}`;
1029
+ }
1030
+ return camelToTitle(meaningful[0]);
1031
+ }
1032
+
1033
+ /** Convert camelCase to Title Case (e.g., "maxLanes" → "Max Lanes") */
1034
+ function camelToTitle(str: string): string {
1035
+ return str
1036
+ .replace(/([A-Z])/g, " $1")
1037
+ .replace(/^./, (s) => s.toUpperCase())
1038
+ .trim();
1039
+ }
1040
+
1041
+ /**
1042
+ * Get display items for the Advanced (JSON Only) section.
1043
+ *
1044
+ * Dynamically discovers all config paths NOT covered by editable sections
1045
+ * by recursively walking the merged config object. This ensures new fields
1046
+ * added to the schema are automatically surfaced for discoverability.
1047
+ */
1048
+ export function getAdvancedItems(config: TaskplaneConfig): AdvancedItem[] {
1049
+ const items: AdvancedItem[] = [];
1050
+
1051
+ // Walk the config object and collect uncovered leaf paths
1052
+ walkConfig(config, "", (path, value) => {
1053
+ if (COVERED_PATHS.has(path)) return; // Skip editable fields
1054
+
1055
+ const label = pathToLabel(path);
1056
+ const display = summarizeValue(value);
1057
+ items.push({ label, value: display, configPath: path });
1058
+ });
1059
+
1060
+ return items;
1061
+ }
1062
+
1063
+ /**
1064
+ * Recursively walk a config object, calling the visitor for each "leaf" field.
1065
+ *
1066
+ * A "leaf" is either:
1067
+ * - A primitive (string, number, boolean)
1068
+ * - An array
1069
+ * - A Record/object that is a "data container" (not a known config subsection)
1070
+ *
1071
+ * Known subsection objects (like `taskRunner.worker`, `orchestrator.merge`)
1072
+ * are recursed into, not reported as leaves themselves.
1073
+ */
1074
+ function walkConfig(obj: any, prefix: string, visitor: (path: string, value: any) => void): void {
1075
+ if (obj === null || obj === undefined) return;
1076
+
1077
+ for (const [key, value] of Object.entries(obj)) {
1078
+ const path = prefix ? `${prefix}.${key}` : key;
1079
+
1080
+ if (Array.isArray(value)) {
1081
+ // Arrays are leaf items (e.g., verify, docs, rules, neverLoad)
1082
+ visitor(path, value);
1083
+ } else if (typeof value === "object" && value !== null) {
1084
+ // Determine if this is a "config subsection" to recurse into,
1085
+ // or a "data Record" to report as a leaf.
1086
+ // Config subsections have known typed structure; data Records
1087
+ // are user-defined key-value maps.
1088
+ if (isConfigSubsection(path)) {
1089
+ walkConfig(value, path, visitor);
1090
+ } else {
1091
+ // Data Record — report as leaf
1092
+ visitor(path, value);
1093
+ }
1094
+ } else {
1095
+ // Primitive — report as leaf
1096
+ visitor(path, value);
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ /**
1102
+ * Known config subsection paths that should be recursed into
1103
+ * (not reported as Advanced items themselves).
1104
+ *
1105
+ * This list is derived from the TaskplaneConfig interface structure.
1106
+ * When the schema adds a new top-level subsection, add it here to
1107
+ * recurse properly. Unknown subsections default to being treated as
1108
+ * data Records (shown in Advanced), which is the safe default for
1109
+ * discoverability.
1110
+ */
1111
+ const CONFIG_SUBSECTIONS = new Set([
1112
+ "taskRunner",
1113
+ "orchestrator",
1114
+ "taskRunner.project",
1115
+ "taskRunner.paths",
1116
+ "taskRunner.testing",
1117
+ "taskRunner.standards",
1118
+ "taskRunner.worker",
1119
+ "taskRunner.reviewer",
1120
+ "taskRunner.context",
1121
+ "orchestrator.orchestrator",
1122
+ "orchestrator.dependencies",
1123
+ "orchestrator.assignment",
1124
+ "orchestrator.preWarm",
1125
+ "orchestrator.merge",
1126
+ "orchestrator.failure",
1127
+ "orchestrator.monitoring",
1128
+ ]);
1129
+
1130
+ function isConfigSubsection(path: string): boolean {
1131
+ return CONFIG_SUBSECTIONS.has(path);
1132
+ }
1133
+
1134
+ /**
1135
+ * Summarize a value for display in the Advanced section.
1136
+ */
1137
+ function summarizeValue(value: any): string {
1138
+ if (value === undefined || value === null) return "(not set)";
1139
+ if (typeof value === "string") return value || "(empty)";
1140
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1141
+ if (Array.isArray(value)) return summarizeArray(value);
1142
+ if (typeof value === "object") return summarizeRecord(value);
1143
+ return String(value);
1144
+ }
1145
+
1146
+ function summarizeRecord(obj: Record<string, any>): string {
1147
+ const keys = Object.keys(obj);
1148
+ if (keys.length === 0) return "(empty)";
1149
+ if (keys.length <= 3) return keys.join(", ");
1150
+ return `${keys.length} entries`;
1151
+ }
1152
+
1153
+ function summarizeArray(arr: any[]): string {
1154
+ if (arr.length === 0) return "(empty)";
1155
+ if (arr.length <= 3) return arr.map(String).join(", ");
1156
+ return `${arr.length} items`;
1157
+ }
1158
+
1159
+ // ── TUI Rendering ────────────────────────────────────────────────────
1160
+
1161
+ /**
1162
+ * Open the settings TUI.
1163
+ *
1164
+ * This is the main entry point called from the /orchid-settings command handler.
1165
+ * Uses a two-level navigation:
1166
+ * 1. SelectList for section navigation
1167
+ * 2. SettingsList for per-section field display and editing
1168
+ *
1169
+ * @param ctx - Extension context for UI access
1170
+ * @param configRoot - Workspace/repo root (from execCtx.workspaceRoot)
1171
+ * @param pointerConfigRoot - Optional pointer-resolved config root (workspace mode)
1172
+ */
1173
+ // ── Model Picker (Sage-style provider → model selection) ────────────
1174
+
1175
+ /**
1176
+ * Interactive two-level model picker: provider first, then model within provider.
1177
+ * Returns the selected model string (e.g., "anthropic/claude-sonnet-4-20250514")
1178
+ * or "" for inherit, or undefined if cancelled.
1179
+ *
1180
+ * Adapted from Sage's pickModel implementation.
1181
+ */
1182
+ async function pickModel(ctx: ExtensionContext, currentModel: string): Promise<string | undefined> {
1183
+ const available = ctx.modelRegistry.getAvailable();
1184
+ if (available.length === 0) {
1185
+ ctx.ui.notify("No available models found in pi model registry", "warning");
1186
+ // Fall back to manual input
1187
+ const manual = await ctx.ui.input(
1188
+ "Model (provider/model-id, or empty for inherit)",
1189
+ currentModel || "",
1190
+ );
1191
+ if (manual === null || manual === undefined) return undefined;
1192
+ return manual;
1193
+ }
1194
+
1195
+ const currentLower = (currentModel || "").trim().toLowerCase();
1196
+ const providers = [...new Set(available.map((m: any) => m.provider))].sort();
1197
+
1198
+ while (true) {
1199
+ // Level 1: Provider selection (with "inherit" as first option)
1200
+ const providerOptions: string[] = [
1201
+ "inherit (use current session model)",
1202
+ ...providers.map((p: string) => {
1203
+ const count = available.filter((m: any) => m.provider === p).length;
1204
+ return `${p} (${count} models)`;
1205
+ }),
1206
+ ];
1207
+
1208
+ const providerChoice = await selectScrollable(ctx, "Choose model provider", providerOptions);
1209
+ if (!providerChoice) return undefined; // Cancelled
1210
+
1211
+ if (providerChoice.startsWith("inherit")) {
1212
+ return ""; // Empty string = inherit
1213
+ }
1214
+
1215
+ // Extract provider name (strip " (N models)" suffix)
1216
+ const provider = providerChoice.replace(/\s*\(\d+ models?\)$/, "");
1217
+ const providerModels = available
1218
+ .filter((m: any) => m.provider === provider)
1219
+ .sort((a: any, b: any) => {
1220
+ // Current model first, then alphabetical
1221
+ const aComposite = `${a.provider}/${a.id}`.toLowerCase();
1222
+ const bComposite = `${b.provider}/${b.id}`.toLowerCase();
1223
+ if (aComposite === currentLower) return -1;
1224
+ if (bComposite === currentLower) return 1;
1225
+ return a.id.localeCompare(b.id);
1226
+ });
1227
+
1228
+ // Level 2: Model selection within provider
1229
+ const modelOptionMap = new Map<string, string>();
1230
+ const modelOptions = ["← Back to providers"];
1231
+
1232
+ for (const model of providerModels) {
1233
+ const composite = `${model.provider}/${model.id}`;
1234
+ const isCurrent = composite.toLowerCase() === currentLower;
1235
+ const label = `${model.id}${isCurrent ? " ✓ current" : ""}`;
1236
+ modelOptions.push(label);
1237
+ modelOptionMap.set(label, composite);
1238
+ }
1239
+
1240
+ const modelChoice = await selectScrollable(ctx, `Choose model (${provider})`, modelOptions);
1241
+ if (!modelChoice) continue; // Cancelled → back to providers
1242
+ if (modelChoice === "← Back to providers") continue;
1243
+
1244
+ const resolved = modelOptionMap.get(modelChoice);
1245
+ if (resolved) return resolved;
1246
+ }
1247
+ }
1248
+
1249
+ type ThinkingModeValue = "" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
1250
+
1251
+ const THINKING_MODE_OPTIONS: Array<{ value: ThinkingModeValue; label: string }> = [
1252
+ { value: "", label: "inherit (use session thinking)" },
1253
+ { value: "off", label: "off" },
1254
+ { value: "minimal", label: "minimal" },
1255
+ { value: "low", label: "low" },
1256
+ { value: "medium", label: "medium" },
1257
+ { value: "high", label: "high" },
1258
+ { value: "xhigh", label: "xhigh" },
1259
+ ];
1260
+
1261
+ function normalizeThinkingMode(value: unknown): ThinkingModeValue {
1262
+ const cleaned = String(value ?? "")
1263
+ .trim()
1264
+ .toLowerCase();
1265
+ if (!cleaned || cleaned === "inherit") return "";
1266
+ if (cleaned === "on") return "high";
1267
+ if (["off", "minimal", "low", "medium", "high", "xhigh"].includes(cleaned)) {
1268
+ return cleaned as ThinkingModeValue;
1269
+ }
1270
+ return "";
1271
+ }
1272
+
1273
+ async function pickThinkingMode(
1274
+ ctx: ExtensionContext,
1275
+ currentThinking: string,
1276
+ ): Promise<ThinkingModeValue | undefined> {
1277
+ const current = normalizeThinkingMode(currentThinking);
1278
+ const resolvedCurrent: ThinkingModeValue = current || "high";
1279
+ const optionToValue = new Map<string, ThinkingModeValue>();
1280
+ const optionLabels: string[] = [];
1281
+
1282
+ for (const option of THINKING_MODE_OPTIONS) {
1283
+ const label = `${option.label}${option.value === resolvedCurrent ? " ✓ current" : ""}`;
1284
+ optionLabels.push(label);
1285
+ optionToValue.set(label, option.value);
1286
+ }
1287
+
1288
+ const selected = await selectScrollable(ctx, "Choose thinking mode", optionLabels, 8);
1289
+ if (!selected) return undefined;
1290
+ return optionToValue.get(selected);
1291
+ }
1292
+
1293
+ const MODEL_THINKING_PATH_MAP: Record<string, { thinkingPath: string; label: string }> = {
1294
+ "taskRunner.worker.model": { thinkingPath: "taskRunner.worker.thinking", label: "Worker" },
1295
+ "taskRunner.reviewer.model": { thinkingPath: "taskRunner.reviewer.thinking", label: "Reviewer" },
1296
+ "orchestrator.merge.model": { thinkingPath: "orchestrator.merge.thinking", label: "Merge" },
1297
+ };
1298
+
1299
+ const THINKING_MODEL_PATH_MAP: Record<string, { modelPath: string; label: string }> = {
1300
+ "taskRunner.worker.thinking": { modelPath: "taskRunner.worker.model", label: "Worker" },
1301
+ "taskRunner.reviewer.thinking": { modelPath: "taskRunner.reviewer.model", label: "Reviewer" },
1302
+ "orchestrator.merge.thinking": { modelPath: "orchestrator.merge.model", label: "Merge" },
1303
+ };
1304
+
1305
+ function resolveModelRecord(ctx: ExtensionContext, modelRef: string): any | undefined {
1306
+ const trimmed = modelRef.trim();
1307
+ if (!trimmed) return undefined;
1308
+
1309
+ const available = ctx.modelRegistry.getAvailable();
1310
+ const lower = trimmed.toLowerCase();
1311
+ const slashIdx = trimmed.indexOf("/");
1312
+
1313
+ if (slashIdx > 0) {
1314
+ const provider = trimmed.slice(0, slashIdx).toLowerCase();
1315
+ const id = trimmed.slice(slashIdx + 1).toLowerCase();
1316
+ return available.find(
1317
+ (m: any) =>
1318
+ String(m?.provider ?? "").toLowerCase() === provider &&
1319
+ String(m?.id ?? "").toLowerCase() === id,
1320
+ );
1321
+ }
1322
+
1323
+ return available.find(
1324
+ (m: any) =>
1325
+ String(m?.id ?? "").toLowerCase() === lower ||
1326
+ `${String(m?.provider ?? "").toLowerCase()}/${String(m?.id ?? "").toLowerCase()}` === lower,
1327
+ );
1328
+ }
1329
+
1330
+ export function modelSupportsThinking(model: any): boolean {
1331
+ if (!model || typeof model !== "object") return false;
1332
+
1333
+ const boolFlags = [
1334
+ "supportsThinking",
1335
+ "thinking",
1336
+ "supportsReasoning",
1337
+ "supportsReasoningEffort",
1338
+ "supportsReasoningTokens",
1339
+ "reasoning",
1340
+ ];
1341
+ const capabilityKeys = [
1342
+ "reasoningEffort",
1343
+ "reasoningTokens",
1344
+ "thinkingModes",
1345
+ "thinkingMode",
1346
+ "reasoning_effort",
1347
+ "reasoning_tokens",
1348
+ ];
1349
+
1350
+ const candidateObjects = [model, model.capabilities, model.features, model.metadata].filter(
1351
+ (entry) => entry && typeof entry === "object",
1352
+ );
1353
+
1354
+ for (const candidate of candidateObjects) {
1355
+ for (const key of boolFlags) {
1356
+ if (typeof candidate[key] === "boolean" && candidate[key]) return true;
1357
+ if (typeof candidate[key] === "string") {
1358
+ const normalized = candidate[key].trim().toLowerCase();
1359
+ if (["yes", "true", "on", "supported"].includes(normalized)) return true;
1360
+ }
1361
+ }
1362
+ for (const key of capabilityKeys) {
1363
+ if (candidate[key] !== undefined && candidate[key] !== null) return true;
1364
+ }
1365
+ }
1366
+
1367
+ return false;
1368
+ }
1369
+
1370
+ export function buildThinkingSuggestionForModelChange(
1371
+ ctx: ExtensionContext,
1372
+ field: FieldDef,
1373
+ previousModelValue: string,
1374
+ nextModelValue: string,
1375
+ mergedConfig: TaskplaneConfig,
1376
+ ): string | null {
1377
+ const mapping = MODEL_THINKING_PATH_MAP[field.configPath];
1378
+ if (!mapping) return null;
1379
+
1380
+ const previousNormalized = previousModelValue.trim().toLowerCase();
1381
+ const nextNormalized = nextModelValue.trim().toLowerCase();
1382
+ if (!nextNormalized || previousNormalized === nextNormalized) return null;
1383
+
1384
+ const modelRecord = resolveModelRecord(ctx, nextModelValue);
1385
+ if (!modelRecord || !modelSupportsThinking(modelRecord)) return null;
1386
+
1387
+ const currentThinking = normalizeThinkingMode(getNestedValue(mergedConfig, mapping.thinkingPath));
1388
+ if (currentThinking === "high") return null;
1389
+
1390
+ return `${mapping.label} model supports thinking. Consider setting ${mapping.label} Thinking to \"high\".`;
1391
+ }
1392
+
1393
+ export function buildThinkingUnsupportedNoteForThinkingField(
1394
+ ctx: ExtensionContext,
1395
+ field: FieldDef,
1396
+ mergedConfig: TaskplaneConfig,
1397
+ ): string | null {
1398
+ const mapping = THINKING_MODEL_PATH_MAP[field.configPath];
1399
+ if (!mapping) return null;
1400
+
1401
+ const modelRef = String(getNestedValue(mergedConfig, mapping.modelPath) ?? "").trim();
1402
+ if (!modelRef) return null;
1403
+
1404
+ const modelRecord = resolveModelRecord(ctx, modelRef);
1405
+ if (!modelRecord || modelSupportsThinking(modelRecord)) return null;
1406
+
1407
+ return `${mapping.label} model does not advertise thinking support. You can still set thinking; unsupported models ignore it at runtime.`;
1408
+ }
1409
+
1410
+ /**
1411
+ * Scrollable select list for model/provider picking.
1412
+ * Uses pi's TUI custom widget API.
1413
+ */
1414
+ async function selectScrollable(
1415
+ ctx: ExtensionContext,
1416
+ title: string,
1417
+ options: string[],
1418
+ maxVisible = 12,
1419
+ ): Promise<string | undefined> {
1420
+ if (options.length === 0) return undefined;
1421
+
1422
+ const items: SelectItem[] = options.map((option, index) => ({
1423
+ value: String(index),
1424
+ label: option,
1425
+ }));
1426
+
1427
+ const selectedValue = await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
1428
+ const container = new Container();
1429
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1430
+ container.addChild(new Text(theme.fg("accent", title), 1, 0));
1431
+ container.addChild(new Text("", 0, 0));
1432
+
1433
+ const selectList = new SelectList(items, Math.max(3, Math.min(maxVisible, items.length)), {
1434
+ selectedPrefix: (text: string) => theme.fg("accent", text),
1435
+ selectedText: (text: string) => theme.fg("accent", text),
1436
+ description: (text: string) => theme.fg("muted", text),
1437
+ scrollInfo: (text: string) => theme.fg("dim", text),
1438
+ noMatch: (text: string) => theme.fg("warning", text),
1439
+ });
1440
+
1441
+ selectList.onSelect = (item) => done(item.value);
1442
+ selectList.onCancel = () => done(undefined);
1443
+ container.addChild(selectList);
1444
+
1445
+ container.addChild(new Text("", 0, 0));
1446
+ container.addChild(
1447
+ new Text(theme.fg("dim", "↑↓ navigate • type to filter • enter select • esc back"), 1, 0),
1448
+ );
1449
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1450
+
1451
+ return {
1452
+ render: (width: number) => container.render(width),
1453
+ invalidate: () => container.invalidate(),
1454
+ handleInput: (data: string) => {
1455
+ selectList.handleInput(data);
1456
+ tui.requestRender();
1457
+ },
1458
+ };
1459
+ });
1460
+
1461
+ if (selectedValue === undefined) return undefined;
1462
+ const selectedIndex = Number(selectedValue);
1463
+ if (!Number.isInteger(selectedIndex) || selectedIndex < 0 || selectedIndex >= options.length)
1464
+ return undefined;
1465
+ return options[selectedIndex];
1466
+ }
1467
+
1468
+ export async function openSettingsTui(
1469
+ ctx: ExtensionContext,
1470
+ configRoot: string,
1471
+ pointerConfigRoot?: string,
1472
+ onConfigChanged?: () => void,
1473
+ ): Promise<void> {
1474
+ // Load current config state — refreshed each time we return to the top level
1475
+ await showSectionSelectorLoop(ctx, configRoot, pointerConfigRoot, onConfigChanged);
1476
+ }
1477
+
1478
+ /**
1479
+ * Reload all config state from disk. Called after write-back to
1480
+ * refresh the TUI display.
1481
+ */
1482
+ function loadConfigState(
1483
+ configRoot: string,
1484
+ pointerConfigRoot?: string,
1485
+ ): {
1486
+ mergedConfig: TaskplaneConfig;
1487
+ prefs: GlobalPreferences;
1488
+ rawProject: Record<string, any> | null;
1489
+ rawPrefs: Record<string, any> | null;
1490
+ } {
1491
+ const resolvedRoot = resolveConfigRoot(configRoot, pointerConfigRoot);
1492
+ return {
1493
+ mergedConfig: loadProjectConfig(configRoot, pointerConfigRoot),
1494
+ prefs: loadGlobalPreferences(),
1495
+ rawProject: readRawProjectJson(resolvedRoot) || readRawYamlConfigs(resolvedRoot),
1496
+ rawPrefs: readRawPreferences(),
1497
+ };
1498
+ }
1499
+
1500
+ /**
1501
+ * Top-level section selector loop.
1502
+ *
1503
+ * Re-loads config state each iteration so write-backs are reflected
1504
+ * immediately in the TUI.
1505
+ */
1506
+ async function showSectionSelectorLoop(
1507
+ ctx: ExtensionContext,
1508
+ configRoot: string,
1509
+ pointerConfigRoot?: string,
1510
+ onConfigChanged?: () => void,
1511
+ ): Promise<void> {
1512
+ while (true) {
1513
+ const state = loadConfigState(configRoot, pointerConfigRoot);
1514
+
1515
+ const sectionItems: SelectItem[] = SECTIONS.map((section, i) => ({
1516
+ value: String(i),
1517
+ label: section.name,
1518
+ description:
1519
+ section.name === "Agent Extensions"
1520
+ ? "Toggle extensions per agent type"
1521
+ : section.readOnly
1522
+ ? "Read-only collection/record fields"
1523
+ : `${section.fields.length} setting${section.fields.length === 1 ? "" : "s"}`,
1524
+ }));
1525
+
1526
+ const selectedSection = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1527
+ const container = new Container();
1528
+
1529
+ // Top border
1530
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1531
+
1532
+ // Title
1533
+ container.addChild(new Text(theme.fg("accent", theme.bold("⚙ Settings")), 1, 0));
1534
+ container.addChild(
1535
+ new Text(theme.fg("dim", "Navigate sections to view and edit configuration"), 1, 0),
1536
+ );
1537
+ container.addChild(new Text("", 0, 0));
1538
+
1539
+ // SelectList
1540
+ const selectList = new SelectList(sectionItems, Math.min(sectionItems.length, 14), {
1541
+ selectedPrefix: (t) => theme.fg("accent", t),
1542
+ selectedText: (t) => theme.fg("accent", t),
1543
+ description: (t) => theme.fg("muted", t),
1544
+ scrollInfo: (t) => theme.fg("dim", t),
1545
+ noMatch: (t) => theme.fg("warning", t),
1546
+ });
1547
+ selectList.onSelect = (item) => done(item.value);
1548
+ selectList.onCancel = () => done(null);
1549
+ container.addChild(selectList);
1550
+
1551
+ // Help text
1552
+ container.addChild(new Text("", 0, 0));
1553
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc close"), 1, 0));
1554
+
1555
+ // Bottom border
1556
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1557
+
1558
+ return {
1559
+ render: (w: number) => container.render(w),
1560
+ invalidate: () => container.invalidate(),
1561
+ handleInput: (data: string) => {
1562
+ selectList.handleInput(data);
1563
+ tui.requestRender();
1564
+ },
1565
+ };
1566
+ });
1567
+
1568
+ if (selectedSection === null) return; // User pressed Esc
1569
+
1570
+ const sectionIndex = parseInt(selectedSection, 10);
1571
+ const section = SECTIONS[sectionIndex];
1572
+
1573
+ if (section.name === "Agent Extensions") {
1574
+ await showExtensionsSection(ctx, configRoot, pointerConfigRoot, onConfigChanged);
1575
+ } else if (section.readOnly) {
1576
+ await showAdvancedSection(ctx, state.mergedConfig);
1577
+ } else {
1578
+ await showSectionSettingsLoop(ctx, section, configRoot, pointerConfigRoot, onConfigChanged);
1579
+ }
1580
+ }
1581
+ }
1582
+
1583
+ /**
1584
+ * Show the Advanced (JSON Only) section — read-only display.
1585
+ */
1586
+ async function showAdvancedSection(
1587
+ ctx: ExtensionContext,
1588
+ mergedConfig: TaskplaneConfig,
1589
+ ): Promise<void> {
1590
+ const advItems = getAdvancedItems(mergedConfig);
1591
+
1592
+ const settingsItems: SettingItem[] = advItems.map((item) => ({
1593
+ id: item.configPath,
1594
+ label: item.label,
1595
+ currentValue: item.value,
1596
+ description: `${item.configPath} — edit in .pi/orchid-config.json`,
1597
+ // No `values` array = no toggle cycling
1598
+ }));
1599
+
1600
+ await ctx.ui.custom((tui, theme, _kb, done) => {
1601
+ const container = new Container();
1602
+
1603
+ // Top border
1604
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1605
+
1606
+ // Title
1607
+ container.addChild(new Text(theme.fg("accent", theme.bold("Advanced (JSON Only)")), 1, 0));
1608
+ container.addChild(
1609
+ new Text(theme.fg("dim", "These fields can only be edited directly in the config file"), 1, 0),
1610
+ );
1611
+ container.addChild(new Text("", 0, 0));
1612
+
1613
+ const settingsList = new SettingsList(
1614
+ settingsItems,
1615
+ Math.min(settingsItems.length + 2, 20),
1616
+ getSettingsListTheme(),
1617
+ () => {}, // onChange — no-op (read-only)
1618
+ () => done(undefined), // onCancel
1619
+ );
1620
+ container.addChild(settingsList);
1621
+
1622
+ // Help text
1623
+ container.addChild(new Text("", 0, 0));
1624
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • esc back"), 1, 0));
1625
+
1626
+ // Bottom border
1627
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1628
+
1629
+ return {
1630
+ render: (w: number) => container.render(w),
1631
+ invalidate: () => container.invalidate(),
1632
+ handleInput: (data: string) => {
1633
+ settingsList.handleInput?.(data);
1634
+ tui.requestRender();
1635
+ },
1636
+ };
1637
+ });
1638
+ }
1639
+
1640
+ /**
1641
+ * TP-180: Agent Extensions section — toggle extensions per agent type.
1642
+ *
1643
+ * Discovers all installed Pi extension packages from project + global settings,
1644
+ * shows per-agent-type toggles (Worker, Reviewer, Merger), and saves
1645
+ * exclusion changes to project orchid-config.json.
1646
+ */
1647
+ async function showExtensionsSection(
1648
+ ctx: ExtensionContext,
1649
+ configRoot: string,
1650
+ pointerConfigRoot?: string,
1651
+ onConfigChanged?: () => void,
1652
+ ): Promise<void> {
1653
+ while (true) {
1654
+ const resolvedRoot = resolveConfigRoot(configRoot, pointerConfigRoot);
1655
+ const mergedConfig = loadProjectConfig(configRoot, pointerConfigRoot);
1656
+
1657
+ // Discover installed packages (excluding OrchID itself)
1658
+ // Use configRoot (project/state root) for consistency with runtime forwarding,
1659
+ // not resolvedRoot (pointer-resolved config path).
1660
+ const packages = loadPiSettingsPackages(configRoot);
1661
+
1662
+ if (packages.length === 0) {
1663
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
1664
+ const container = new Container();
1665
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1666
+ container.addChild(new Text(theme.fg("accent", theme.bold("Agent Extensions")), 1, 0));
1667
+ container.addChild(new Text("", 0, 0));
1668
+ container.addChild(new Text(theme.fg("dim", "No third-party extensions found."), 1, 0));
1669
+ container.addChild(
1670
+ new Text(theme.fg("dim", "Install extensions via pi settings to see them here."), 1, 0),
1671
+ );
1672
+ container.addChild(new Text("", 0, 0));
1673
+ container.addChild(new Text(theme.fg("dim", "esc back"), 1, 0));
1674
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1675
+ return {
1676
+ render: (w: number) => container.render(w),
1677
+ invalidate: () => container.invalidate(),
1678
+ handleInput: (data: string) => {
1679
+ if (data === "\x1b" || data === "\x1b\x1b") done(undefined);
1680
+ },
1681
+ };
1682
+ });
1683
+ return;
1684
+ }
1685
+
1686
+ // Read current exclusion lists
1687
+ const workerExclude = new Set(mergedConfig.taskRunner.worker.excludeExtensions ?? []);
1688
+ const reviewerExclude = new Set(mergedConfig.taskRunner.reviewer.excludeExtensions ?? []);
1689
+ const mergeExclude = new Set(mergedConfig.orchestrator.merge.excludeExtensions ?? []);
1690
+
1691
+ const agentTypes = [
1692
+ { name: "Worker", exclude: workerExclude, configPath: "taskRunner.worker.excludeExtensions" },
1693
+ {
1694
+ name: "Reviewer",
1695
+ exclude: reviewerExclude,
1696
+ configPath: "taskRunner.reviewer.excludeExtensions",
1697
+ },
1698
+ { name: "Merger", exclude: mergeExclude, configPath: "orchestrator.merge.excludeExtensions" },
1699
+ ];
1700
+
1701
+ // Build toggle items: one per package per agent type
1702
+ const settingsItems: SettingItem[] = [];
1703
+ for (const pkg of packages) {
1704
+ for (const agentType of agentTypes) {
1705
+ const isExcluded = agentType.exclude.has(pkg);
1706
+ const enabled = !isExcluded;
1707
+ settingsItems.push({
1708
+ id: `${agentType.configPath}::${pkg}`,
1709
+ label: `${pkg}`,
1710
+ currentValue: enabled ? "✅ enabled" : "❌ disabled",
1711
+ description: agentType.name,
1712
+ values: [enabled ? "❌ disabled" : "✅ enabled"],
1713
+ });
1714
+ }
1715
+ }
1716
+
1717
+ const result = await ctx.ui.custom<{ id: string; value: string } | null>(
1718
+ (tui, theme, _kb, done) => {
1719
+ const container = new Container();
1720
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1721
+ container.addChild(new Text(theme.fg("accent", theme.bold("Agent Extensions")), 1, 0));
1722
+ container.addChild(new Text(theme.fg("dim", "Toggle extensions on/off per agent type"), 1, 0));
1723
+ container.addChild(new Text("", 0, 0));
1724
+
1725
+ const settingsList = new SettingsList(
1726
+ settingsItems,
1727
+ Math.min(settingsItems.length + 2, 20),
1728
+ getSettingsListTheme(),
1729
+ (id, newValue) => done({ id, value: newValue }),
1730
+ () => done(null),
1731
+ );
1732
+ container.addChild(settingsList);
1733
+
1734
+ container.addChild(new Text("", 0, 0));
1735
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • space toggle • esc back"), 1, 0));
1736
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1737
+
1738
+ return {
1739
+ render: (w: number) => container.render(w),
1740
+ invalidate: () => container.invalidate(),
1741
+ handleInput: (data: string) => {
1742
+ settingsList.handleInput?.(data);
1743
+ tui.requestRender();
1744
+ },
1745
+ };
1746
+ },
1747
+ );
1748
+
1749
+ if (!result) return; // User pressed Esc
1750
+
1751
+ // Parse the toggle result
1752
+ const [configPath, pkg] = result.id.split("::", 2);
1753
+ if (!configPath || !pkg) continue;
1754
+
1755
+ const enabling = result.value.includes("enabled");
1756
+
1757
+ // Read current exclusion array from merged effective config (handles YAML+JSON)
1758
+ const freshConfig = loadProjectConfig(configRoot, pointerConfigRoot);
1759
+ const currentExcludeList: string[] =
1760
+ (getNestedValue(freshConfig, configPath) as string[] | undefined) ?? [];
1761
+
1762
+ let newExcludeList: string[];
1763
+ if (enabling) {
1764
+ // Remove from exclusions → enable
1765
+ newExcludeList = currentExcludeList.filter((e: string) => e !== pkg);
1766
+ } else {
1767
+ // Add to exclusions → disable
1768
+ newExcludeList = currentExcludeList.includes(pkg)
1769
+ ? currentExcludeList
1770
+ : [...currentExcludeList, pkg];
1771
+ }
1772
+
1773
+ try {
1774
+ writeProjectConfigField(configRoot, configPath, newExcludeList, pointerConfigRoot);
1775
+ if (onConfigChanged) {
1776
+ try {
1777
+ onConfigChanged();
1778
+ } catch {
1779
+ /* non-fatal */
1780
+ }
1781
+ }
1782
+ ctx.ui.notify(
1783
+ `${enabling ? "✅ Enabled" : "❌ Disabled"} ${pkg} for ${configPath.includes("worker") ? "Worker" : configPath.includes("reviewer") ? "Reviewer" : "Merger"}`,
1784
+ "info",
1785
+ );
1786
+ } catch (err: any) {
1787
+ ctx.ui.notify(`❌ Failed to save: ${err.message}`, "error");
1788
+ }
1789
+
1790
+ // Loop continues → re-render with fresh state
1791
+ }
1792
+ }
1793
+
1794
+ /**
1795
+ * Format a source badge for display.
1796
+ */
1797
+ function formatSourceBadge(source: FieldSource): string {
1798
+ switch (source) {
1799
+ case "project":
1800
+ return "(project)";
1801
+ case "global":
1802
+ return "(global)";
1803
+ }
1804
+ }
1805
+
1806
+ /** Represents a pending field change returned from the section TUI. */
1807
+ interface PendingChange {
1808
+ fieldId: string;
1809
+ rawValue: string;
1810
+ }
1811
+
1812
+ /**
1813
+ * Section settings loop — shows the section, handles writes, and
1814
+ * re-renders with fresh state after each successful write.
1815
+ */
1816
+ async function showSectionSettingsLoop(
1817
+ ctx: ExtensionContext,
1818
+ section: SectionDef,
1819
+ configRoot: string,
1820
+ pointerConfigRoot?: string,
1821
+ onConfigChanged?: () => void,
1822
+ ): Promise<void> {
1823
+ while (true) {
1824
+ const state = loadConfigState(configRoot, pointerConfigRoot);
1825
+ const result = await showSectionSettingsOnce(
1826
+ ctx,
1827
+ section,
1828
+ state.mergedConfig,
1829
+ state.prefs,
1830
+ state.rawProject,
1831
+ state.rawPrefs,
1832
+ );
1833
+
1834
+ if (result === null) return; // User pressed Esc → back to sections
1835
+
1836
+ // Process the pending change
1837
+ const field = section.fields.find((f) => f.configPath === result.fieldId);
1838
+ if (!field) continue; // Safety: field not found
1839
+
1840
+ let previousModelValue = "";
1841
+
1842
+ // Input/picker fields: the submenu returned a sentinel — open the editor picker.
1843
+ if (
1844
+ result.rawValue === "__EDIT_REQUESTED__" &&
1845
+ (field.control === "input" || field.control === "picker")
1846
+ ) {
1847
+ const state = loadConfigState(configRoot, pointerConfigRoot);
1848
+ const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
1849
+ const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|global)\)$/, "");
1850
+ const normalizedCurrent = currentClean === "(inherit)" ? "" : currentClean;
1851
+
1852
+ // Model fields: use interactive provider → model picker instead of free-text
1853
+ if (field.configPath.endsWith(".model")) {
1854
+ previousModelValue = normalizedCurrent;
1855
+ const selected = await pickModel(ctx, normalizedCurrent);
1856
+ if (selected === undefined) continue; // Cancelled
1857
+ result.rawValue = selected;
1858
+ } else if (field.control === "picker" && field.configPath.endsWith(".thinking")) {
1859
+ const note = buildThinkingUnsupportedNoteForThinkingField(ctx, field, state.mergedConfig);
1860
+ if (note) ctx.ui.notify(note, "info");
1861
+ const selected = await pickThinkingMode(ctx, normalizedCurrent);
1862
+ if (selected === undefined) continue; // Cancelled
1863
+ result.rawValue = selected;
1864
+ } else if (field.control === "picker" && field.values && field.values.length > 0) {
1865
+ // Enum picker: show scrollable list of allowed values
1866
+ const options = field.values.map((v) => `${v}${v === normalizedCurrent ? " ✓ current" : ""}`);
1867
+ const selected = await selectScrollable(ctx, field.label, options);
1868
+ if (!selected) continue; // Cancelled
1869
+ result.rawValue = selected.replace(/\s+✓ current$/, "");
1870
+ } else {
1871
+ const placeholder =
1872
+ currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean;
1873
+
1874
+ const newValue = await ctx.ui.input(
1875
+ `${field.label}${field.description ? ` — ${field.description}` : ""}`,
1876
+ placeholder,
1877
+ );
1878
+
1879
+ if (newValue === null || newValue === undefined) continue; // Cancelled
1880
+
1881
+ // Validate
1882
+ const validation = validateFieldInput(field, newValue);
1883
+ if (!validation.valid) {
1884
+ ctx.ui.notify(`❌ Invalid value: ${validation.error}`, "error");
1885
+ continue;
1886
+ }
1887
+
1888
+ result.rawValue = newValue;
1889
+ }
1890
+ }
1891
+
1892
+ const typedValue = coerceValueForWrite(field, result.rawValue);
1893
+ const hasProjectOverride =
1894
+ field.layer !== "L2" &&
1895
+ !!state.rawProject &&
1896
+ getNestedValue(state.rawProject, field.configPath) !== undefined;
1897
+
1898
+ // Collect UI answers for the write-decision contract
1899
+ let destinationChoice: string | null = null;
1900
+ if (field.layer !== "L2") {
1901
+ const options = [
1902
+ "Global preferences (default)",
1903
+ "Project override (this project only)",
1904
+ ...(hasProjectOverride ? ["Remove project override (revert to global)"] : []),
1905
+ "Cancel",
1906
+ ];
1907
+ destinationChoice = await ctx.ui.select("Save this change to:", options);
1908
+ }
1909
+
1910
+ let projectConfirmed = true;
1911
+ if (destinationChoice?.startsWith("Project override")) {
1912
+ projectConfirmed = await ctx.ui.confirm(
1913
+ "Confirm project override",
1914
+ "This writes to .pi/orchid-config.json as a project override. Continue?",
1915
+ );
1916
+ }
1917
+
1918
+ const dest = resolveWriteAction(field, destinationChoice, projectConfirmed);
1919
+ if (dest === "skip") continue;
1920
+
1921
+ // Perform the write
1922
+ try {
1923
+ if (dest === "project") {
1924
+ writeProjectConfigField(configRoot, field.configPath, typedValue, pointerConfigRoot);
1925
+ } else if (dest === "remove-project") {
1926
+ writeProjectConfigField(configRoot, field.configPath, undefined, pointerConfigRoot);
1927
+ } else {
1928
+ writeGlobalPreference(toGlobalPreferencePath(field), typedValue);
1929
+ }
1930
+ // Notify caller to reload in-memory config from disk
1931
+ if (onConfigChanged) {
1932
+ try {
1933
+ onConfigChanged();
1934
+ } catch {
1935
+ /* non-fatal */
1936
+ }
1937
+ }
1938
+
1939
+ ctx.ui.notify(`✅ ${field.label} updated.`, "info");
1940
+
1941
+ const refreshedState = loadConfigState(configRoot, pointerConfigRoot);
1942
+ const suggestion = buildThinkingSuggestionForModelChange(
1943
+ ctx,
1944
+ field,
1945
+ previousModelValue,
1946
+ String(result.rawValue ?? ""),
1947
+ refreshedState.mergedConfig,
1948
+ );
1949
+ if (suggestion) {
1950
+ ctx.ui.notify(`💡 ${suggestion}`, "info");
1951
+ }
1952
+ } catch (err: any) {
1953
+ ctx.ui.notify(`❌ Failed to save: ${err.message}`, "error");
1954
+ }
1955
+
1956
+ // Loop continues → re-show section with fresh state
1957
+ }
1958
+ }
1959
+
1960
+ /**
1961
+ * Show the settings list for a section once.
1962
+ *
1963
+ * Returns a PendingChange when the user edits a field (toggle or input),
1964
+ * or null when the user presses Esc to go back.
1965
+ *
1966
+ * Design: the TUI exits after any change so the caller can handle
1967
+ * confirmation/destination choice with standard ctx.ui methods,
1968
+ * then re-renders with fresh state.
1969
+ */
1970
+ async function showSectionSettingsOnce(
1971
+ ctx: ExtensionContext,
1972
+ section: SectionDef,
1973
+ mergedConfig: TaskplaneConfig,
1974
+ prefs: GlobalPreferences,
1975
+ rawProject: Record<string, any> | null,
1976
+ rawPrefs: Record<string, any> | null,
1977
+ ): Promise<PendingChange | null> {
1978
+ // Build SettingItem[] from section fields
1979
+ const settingsItems: SettingItem[] = section.fields.map((field) => {
1980
+ const displayValue = getFieldDisplayValue(field, mergedConfig, prefs);
1981
+ const source = detectFieldSource(field, rawProject, rawPrefs);
1982
+ const sourceBadge = formatSourceBadge(source);
1983
+
1984
+ const item: SettingItem = {
1985
+ id: field.configPath,
1986
+ label: field.label,
1987
+ currentValue: `${displayValue} ${sourceBadge}`,
1988
+ description: field.description,
1989
+ };
1990
+
1991
+ // Toggle fields: use cycling for 2 values (boolean-like), submenu for 3+
1992
+ if (field.control === "toggle" && field.values) {
1993
+ if (field.values.length <= 2) {
1994
+ item.values = field.values.map((v) => `${v} ${sourceBadge}`);
1995
+ } else {
1996
+ // 3+ values: use a submenu so the user can pick any option,
1997
+ // not just cycle to the next one and immediately commit.
1998
+ item.submenu = (_currentValue: string, done: (selected?: string) => void) => {
1999
+ const selectItems: SelectItem[] = field.values!.map((v) => ({
2000
+ value: `${v} ${sourceBadge}`,
2001
+ label: v,
2002
+ }));
2003
+ // Return SelectList directly — Container doesn't forward
2004
+ // handleInput to children, which would freeze the TUI.
2005
+ const list = new SelectList(selectItems, Math.min(selectItems.length + 1, 10), {
2006
+ selectedPrefix: (t: string) => `\x1b[36m${t}\x1b[0m`,
2007
+ selectedText: (t: string) => `\x1b[36m${t}\x1b[0m`,
2008
+ description: (t: string) => `\x1b[2m${t}\x1b[0m`,
2009
+ scrollInfo: (t: string) => `\x1b[2m${t}\x1b[0m`,
2010
+ noMatch: (t: string) => `\x1b[33m${t}\x1b[0m`,
2011
+ });
2012
+ const currentIdx = field.values!.indexOf(displayValue);
2013
+ if (currentIdx >= 0) list.setSelectedIndex(currentIdx);
2014
+ list.onSelect = (selected) => done(selected.value);
2015
+ list.onCancel = () => done();
2016
+ return list;
2017
+ };
2018
+ }
2019
+ }
2020
+
2021
+ // Input fields: use a single-value cycling pattern instead of a submenu.
2022
+ // The inline submenu approach freezes on Windows/tmux (issue #57).
2023
+ // We set a single sentinel value so pressing Enter/Space triggers onChange,
2024
+ // which exits the TUI. The caller then uses ctx.ui.input() for actual editing.
2025
+ if (field.control === "input" || field.control === "picker") {
2026
+ item.values = [`__EDIT_REQUESTED__`];
2027
+ }
2028
+
2029
+ return item;
2030
+ });
2031
+
2032
+ // Find JSON-only fields for this section's config path (footer note)
2033
+ const jsonOnlyNote = getJsonOnlyFooterForSection(section, mergedConfig);
2034
+
2035
+ return ctx.ui.custom<PendingChange | null>((tui, theme, _kb, done) => {
2036
+ const container = new Container();
2037
+
2038
+ // Top border
2039
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2040
+
2041
+ // Title
2042
+ container.addChild(new Text(theme.fg("accent", theme.bold(section.name)), 1, 0));
2043
+ container.addChild(new Text("", 0, 0));
2044
+
2045
+ const settingsList = new SettingsList(
2046
+ settingsItems,
2047
+ Math.min(settingsItems.length + 2, 20),
2048
+ getSettingsListTheme(),
2049
+ (id, newValue) => {
2050
+ // onChange: a toggle was cycled or an input was submitted
2051
+ // Exit TUI with the change so the caller can handle write-back
2052
+ done({ fieldId: id, rawValue: newValue });
2053
+ },
2054
+ () => done(null), // onCancel → back to section selector
2055
+ { enableSearch: settingsItems.length > 5 },
2056
+ );
2057
+ container.addChild(settingsList);
2058
+
2059
+ // JSON-only footer note
2060
+ if (jsonOnlyNote) {
2061
+ container.addChild(new Text("", 0, 0));
2062
+ container.addChild(new Text(theme.fg("dim", jsonOnlyNote), 1, 0));
2063
+ }
2064
+
2065
+ // Help text
2066
+ container.addChild(new Text("", 0, 0));
2067
+ container.addChild(
2068
+ new Text(theme.fg("dim", "↑↓ navigate • ←→/space cycle • enter edit • esc back"), 1, 0),
2069
+ );
2070
+
2071
+ // Bottom border
2072
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
2073
+
2074
+ return {
2075
+ render: (w: number) => container.render(w),
2076
+ invalidate: () => container.invalidate(),
2077
+ handleInput: (data: string) => {
2078
+ settingsList.handleInput?.(data);
2079
+ tui.requestRender();
2080
+ },
2081
+ };
2082
+ });
2083
+ }
2084
+
2085
+ // ── Input Submenu ────────────────────────────────────────────────────
2086
+
2087
+ /**
2088
+ * Create a submenu component for inline text input editing.
2089
+ * Used by SettingsList's submenu pattern for input-type fields.
2090
+ */
2091
+ function createInputSubmenu(
2092
+ field: FieldDef,
2093
+ currentValue: string,
2094
+ done: (selectedValue?: string) => void,
2095
+ ): any {
2096
+ // Strip source badge from current value for editing
2097
+ const cleanValue = currentValue.replace(/\s+\((?:default|project|global)\)$/, "");
2098
+ let inputBuffer = cleanValue === "(not set)" || cleanValue === "(inherit)" ? "" : cleanValue;
2099
+ let errorMsg = "";
2100
+ let cursorPos = inputBuffer.length;
2101
+
2102
+ const component = {
2103
+ render(width: number): string[] {
2104
+ const lines: string[] = [];
2105
+ const prompt = ` Enter ${field.label}: `;
2106
+ const inputDisplay = inputBuffer + "█"; // Simple cursor
2107
+ lines.push(truncateLine(prompt + inputDisplay, width));
2108
+
2109
+ if (field.optional) {
2110
+ lines.push(truncateLine(" (empty to unset)", width));
2111
+ }
2112
+
2113
+ if (errorMsg) {
2114
+ lines.push(truncateLine(` ❌ ${errorMsg}`, width));
2115
+ }
2116
+
2117
+ lines.push(truncateLine(" enter confirm • esc cancel", width));
2118
+ return lines;
2119
+ },
2120
+
2121
+ invalidate() {},
2122
+
2123
+ handleInput(data: string): void {
2124
+ // Simple input handling — enter, escape, backspace, printable chars
2125
+ if (data === "\r" || data === "\n") {
2126
+ // Validate and confirm
2127
+ const result = validateFieldInput(field, inputBuffer);
2128
+ if (result.valid) {
2129
+ if (inputBuffer.trim() === "" && field.optional) {
2130
+ done("(not set)");
2131
+ } else {
2132
+ done(inputBuffer);
2133
+ }
2134
+ } else {
2135
+ errorMsg = result.error || "Invalid input";
2136
+ }
2137
+ } else if (data === "\x1b" || data === "\x1b\x1b") {
2138
+ // Escape — cancel
2139
+ done(undefined);
2140
+ } else if (data === "\x7f" || data === "\b") {
2141
+ // Backspace
2142
+ if (inputBuffer.length > 0) {
2143
+ inputBuffer = inputBuffer.slice(0, -1);
2144
+ errorMsg = "";
2145
+ }
2146
+ } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
2147
+ // Printable character
2148
+ inputBuffer += data;
2149
+ errorMsg = "";
2150
+ }
2151
+ },
2152
+ };
2153
+
2154
+ return component;
2155
+ }
2156
+
2157
+ /** Simple line truncation for submenu rendering */
2158
+ function truncateLine(text: string, width: number): string {
2159
+ if (text.length <= width) return text;
2160
+ return text.substring(0, width - 3) + "...";
2161
+ }
2162
+
2163
+ // ── JSON-Only Footer ─────────────────────────────────────────────────
2164
+
2165
+ /**
2166
+ * Map from section name to the config subsection prefixes it covers.
2167
+ * Used to dynamically discover JSON-only sibling fields.
2168
+ */
2169
+ const SECTION_CONFIG_PREFIXES: Record<string, string[]> = {
2170
+ Orchestrator: ["orchestrator.orchestrator"],
2171
+ "Agent: Supervisor": ["orchestrator.supervisor"],
2172
+ "Agent: Worker": ["taskRunner.worker"],
2173
+ "Agent: Reviewer": ["taskRunner.reviewer"],
2174
+ "Agent: Merge": ["orchestrator.merge"],
2175
+ "Context Limits": ["taskRunner.context"],
2176
+ "Failure Policy": ["orchestrator.failure"],
2177
+ Dependencies: ["orchestrator.dependencies"],
2178
+ Assignment: ["orchestrator.assignment"],
2179
+ "Pre-Warm": ["orchestrator.preWarm"],
2180
+ Monitoring: ["orchestrator.monitoring"],
2181
+ };
2182
+
2183
+ /**
2184
+ * Generate a footer note about JSON-only fields related to a section.
2185
+ *
2186
+ * Dynamically discovers uncovered fields under the same config subsection
2187
+ * prefix, so new fields added to the schema auto-appear in footers.
2188
+ */
2189
+ function getJsonOnlyFooterForSection(section: SectionDef, config: TaskplaneConfig): string | null {
2190
+ const prefixes = SECTION_CONFIG_PREFIXES[section.name];
2191
+ if (!prefixes) return null;
2192
+
2193
+ // Find all uncovered leaf fields under these prefixes
2194
+ const uncoveredFields: string[] = [];
2195
+ walkConfig(config, "", (path, _value) => {
2196
+ if (COVERED_PATHS.has(path)) return; // Already editable
2197
+ for (const prefix of prefixes) {
2198
+ if (path.startsWith(prefix + ".")) {
2199
+ // Extract the field name (last segment)
2200
+ const fieldName = path.split(".").pop() || path;
2201
+ uncoveredFields.push(fieldName);
2202
+ }
2203
+ }
2204
+ });
2205
+
2206
+ if (uncoveredFields.length === 0) return null;
2207
+ return `+ ${uncoveredFields.join(", ")} (edit JSON directly)`;
2208
+ }