taskplane 0.3.1 → 0.4.1

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.
@@ -0,0 +1,1387 @@
1
+ /**
2
+ * Settings TUI — interactive configuration viewer and editor.
3
+ *
4
+ * Provides a `/taskplane-settings` command that renders a two-level navigation:
5
+ * 1. Section selector (12 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 config files (before defaults merge) to
10
+ * determine whether each field value comes from project config, user
11
+ * preferences, or schema defaults.
12
+ *
13
+ * Write-back targets the correct destination per field layer:
14
+ * - L1-only → project JSON, L2-only → preferences JSON,
15
+ * - L1+L2 → user chooses destination via ctx.ui.select()
16
+ *
17
+ * @module settings/tui
18
+ */
19
+
20
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
21
+ import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
22
+ import { Container, type SelectItem, SelectList, type SettingItem, SettingsList, Text } from "@mariozechner/pi-tui";
23
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
24
+ import { join, dirname } from "path";
25
+ import { parse as yamlParse } from "yaml";
26
+
27
+ import {
28
+ CONFIG_VERSION,
29
+ DEFAULT_PROJECT_CONFIG,
30
+ PROJECT_CONFIG_FILENAME,
31
+ type TaskplaneConfig,
32
+ type UserPreferences,
33
+ } from "./config-schema.ts";
34
+ import {
35
+ loadUserPreferences,
36
+ loadProjectConfig,
37
+ loadLayer1Config,
38
+ resolveConfigRoot,
39
+ resolveUserPreferencesPath,
40
+ } from "./config-loader.ts";
41
+
42
+
43
+ // ── Types ────────────────────────────────────────────────────────────
44
+
45
+ /** Source of a field's current value */
46
+ export type FieldSource = "default" | "project" | "user";
47
+
48
+ /** Layer assignment for a field */
49
+ export type FieldLayer = "L1" | "L2" | "L1+L2";
50
+
51
+ /** UI control type for a field */
52
+ export type FieldControl = "toggle" | "input";
53
+
54
+ /** Field definition for the settings TUI */
55
+ export interface FieldDef {
56
+ /** Dot-separated config path (e.g., "orchestrator.orchestrator.maxLanes") */
57
+ configPath: string;
58
+ /** Human-readable label */
59
+ label: string;
60
+ /** UI control type */
61
+ control: FieldControl;
62
+ /** Layer assignment */
63
+ layer: FieldLayer;
64
+ /** For toggle fields: list of allowed values */
65
+ values?: string[];
66
+ /** Field type for validation */
67
+ fieldType: "string" | "number" | "boolean" | "enum";
68
+ /** Whether the field is optional (can be unset) */
69
+ optional?: boolean;
70
+ /** For L1+L2 fields: the user preferences key */
71
+ prefsKey?: keyof UserPreferences;
72
+ /** Description shown when selected */
73
+ description?: string;
74
+ }
75
+
76
+ /** Section definition */
77
+ export interface SectionDef {
78
+ /** Section display name */
79
+ name: string;
80
+ /** Fields in this section */
81
+ fields: FieldDef[];
82
+ /** Whether this section is read-only (Advanced) */
83
+ readOnly?: boolean;
84
+ }
85
+
86
+
87
+ // ── Section & Field Definitions ──────────────────────────────────────
88
+
89
+ /**
90
+ * Canonical navigation map — 12 sections.
91
+ * Order matches the Step 1 design in STATUS.md.
92
+ */
93
+ export const SECTIONS: SectionDef[] = [
94
+ {
95
+ name: "Orchestrator",
96
+ fields: [
97
+ { configPath: "orchestrator.orchestrator.maxLanes", label: "Max Lanes", control: "input", layer: "L1", fieldType: "number", description: "Maximum parallel execution lanes" },
98
+ { configPath: "orchestrator.orchestrator.worktreeLocation", label: "Worktree Location", control: "toggle", layer: "L1", fieldType: "enum", values: ["sibling", "subdirectory"], description: "Where lane worktree directories are created" },
99
+ { configPath: "orchestrator.orchestrator.worktreePrefix", label: "Worktree Prefix", control: "input", layer: "L1", fieldType: "string", description: "Prefix for worktree directory names" },
100
+ { configPath: "orchestrator.orchestrator.batchIdFormat", label: "Batch ID Format", control: "toggle", layer: "L1", fieldType: "enum", values: ["timestamp", "sequential"], description: "Batch ID format for logs/branch naming" },
101
+ { configPath: "orchestrator.orchestrator.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1+L2", fieldType: "enum", values: ["tmux", "subprocess"], prefsKey: "spawnMode", description: "How lane sessions are spawned" },
102
+ { configPath: "orchestrator.orchestrator.tmuxPrefix", label: "Tmux Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "tmuxPrefix", description: "Prefix for orchestrator tmux sessions" },
103
+ { configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" },
104
+ ],
105
+ },
106
+ {
107
+ name: "Dependencies",
108
+ fields: [
109
+ { configPath: "orchestrator.dependencies.source", label: "Dep Source", control: "toggle", layer: "L1", fieldType: "enum", values: ["prompt", "agent"], description: "Dependency extraction source" },
110
+ { configPath: "orchestrator.dependencies.cache", label: "Dep Cache", control: "toggle", layer: "L1", fieldType: "boolean", values: ["true", "false"], description: "Cache dependency analysis results" },
111
+ ],
112
+ },
113
+ {
114
+ name: "Assignment",
115
+ fields: [
116
+ { configPath: "orchestrator.assignment.strategy", label: "Strategy", control: "toggle", layer: "L1", fieldType: "enum", values: ["affinity-first", "round-robin", "load-balanced"], description: "Lane assignment strategy" },
117
+ ],
118
+ },
119
+ {
120
+ name: "Pre-Warm",
121
+ fields: [
122
+ { configPath: "orchestrator.preWarm.autoDetect", label: "Auto-Detect", control: "toggle", layer: "L1", fieldType: "boolean", values: ["true", "false"], description: "Enable automatic pre-warm command detection" },
123
+ ],
124
+ },
125
+ {
126
+ name: "Merge",
127
+ fields: [
128
+ { configPath: "orchestrator.merge.model", label: "Merge Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeModel", description: "Merge-agent model (empty = inherit session)" },
129
+ { configPath: "orchestrator.merge.tools", label: "Merge Tools", control: "input", layer: "L1", fieldType: "string", description: "Merge-agent tool allowlist" },
130
+ { configPath: "orchestrator.merge.order", label: "Merge Order", control: "toggle", layer: "L1", fieldType: "enum", values: ["fewest-files-first", "sequential"], description: "Lane merge ordering policy" },
131
+ { configPath: "orchestrator.merge.timeoutMinutes", label: "Merge Timeout (minutes)", control: "input", layer: "L1", fieldType: "number", description: "Max time for merge agent to complete. Increase for large batches (default: 10)" },
132
+ ],
133
+ },
134
+ {
135
+ name: "Failure Policy",
136
+ fields: [
137
+ { configPath: "orchestrator.failure.onTaskFailure", label: "On Task Failure", control: "toggle", layer: "L1", fieldType: "enum", values: ["skip-dependents", "stop-wave", "stop-all"], description: "Batch behavior when a task fails" },
138
+ { configPath: "orchestrator.failure.onMergeFailure", label: "On Merge Failure", control: "toggle", layer: "L1", fieldType: "enum", values: ["pause", "abort"], description: "Behavior when a merge step fails" },
139
+ { configPath: "orchestrator.failure.stallTimeout", label: "Stall Timeout (min)", control: "input", layer: "L1", fieldType: "number", description: "Stall detection threshold (minutes)" },
140
+ { configPath: "orchestrator.failure.maxWorkerMinutes", label: "Max Worker Min", control: "input", layer: "L1", fieldType: "number", description: "Max worker runtime budget per task (minutes)" },
141
+ { configPath: "orchestrator.failure.abortGracePeriod", label: "Abort Grace (sec)", control: "input", layer: "L1", fieldType: "number", description: "Graceful abort wait time (seconds)" },
142
+ ],
143
+ },
144
+ {
145
+ name: "Monitoring",
146
+ fields: [
147
+ { configPath: "orchestrator.monitoring.pollInterval", label: "Poll Interval (sec)", control: "input", layer: "L1", fieldType: "number", description: "Poll interval for lane/task monitoring (seconds)" },
148
+ ],
149
+ },
150
+ {
151
+ name: "Worker",
152
+ fields: [
153
+ { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (empty = inherit session)" },
154
+ { configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
155
+ { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "input", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
156
+ { configPath: "taskRunner.worker.spawnMode", label: "Worker Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["(inherit)", "subprocess", "tmux"], optional: true, description: "Worker spawn mode override (inherit = use orchestrator)" },
157
+ ],
158
+ },
159
+ {
160
+ name: "Reviewer",
161
+ fields: [
162
+ { configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (empty = inherit session)" },
163
+ { configPath: "taskRunner.reviewer.tools", label: "Reviewer Tools", control: "input", layer: "L1", fieldType: "string", description: "Reviewer tool allowlist" },
164
+ { configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "input", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" },
165
+ ],
166
+ },
167
+ {
168
+ name: "Context Limits",
169
+ fields: [
170
+ { configPath: "taskRunner.context.workerContextWindow", label: "Context Window", control: "input", layer: "L1", fieldType: "number", description: "Worker context window size" },
171
+ { configPath: "taskRunner.context.warnPercent", label: "Warn %", control: "input", layer: "L1", fieldType: "number", description: "Context utilization warn threshold (%)" },
172
+ { configPath: "taskRunner.context.killPercent", label: "Kill %", control: "input", layer: "L1", fieldType: "number", description: "Context utilization hard-stop threshold (%)" },
173
+ { configPath: "taskRunner.context.maxWorkerIterations", label: "Max Iterations", control: "input", layer: "L1", fieldType: "number", description: "Max worker iterations per step" },
174
+ { configPath: "taskRunner.context.maxReviewCycles", label: "Max Review Cycles", control: "input", layer: "L1", fieldType: "number", description: "Max revise loops per review stage" },
175
+ { configPath: "taskRunner.context.noProgressLimit", label: "No Progress Limit", control: "input", layer: "L1", fieldType: "number", description: "Max no-progress iterations before failure" },
176
+ { configPath: "taskRunner.context.maxWorkerMinutes", label: "Max Worker Min (ctx)", control: "input", layer: "L1", fieldType: "number", optional: true, description: "Per-worker wall-clock cap (minutes, empty = no cap)" },
177
+ ],
178
+ },
179
+ {
180
+ name: "User Preferences",
181
+ fields: [
182
+ { configPath: "preferences.dashboardPort", label: "Dashboard Port", control: "input", layer: "L2", fieldType: "number", prefsKey: "dashboardPort", optional: true, description: "Dashboard server port" },
183
+ ],
184
+ },
185
+ {
186
+ name: "Advanced (JSON Only)",
187
+ readOnly: true,
188
+ fields: [], // Populated dynamically in getAdvancedItems()
189
+ },
190
+ ];
191
+
192
+
193
+ // ── Raw Config Readers (Source Detection) ────────────────────────────
194
+
195
+ /**
196
+ * Resolve the path to a config file under the given root.
197
+ *
198
+ * Supports both standard layout (`<root>/.pi/<file>`) and flat layout
199
+ * (`<root>/<file>`) used by pointer-resolved `.taskplane/` config roots.
200
+ */
201
+ function resolveConfigFilePath(configRoot: string, filename: string): string {
202
+ const standardPath = join(configRoot, ".pi", filename);
203
+ if (existsSync(standardPath)) return standardPath;
204
+ const flatPath = join(configRoot, filename);
205
+ if (existsSync(flatPath)) return flatPath;
206
+ return standardPath;
207
+ }
208
+
209
+ /**
210
+ * Read the raw project config JSON as a plain object (no defaults merge).
211
+ * Returns null if no JSON config exists. Does not throw on parse errors.
212
+ */
213
+ export function readRawProjectJson(configRoot: string): Record<string, any> | null {
214
+ const jsonPath = resolveConfigFilePath(configRoot, PROJECT_CONFIG_FILENAME);
215
+ if (!existsSync(jsonPath)) return null;
216
+ try {
217
+ const raw = readFileSync(jsonPath, "utf-8");
218
+ const parsed = JSON.parse(raw);
219
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Read raw YAML config files and merge into a single raw object
227
+ * using the same path structure as the JSON config.
228
+ * Returns null if no YAML files exist.
229
+ */
230
+ export function readRawYamlConfigs(configRoot: string): Record<string, any> | null {
231
+ const trPath = resolveConfigFilePath(configRoot, "task-runner.yaml");
232
+ const orchPath = resolveConfigFilePath(configRoot, "task-orchestrator.yaml");
233
+ const hasTr = existsSync(trPath);
234
+ const hasOrch = existsSync(orchPath);
235
+ if (!hasTr && !hasOrch) return null;
236
+
237
+ const result: Record<string, any> = {};
238
+
239
+ if (hasTr) {
240
+ try {
241
+ const raw = readFileSync(trPath, "utf-8");
242
+ const parsed = yamlParse(raw);
243
+ if (parsed && typeof parsed === "object") {
244
+ result.taskRunner = convertYamlKeys(parsed, "taskRunner");
245
+ }
246
+ } catch { /* ignore */ }
247
+ }
248
+
249
+ if (hasOrch) {
250
+ try {
251
+ const raw = readFileSync(orchPath, "utf-8");
252
+ const parsed = yamlParse(raw);
253
+ if (parsed && typeof parsed === "object") {
254
+ result.orchestrator = convertYamlKeys(parsed, "orchestrator");
255
+ }
256
+ } catch { /* ignore */ }
257
+ }
258
+
259
+ return Object.keys(result).length > 0 ? result : null;
260
+ }
261
+
262
+ /**
263
+ * Simple snake_case to camelCase conversion for YAML key lookup.
264
+ * Only converts top-level section keys we need for source detection.
265
+ */
266
+ function convertYamlKeys(raw: any, section: "taskRunner" | "orchestrator"): Record<string, any> {
267
+ const result: Record<string, any> = {};
268
+ if (section === "taskRunner") {
269
+ if (raw.worker) result.worker = snakeKeysToCamel(raw.worker);
270
+ if (raw.reviewer) result.reviewer = snakeKeysToCamel(raw.reviewer);
271
+ if (raw.context) result.context = snakeKeysToCamel(raw.context);
272
+ if (raw.project) result.project = snakeKeysToCamel(raw.project);
273
+ if (raw.paths) result.paths = snakeKeysToCamel(raw.paths);
274
+ if (raw.testing) result.testing = raw.testing;
275
+ if (raw.standards) result.standards = raw.standards;
276
+ if (raw.standards_overrides) result.standardsOverrides = raw.standards_overrides;
277
+ if (raw.task_areas) result.taskAreas = raw.task_areas;
278
+ if (raw.reference_docs) result.referenceDocs = raw.reference_docs;
279
+ if (raw.never_load) result.neverLoad = raw.never_load;
280
+ if (raw.self_doc_targets) result.selfDocTargets = raw.self_doc_targets;
281
+ if (raw.protected_docs) result.protectedDocs = raw.protected_docs;
282
+ } else {
283
+ if (raw.orchestrator) result.orchestrator = snakeKeysToCamel(raw.orchestrator);
284
+ if (raw.dependencies) result.dependencies = snakeKeysToCamel(raw.dependencies);
285
+ if (raw.assignment) {
286
+ result.assignment = {};
287
+ if (raw.assignment.strategy !== undefined) result.assignment.strategy = raw.assignment.strategy;
288
+ if (raw.assignment.size_weights) result.assignment.sizeWeights = raw.assignment.size_weights;
289
+ }
290
+ if (raw.pre_warm) {
291
+ result.preWarm = {};
292
+ if (raw.pre_warm.auto_detect !== undefined) result.preWarm.autoDetect = raw.pre_warm.auto_detect;
293
+ if (raw.pre_warm.commands) result.preWarm.commands = raw.pre_warm.commands;
294
+ if (raw.pre_warm.always) result.preWarm.always = raw.pre_warm.always;
295
+ }
296
+ if (raw.merge) result.merge = snakeKeysToCamel(raw.merge);
297
+ if (raw.failure) result.failure = snakeKeysToCamel(raw.failure);
298
+ if (raw.monitoring) result.monitoring = snakeKeysToCamel(raw.monitoring);
299
+ }
300
+ return result;
301
+ }
302
+
303
+ /** Convert snake_case keys in a flat object to camelCase */
304
+ function snakeKeysToCamel(obj: Record<string, any>): Record<string, any> {
305
+ const result: Record<string, any> = {};
306
+ for (const [key, val] of Object.entries(obj)) {
307
+ const camelKey = key.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
308
+ result[camelKey] = val;
309
+ }
310
+ return result;
311
+ }
312
+
313
+ /**
314
+ * Read the raw user preferences JSON.
315
+ */
316
+ function readRawPreferences(): Record<string, any> | null {
317
+ const prefsPath = resolveUserPreferencesPath();
318
+ if (!existsSync(prefsPath)) return null;
319
+ try {
320
+ const raw = readFileSync(prefsPath, "utf-8");
321
+ const parsed = JSON.parse(raw);
322
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
323
+ } catch {
324
+ return null;
325
+ }
326
+ }
327
+
328
+
329
+ // ── Write-Back ───────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Set a nested value in an object by dot-path, creating intermediate
333
+ * objects as needed. If `value` is undefined, deletes the leaf key
334
+ * (for clearing optional fields).
335
+ */
336
+ function setNestedValue(obj: Record<string, any>, path: string, value: any): void {
337
+ const parts = path.split(".");
338
+ let current = obj;
339
+ for (let i = 0; i < parts.length - 1; i++) {
340
+ const part = parts[i];
341
+ if (current[part] === undefined || current[part] === null || typeof current[part] !== "object") {
342
+ current[part] = {};
343
+ }
344
+ current = current[part];
345
+ }
346
+ const leafKey = parts[parts.length - 1];
347
+ if (value === undefined) {
348
+ delete current[leafKey];
349
+ } else {
350
+ current[leafKey] = value;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Write a value to the project config JSON (Layer 1).
356
+ *
357
+ * Writes to the resolved config root using the active layout:
358
+ * - standard: `<configRoot>/.pi/taskplane-config.json`
359
+ * - flat: `<configRoot>/taskplane-config.json` (pointer `.taskplane/` roots)
360
+ *
361
+ * When no JSON config exists (YAML-only scenario), bootstraps the new
362
+ * JSON file from the full current Layer 1 config (YAML values + defaults).
363
+ * This preserves ALL existing YAML-set values — because JSON takes
364
+ * precedence on next load, a partial skeleton would silently reset
365
+ * non-edited fields to defaults.
366
+ *
367
+ * YAML files are preserved alongside the new JSON; the loader's
368
+ * JSON-first precedence means the JSON file is authoritative going forward.
369
+ *
370
+ * Uses atomic tmp+rename write pattern to prevent partial writes.
371
+ */
372
+ export function writeProjectConfigField(
373
+ configRoot: string,
374
+ configPath: string,
375
+ value: any,
376
+ pointerConfigRoot?: string,
377
+ ): void {
378
+ const resolvedRoot = resolveConfigRoot(configRoot, pointerConfigRoot);
379
+
380
+ const hasStandardLayout =
381
+ existsSync(join(resolvedRoot, ".pi", PROJECT_CONFIG_FILENAME)) ||
382
+ existsSync(join(resolvedRoot, ".pi", "task-runner.yaml")) ||
383
+ existsSync(join(resolvedRoot, ".pi", "task-orchestrator.yaml"));
384
+ const hasFlatLayout =
385
+ existsSync(join(resolvedRoot, PROJECT_CONFIG_FILENAME)) ||
386
+ existsSync(join(resolvedRoot, "task-runner.yaml")) ||
387
+ existsSync(join(resolvedRoot, "task-orchestrator.yaml"));
388
+ const useFlatLayout = !hasStandardLayout && hasFlatLayout;
389
+
390
+ const jsonPath = useFlatLayout
391
+ ? join(resolvedRoot, PROJECT_CONFIG_FILENAME)
392
+ : join(resolvedRoot, ".pi", PROJECT_CONFIG_FILENAME);
393
+ const tmpPath = jsonPath + ".tmp";
394
+
395
+ // Ensure parent directory exists
396
+ mkdirSync(dirname(jsonPath), { recursive: true });
397
+
398
+ // Load existing JSON config, or bootstrap from full L1 config.
399
+ // When YAML-only, we seed from loadLayer1Config to preserve all
400
+ // YAML-sourced values (since JSON takes precedence on next load
401
+ // and YAML values would otherwise be lost).
402
+ let configObj: Record<string, any>;
403
+ if (existsSync(jsonPath)) {
404
+ try {
405
+ const raw = readFileSync(jsonPath, "utf-8");
406
+ configObj = JSON.parse(raw);
407
+ } catch (e: any) {
408
+ // Malformed JSON — cannot safely bootstrap because loadLayer1Config
409
+ // would also fail on the same corrupt file. Surface the error so the
410
+ // user can fix/delete the malformed JSON file first.
411
+ throw new Error(
412
+ `Cannot write settings: ${jsonPath} contains malformed JSON. ` +
413
+ `Please fix or delete the file and try again. ` +
414
+ `(Parse error: ${e.message ?? "unknown"})`,
415
+ );
416
+ }
417
+ } else {
418
+ // No JSON exists (possibly YAML-only) — bootstrap from full L1 config.
419
+ // Deep-clone via JSON roundtrip since we mutate the object below.
420
+ configObj = JSON.parse(JSON.stringify(loadLayer1Config(configRoot, pointerConfigRoot)));
421
+ }
422
+
423
+ // Set the value at the config path
424
+ setNestedValue(configObj, configPath, value);
425
+
426
+ // Atomic write: tmp + rename
427
+ const json = JSON.stringify(configObj, null, 2) + "\n";
428
+ writeFileSync(tmpPath, json, "utf-8");
429
+ try {
430
+ renameSync(tmpPath, jsonPath);
431
+ } catch {
432
+ // Windows fallback: direct write if rename fails
433
+ writeFileSync(jsonPath, json, "utf-8");
434
+ try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* cleanup best-effort */ }
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Write a value to the user preferences JSON (Layer 2).
440
+ *
441
+ * Writes to `resolveUserPreferencesPath()`.
442
+ * If `value` is undefined, deletes the key from the preferences file
443
+ * (for clearing preferences).
444
+ *
445
+ * Uses atomic tmp+rename write pattern to prevent partial writes.
446
+ */
447
+ export function writeUserPreference(
448
+ prefsKey: keyof import("./config-schema.ts").UserPreferences,
449
+ value: any,
450
+ ): void {
451
+ const prefsPath = resolveUserPreferencesPath();
452
+ const tmpPath = prefsPath + ".tmp";
453
+
454
+ // Ensure directory exists
455
+ const prefsDir = dirname(prefsPath);
456
+ if (!existsSync(prefsDir)) {
457
+ mkdirSync(prefsDir, { recursive: true });
458
+ }
459
+
460
+ // Load existing prefs or create empty object
461
+ let prefsObj: Record<string, any> = {};
462
+ if (existsSync(prefsPath)) {
463
+ try {
464
+ const raw = readFileSync(prefsPath, "utf-8");
465
+ const parsed = JSON.parse(raw);
466
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
467
+ prefsObj = parsed;
468
+ }
469
+ } catch {
470
+ // Malformed — start fresh (preserve what we can't parse)
471
+ prefsObj = {};
472
+ }
473
+ }
474
+
475
+ // Set or delete the value
476
+ if (value === undefined) {
477
+ delete prefsObj[prefsKey];
478
+ } else {
479
+ prefsObj[prefsKey] = value;
480
+ }
481
+
482
+ // Atomic write: tmp + rename
483
+ const json = JSON.stringify(prefsObj, null, 2) + "\n";
484
+ writeFileSync(tmpPath, json, "utf-8");
485
+ try {
486
+ renameSync(tmpPath, prefsPath);
487
+ } catch {
488
+ // Windows fallback: direct write if rename fails
489
+ writeFileSync(prefsPath, json, "utf-8");
490
+ try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* cleanup best-effort */ }
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Convert a raw string value from the TUI into the appropriate typed
496
+ * value for writing to config JSON.
497
+ *
498
+ * - Numbers: parse to number
499
+ * - Booleans: parse "true"/"false" to boolean
500
+ * - "(not set)" / "(inherit)": returns undefined (delete key)
501
+ * - Strings: return as-is
502
+ */
503
+ export function coerceValueForWrite(field: FieldDef, rawValue: string): any {
504
+ // Strip source badge if present
505
+ const cleaned = rawValue.replace(/\s+\((?:default|project|user)\)$/, "").trim();
506
+
507
+ // Unset / inherit → undefined (delete key)
508
+ if (cleaned === "(not set)" || cleaned === "(inherit)") {
509
+ return undefined;
510
+ }
511
+
512
+ switch (field.fieldType) {
513
+ case "number": {
514
+ const num = Number(cleaned);
515
+ return Number.isFinite(num) ? num : undefined;
516
+ }
517
+ case "boolean":
518
+ return cleaned === "true";
519
+ case "enum":
520
+ case "string":
521
+ default:
522
+ return cleaned;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Determine the write destination for a field change.
528
+ *
529
+ * - L1-only → "project"
530
+ * - L2-only → "prefs"
531
+ * - L1+L2 → must be chosen by the user (returns null to signal "ask user")
532
+ */
533
+ export type WriteDestination = "project" | "prefs";
534
+
535
+ export function getDefaultWriteDestination(field: FieldDef): WriteDestination | null {
536
+ if (field.layer === "L1") return "project";
537
+ if (field.layer === "L2") return "prefs";
538
+ // L1+L2 → user must choose
539
+ return null;
540
+ }
541
+
542
+ /**
543
+ * Resolve the write action for a field change.
544
+ *
545
+ * Encapsulates the destination + confirmation decision tree from
546
+ * showSectionSettingsLoop as a pure function for testability.
547
+ *
548
+ * @param field - The field being edited
549
+ * @param destinationChoice - For L1+L2 fields: the user's choice from the
550
+ * destination select ("User preferences (personal)", "Project config (shared)",
551
+ * "Cancel", or null). Ignored for L1-only and L2-only fields.
552
+ * @param projectConfirmed - For project-destination writes: whether the user
553
+ * confirmed the project config change. Ignored for prefs-destination writes.
554
+ * @returns The resolved destination ("project" | "prefs") or "skip" if the
555
+ * user cancelled or declined confirmation.
556
+ */
557
+ export function resolveWriteAction(
558
+ field: FieldDef,
559
+ destinationChoice: string | null,
560
+ projectConfirmed: boolean,
561
+ ): WriteDestination | "skip" {
562
+ const defaultDest = getDefaultWriteDestination(field);
563
+ let dest: WriteDestination | null = defaultDest;
564
+
565
+ // L1+L2 fields: resolve from user's destination choice
566
+ if (dest === null) {
567
+ if (!destinationChoice || destinationChoice === "Cancel") return "skip";
568
+ dest = destinationChoice.startsWith("User") ? "prefs" : "project";
569
+ }
570
+
571
+ // Confirmation gate for project config writes
572
+ if (dest === "project" && !projectConfirmed) return "skip";
573
+
574
+ return dest;
575
+ }
576
+
577
+
578
+ // ── Source Detection ─────────────────────────────────────────────────
579
+
580
+ /**
581
+ * Get a nested value from an object by dot-path.
582
+ * e.g., getNestedValue(obj, "orchestrator.orchestrator.maxLanes")
583
+ */
584
+ function getNestedValue(obj: any, path: string): any {
585
+ const parts = path.split(".");
586
+ let current = obj;
587
+ for (const part of parts) {
588
+ if (current === null || current === undefined || typeof current !== "object") return undefined;
589
+ current = current[part];
590
+ }
591
+ return current;
592
+ }
593
+
594
+ /**
595
+ * Determine the source of a field's current value.
596
+ *
597
+ * Implements the source-badge rules from Step 1:
598
+ * - For L1+L2 fields: check user prefs first (type-specific "is set" rules)
599
+ * - Then check raw project config
600
+ * - Fallback to default
601
+ */
602
+ export function detectFieldSource(
603
+ field: FieldDef,
604
+ rawProjectConfig: Record<string, any> | null,
605
+ rawPrefs: Record<string, any> | null,
606
+ ): FieldSource {
607
+ // L2 check for dual-layer and L2-only fields.
608
+ // Type guards MUST match extractAllowlistedPreferences() in config-loader.ts
609
+ // to avoid showing "(user)" for values that the merge layer would reject.
610
+ if ((field.layer === "L1+L2" || field.layer === "L2") && field.prefsKey && rawPrefs) {
611
+ const prefVal = rawPrefs[field.prefsKey];
612
+ if (field.fieldType === "string") {
613
+ // String rule: must be typeof string, non-empty → (user)
614
+ // Matches: `typeof raw.X === "string"` AND applyUserPreferences `val !== "" `
615
+ if (typeof prefVal === "string" && prefVal !== "") return "user";
616
+ } else if (field.fieldType === "enum") {
617
+ // Enum rule: must be a valid enum value from the field's values array.
618
+ // Matches extractAllowlistedPreferences which checks exact enum membership
619
+ // (e.g., raw.spawnMode === "tmux" || raw.spawnMode === "subprocess").
620
+ if (prefVal !== undefined && field.values && field.values.includes(String(prefVal))) return "user";
621
+ } else if (field.fieldType === "number") {
622
+ // Number rule: must be typeof number and finite → (user)
623
+ // Matches: `typeof raw.X === "number" && Number.isFinite(raw.X)`
624
+ if (typeof prefVal === "number" && Number.isFinite(prefVal)) return "user";
625
+ }
626
+ }
627
+
628
+ // L2-only fields have no project layer
629
+ if (field.layer === "L2") return "default";
630
+
631
+ // L1 check: look in raw project config
632
+ if (rawProjectConfig) {
633
+ const val = getNestedValue(rawProjectConfig, field.configPath);
634
+ if (val !== undefined) return "project";
635
+ }
636
+
637
+ return "default";
638
+ }
639
+
640
+
641
+ // ── Value Formatting ─────────────────────────────────────────────────
642
+
643
+ /**
644
+ * Get the display value for a field from the merged config.
645
+ */
646
+ export function getFieldDisplayValue(
647
+ field: FieldDef,
648
+ mergedConfig: TaskplaneConfig,
649
+ prefs: UserPreferences,
650
+ ): string {
651
+ // Special case: dashboardPort (L2-only, not in merged config)
652
+ if (field.configPath === "preferences.dashboardPort") {
653
+ const val = prefs.dashboardPort;
654
+ return val !== undefined ? String(val) : "(not set)";
655
+ }
656
+
657
+ const val = getNestedValue(mergedConfig, field.configPath);
658
+
659
+ // Optional fields may be undefined
660
+ if (val === undefined) {
661
+ if (field.optional && field.configPath === "taskRunner.worker.spawnMode") {
662
+ return "(inherit)";
663
+ }
664
+ return "(not set)";
665
+ }
666
+
667
+ // Boolean fields: show "true"/"false"
668
+ if (field.fieldType === "boolean") {
669
+ return String(val);
670
+ }
671
+
672
+ return String(val);
673
+ }
674
+
675
+
676
+ // ── Validation ───────────────────────────────────────────────────────
677
+
678
+ export interface ValidationResult {
679
+ valid: boolean;
680
+ error?: string;
681
+ }
682
+
683
+ /**
684
+ * Validate a user-entered value for a field.
685
+ */
686
+ export function validateFieldInput(field: FieldDef, input: string): ValidationResult {
687
+ // Empty input for optional fields = unset
688
+ if (input.trim() === "" && field.optional) {
689
+ return { valid: true };
690
+ }
691
+
692
+ // Empty input for required fields
693
+ if (input.trim() === "" && !field.optional) {
694
+ // String fields allow empty (e.g., model = "" means inherit)
695
+ if (field.fieldType === "string") return { valid: true };
696
+ return { valid: false, error: "Value required" };
697
+ }
698
+
699
+ switch (field.fieldType) {
700
+ case "number": {
701
+ const num = Number(input.trim());
702
+ if (!Number.isFinite(num) || num <= 0) {
703
+ return { valid: false, error: "Must be a positive integer" };
704
+ }
705
+ // Integer check for most number fields
706
+ if (!Number.isInteger(num)) {
707
+ return { valid: false, error: "Must be a whole number" };
708
+ }
709
+ return { valid: true };
710
+ }
711
+ case "enum": {
712
+ if (field.values && !field.values.includes(input.trim())) {
713
+ return { valid: false, error: `Must be one of: ${field.values.join(", ")}` };
714
+ }
715
+ return { valid: true };
716
+ }
717
+ case "string":
718
+ return { valid: true };
719
+ case "boolean": {
720
+ if (input.trim() !== "true" && input.trim() !== "false") {
721
+ return { valid: false, error: "Must be true or false" };
722
+ }
723
+ return { valid: true };
724
+ }
725
+ default:
726
+ return { valid: true };
727
+ }
728
+ }
729
+
730
+
731
+ // ── Advanced Section Items ───────────────────────────────────────────
732
+
733
+ export interface AdvancedItem {
734
+ label: string;
735
+ value: string;
736
+ configPath: string;
737
+ }
738
+
739
+ /**
740
+ * Build a Set of all config paths that are covered by editable sections.
741
+ * Used to detect "uncovered" paths for the Advanced section.
742
+ */
743
+ function buildCoveredPaths(): Set<string> {
744
+ const covered = new Set<string>();
745
+ for (const section of SECTIONS) {
746
+ for (const field of section.fields) {
747
+ covered.add(field.configPath);
748
+ }
749
+ }
750
+ // Also mark the preferences-only path
751
+ covered.add("preferences.dashboardPort");
752
+ return covered;
753
+ }
754
+
755
+ /** Cached set of editable config paths */
756
+ const COVERED_PATHS = buildCoveredPaths();
757
+
758
+ /**
759
+ * Convert a dot-path to a human-readable label.
760
+ * e.g., "taskRunner.project.name" → "Project Name"
761
+ * "orchestrator.preWarm.commands" → "Pre-Warm Commands"
762
+ */
763
+ function pathToLabel(path: string): string {
764
+ const parts = path.split(".");
765
+ // Take the last 1-2 meaningful segments (skip top-level "taskRunner"/"orchestrator")
766
+ const meaningful = parts.slice(1); // Drop "taskRunner"/"orchestrator"/"configVersion"
767
+ if (meaningful.length === 0) {
768
+ // Top-level like "configVersion"
769
+ return camelToTitle(parts[parts.length - 1]);
770
+ }
771
+ // For nested paths like "project.name", use last 2 segments if parent is a grouping
772
+ if (meaningful.length >= 2) {
773
+ return `${camelToTitle(meaningful[meaningful.length - 2])} ${camelToTitle(meaningful[meaningful.length - 1])}`;
774
+ }
775
+ return camelToTitle(meaningful[0]);
776
+ }
777
+
778
+ /** Convert camelCase to Title Case (e.g., "maxLanes" → "Max Lanes") */
779
+ function camelToTitle(str: string): string {
780
+ return str
781
+ .replace(/([A-Z])/g, " $1")
782
+ .replace(/^./, (s) => s.toUpperCase())
783
+ .trim();
784
+ }
785
+
786
+ /**
787
+ * Get display items for the Advanced (JSON Only) section.
788
+ *
789
+ * Dynamically discovers all config paths NOT covered by editable sections
790
+ * by recursively walking the merged config object. This ensures new fields
791
+ * added to the schema are automatically surfaced for discoverability.
792
+ */
793
+ export function getAdvancedItems(config: TaskplaneConfig): AdvancedItem[] {
794
+ const items: AdvancedItem[] = [];
795
+
796
+ // Walk the config object and collect uncovered leaf paths
797
+ walkConfig(config, "", (path, value) => {
798
+ if (COVERED_PATHS.has(path)) return; // Skip editable fields
799
+
800
+ const label = pathToLabel(path);
801
+ const display = summarizeValue(value);
802
+ items.push({ label, value: display, configPath: path });
803
+ });
804
+
805
+ return items;
806
+ }
807
+
808
+ /**
809
+ * Recursively walk a config object, calling the visitor for each "leaf" field.
810
+ *
811
+ * A "leaf" is either:
812
+ * - A primitive (string, number, boolean)
813
+ * - An array
814
+ * - A Record/object that is a "data container" (not a known config subsection)
815
+ *
816
+ * Known subsection objects (like `taskRunner.worker`, `orchestrator.merge`)
817
+ * are recursed into, not reported as leaves themselves.
818
+ */
819
+ function walkConfig(
820
+ obj: any,
821
+ prefix: string,
822
+ visitor: (path: string, value: any) => void,
823
+ ): void {
824
+ if (obj === null || obj === undefined) return;
825
+
826
+ for (const [key, value] of Object.entries(obj)) {
827
+ const path = prefix ? `${prefix}.${key}` : key;
828
+
829
+ if (Array.isArray(value)) {
830
+ // Arrays are leaf items (e.g., verify, docs, rules, neverLoad)
831
+ visitor(path, value);
832
+ } else if (typeof value === "object" && value !== null) {
833
+ // Determine if this is a "config subsection" to recurse into,
834
+ // or a "data Record" to report as a leaf.
835
+ // Config subsections have known typed structure; data Records
836
+ // are user-defined key-value maps.
837
+ if (isConfigSubsection(path)) {
838
+ walkConfig(value, path, visitor);
839
+ } else {
840
+ // Data Record — report as leaf
841
+ visitor(path, value);
842
+ }
843
+ } else {
844
+ // Primitive — report as leaf
845
+ visitor(path, value);
846
+ }
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Known config subsection paths that should be recursed into
852
+ * (not reported as Advanced items themselves).
853
+ *
854
+ * This list is derived from the TaskplaneConfig interface structure.
855
+ * When the schema adds a new top-level subsection, add it here to
856
+ * recurse properly. Unknown subsections default to being treated as
857
+ * data Records (shown in Advanced), which is the safe default for
858
+ * discoverability.
859
+ */
860
+ const CONFIG_SUBSECTIONS = new Set([
861
+ "taskRunner",
862
+ "orchestrator",
863
+ "taskRunner.project",
864
+ "taskRunner.paths",
865
+ "taskRunner.testing",
866
+ "taskRunner.standards",
867
+ "taskRunner.worker",
868
+ "taskRunner.reviewer",
869
+ "taskRunner.context",
870
+ "orchestrator.orchestrator",
871
+ "orchestrator.dependencies",
872
+ "orchestrator.assignment",
873
+ "orchestrator.preWarm",
874
+ "orchestrator.merge",
875
+ "orchestrator.failure",
876
+ "orchestrator.monitoring",
877
+ ]);
878
+
879
+ function isConfigSubsection(path: string): boolean {
880
+ return CONFIG_SUBSECTIONS.has(path);
881
+ }
882
+
883
+ /**
884
+ * Summarize a value for display in the Advanced section.
885
+ */
886
+ function summarizeValue(value: any): string {
887
+ if (value === undefined || value === null) return "(not set)";
888
+ if (typeof value === "string") return value || "(empty)";
889
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
890
+ if (Array.isArray(value)) return summarizeArray(value);
891
+ if (typeof value === "object") return summarizeRecord(value);
892
+ return String(value);
893
+ }
894
+
895
+ function summarizeRecord(obj: Record<string, any>): string {
896
+ const keys = Object.keys(obj);
897
+ if (keys.length === 0) return "(empty)";
898
+ if (keys.length <= 3) return keys.join(", ");
899
+ return `${keys.length} entries`;
900
+ }
901
+
902
+ function summarizeArray(arr: any[]): string {
903
+ if (arr.length === 0) return "(empty)";
904
+ if (arr.length <= 3) return arr.map(String).join(", ");
905
+ return `${arr.length} items`;
906
+ }
907
+
908
+
909
+ // ── TUI Rendering ────────────────────────────────────────────────────
910
+
911
+ /**
912
+ * Open the settings TUI.
913
+ *
914
+ * This is the main entry point called from the /taskplane-settings command handler.
915
+ * Uses a two-level navigation:
916
+ * 1. SelectList for section navigation
917
+ * 2. SettingsList for per-section field display and editing
918
+ *
919
+ * @param ctx - Extension context for UI access
920
+ * @param configRoot - Workspace/repo root (from execCtx.workspaceRoot)
921
+ * @param pointerConfigRoot - Optional pointer-resolved config root (workspace mode)
922
+ */
923
+ export async function openSettingsTui(
924
+ ctx: ExtensionContext,
925
+ configRoot: string,
926
+ pointerConfigRoot?: string,
927
+ ): Promise<void> {
928
+ // Load current config state — refreshed each time we return to the top level
929
+ await showSectionSelectorLoop(ctx, configRoot, pointerConfigRoot);
930
+ }
931
+
932
+ /**
933
+ * Reload all config state from disk. Called after write-back to
934
+ * refresh the TUI display.
935
+ */
936
+ function loadConfigState(configRoot: string, pointerConfigRoot?: string): {
937
+ mergedConfig: TaskplaneConfig;
938
+ prefs: UserPreferences;
939
+ rawProject: Record<string, any> | null;
940
+ rawPrefs: Record<string, any> | null;
941
+ } {
942
+ const resolvedRoot = resolveConfigRoot(configRoot, pointerConfigRoot);
943
+ return {
944
+ mergedConfig: loadProjectConfig(configRoot, pointerConfigRoot),
945
+ prefs: loadUserPreferences(),
946
+ rawProject: readRawProjectJson(resolvedRoot) || readRawYamlConfigs(resolvedRoot),
947
+ rawPrefs: readRawPreferences(),
948
+ };
949
+ }
950
+
951
+ /**
952
+ * Top-level section selector loop.
953
+ *
954
+ * Re-loads config state each iteration so write-backs are reflected
955
+ * immediately in the TUI.
956
+ */
957
+ async function showSectionSelectorLoop(
958
+ ctx: ExtensionContext,
959
+ configRoot: string,
960
+ pointerConfigRoot?: string,
961
+ ): Promise<void> {
962
+ while (true) {
963
+ const state = loadConfigState(configRoot, pointerConfigRoot);
964
+
965
+ const sectionItems: SelectItem[] = SECTIONS.map((section, i) => ({
966
+ value: String(i),
967
+ label: section.name,
968
+ description: section.readOnly
969
+ ? "Read-only collection/record fields"
970
+ : `${section.fields.length} setting${section.fields.length === 1 ? "" : "s"}`,
971
+ }));
972
+
973
+ const selectedSection = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
974
+ const container = new Container();
975
+
976
+ // Top border
977
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
978
+
979
+ // Title
980
+ container.addChild(new Text(theme.fg("accent", theme.bold("⚙ Settings")), 1, 0));
981
+ container.addChild(new Text(theme.fg("dim", "Navigate sections to view and edit configuration"), 1, 0));
982
+ container.addChild(new Text("", 0, 0));
983
+
984
+ // SelectList
985
+ const selectList = new SelectList(sectionItems, Math.min(sectionItems.length, 14), {
986
+ selectedPrefix: (t) => theme.fg("accent", t),
987
+ selectedText: (t) => theme.fg("accent", t),
988
+ description: (t) => theme.fg("muted", t),
989
+ scrollInfo: (t) => theme.fg("dim", t),
990
+ noMatch: (t) => theme.fg("warning", t),
991
+ });
992
+ selectList.onSelect = (item) => done(item.value);
993
+ selectList.onCancel = () => done(null);
994
+ container.addChild(selectList);
995
+
996
+ // Help text
997
+ container.addChild(new Text("", 0, 0));
998
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc close"), 1, 0));
999
+
1000
+ // Bottom border
1001
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1002
+
1003
+ return {
1004
+ render: (w: number) => container.render(w),
1005
+ invalidate: () => container.invalidate(),
1006
+ handleInput: (data: string) => { selectList.handleInput(data); tui.requestRender(); },
1007
+ };
1008
+ });
1009
+
1010
+ if (selectedSection === null) return; // User pressed Esc
1011
+
1012
+ const sectionIndex = parseInt(selectedSection, 10);
1013
+ const section = SECTIONS[sectionIndex];
1014
+
1015
+ if (section.readOnly) {
1016
+ await showAdvancedSection(ctx, state.mergedConfig);
1017
+ } else {
1018
+ await showSectionSettingsLoop(ctx, section, configRoot, pointerConfigRoot);
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Show the Advanced (JSON Only) section — read-only display.
1025
+ */
1026
+ async function showAdvancedSection(
1027
+ ctx: ExtensionContext,
1028
+ mergedConfig: TaskplaneConfig,
1029
+ ): Promise<void> {
1030
+ const advItems = getAdvancedItems(mergedConfig);
1031
+
1032
+ const settingsItems: SettingItem[] = advItems.map((item) => ({
1033
+ id: item.configPath,
1034
+ label: item.label,
1035
+ currentValue: item.value,
1036
+ description: `${item.configPath} — edit in .pi/taskplane-config.json`,
1037
+ // No `values` array = no toggle cycling
1038
+ }));
1039
+
1040
+ await ctx.ui.custom((tui, theme, _kb, done) => {
1041
+ const container = new Container();
1042
+
1043
+ // Top border
1044
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1045
+
1046
+ // Title
1047
+ container.addChild(new Text(theme.fg("accent", theme.bold("Advanced (JSON Only)")), 1, 0));
1048
+ container.addChild(new Text(theme.fg("dim", "These fields can only be edited directly in the config file"), 1, 0));
1049
+ container.addChild(new Text("", 0, 0));
1050
+
1051
+ const settingsList = new SettingsList(
1052
+ settingsItems,
1053
+ Math.min(settingsItems.length + 2, 20),
1054
+ getSettingsListTheme(),
1055
+ () => {}, // onChange — no-op (read-only)
1056
+ () => done(undefined), // onCancel
1057
+ );
1058
+ container.addChild(settingsList);
1059
+
1060
+ // Help text
1061
+ container.addChild(new Text("", 0, 0));
1062
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate • esc back"), 1, 0));
1063
+
1064
+ // Bottom border
1065
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1066
+
1067
+ return {
1068
+ render: (w: number) => container.render(w),
1069
+ invalidate: () => container.invalidate(),
1070
+ handleInput: (data: string) => { settingsList.handleInput?.(data); tui.requestRender(); },
1071
+ };
1072
+ });
1073
+ }
1074
+
1075
+ /**
1076
+ * Format a source badge for display.
1077
+ */
1078
+ function formatSourceBadge(source: FieldSource): string {
1079
+ switch (source) {
1080
+ case "default": return "(default)";
1081
+ case "project": return "(project)";
1082
+ case "user": return "(user)";
1083
+ }
1084
+ }
1085
+
1086
+ /** Represents a pending field change returned from the section TUI. */
1087
+ interface PendingChange {
1088
+ fieldId: string;
1089
+ rawValue: string;
1090
+ }
1091
+
1092
+ /**
1093
+ * Section settings loop — shows the section, handles writes, and
1094
+ * re-renders with fresh state after each successful write.
1095
+ */
1096
+ async function showSectionSettingsLoop(
1097
+ ctx: ExtensionContext,
1098
+ section: SectionDef,
1099
+ configRoot: string,
1100
+ pointerConfigRoot?: string,
1101
+ ): Promise<void> {
1102
+ while (true) {
1103
+ const state = loadConfigState(configRoot, pointerConfigRoot);
1104
+ const result = await showSectionSettingsOnce(ctx, section, state.mergedConfig, state.prefs, state.rawProject, state.rawPrefs);
1105
+
1106
+ if (result === null) return; // User pressed Esc → back to sections
1107
+
1108
+ // Process the pending change
1109
+ const field = section.fields.find((f) => f.configPath === result.fieldId);
1110
+ if (!field) continue; // Safety: field not found
1111
+
1112
+ const typedValue = coerceValueForWrite(field, result.rawValue);
1113
+
1114
+ // Collect UI answers for the write-decision contract
1115
+ let destinationChoice: string | null = null;
1116
+ if (getDefaultWriteDestination(field) === null) {
1117
+ // L1+L2 fields: ask user where to save
1118
+ destinationChoice = await ctx.ui.select(
1119
+ "Save this change to:",
1120
+ [
1121
+ "User preferences (personal)",
1122
+ "Project config (shared)",
1123
+ "Cancel",
1124
+ ],
1125
+ );
1126
+ }
1127
+
1128
+ let projectConfirmed = true;
1129
+ // Only ask for confirmation if the resolved dest will be "project"
1130
+ const needsProjectConfirm =
1131
+ (field.layer === "L1") ||
1132
+ (field.layer === "L1+L2" && destinationChoice?.startsWith("Project"));
1133
+ if (needsProjectConfirm) {
1134
+ projectConfirmed = await ctx.ui.confirm(
1135
+ "Confirm project config change",
1136
+ "This writes to .pi/taskplane-config.json (shared project config). Continue?",
1137
+ );
1138
+ }
1139
+
1140
+ const dest = resolveWriteAction(field, destinationChoice, projectConfirmed);
1141
+ if (dest === "skip") continue;
1142
+
1143
+ // Perform the write
1144
+ try {
1145
+ if (dest === "project") {
1146
+ writeProjectConfigField(configRoot, field.configPath, typedValue, pointerConfigRoot);
1147
+ } else {
1148
+ // L2 write — use prefsKey
1149
+ if (field.prefsKey) {
1150
+ writeUserPreference(field.prefsKey, typedValue);
1151
+ }
1152
+ }
1153
+ ctx.ui.notify(
1154
+ `✅ ${field.label} updated.\n` +
1155
+ `ℹ Restart session to apply changes.`,
1156
+ "info",
1157
+ );
1158
+ } catch (err: any) {
1159
+ ctx.ui.notify(`❌ Failed to save: ${err.message}`, "error");
1160
+ }
1161
+
1162
+ // Loop continues → re-show section with fresh state
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Show the settings list for a section once.
1168
+ *
1169
+ * Returns a PendingChange when the user edits a field (toggle or input),
1170
+ * or null when the user presses Esc to go back.
1171
+ *
1172
+ * Design: the TUI exits after any change so the caller can handle
1173
+ * confirmation/destination choice with standard ctx.ui methods,
1174
+ * then re-renders with fresh state.
1175
+ */
1176
+ async function showSectionSettingsOnce(
1177
+ ctx: ExtensionContext,
1178
+ section: SectionDef,
1179
+ mergedConfig: TaskplaneConfig,
1180
+ prefs: UserPreferences,
1181
+ rawProject: Record<string, any> | null,
1182
+ rawPrefs: Record<string, any> | null,
1183
+ ): Promise<PendingChange | null> {
1184
+ // Build SettingItem[] from section fields
1185
+ const settingsItems: SettingItem[] = section.fields.map((field) => {
1186
+ const displayValue = getFieldDisplayValue(field, mergedConfig, prefs);
1187
+ const source = detectFieldSource(field, rawProject, rawPrefs);
1188
+ const sourceBadge = formatSourceBadge(source);
1189
+
1190
+ const item: SettingItem = {
1191
+ id: field.configPath,
1192
+ label: field.label,
1193
+ currentValue: `${displayValue} ${sourceBadge}`,
1194
+ description: field.description,
1195
+ };
1196
+
1197
+ // Toggle fields get values array for cycling
1198
+ if (field.control === "toggle" && field.values) {
1199
+ item.values = field.values.map((v) => `${v} ${sourceBadge}`);
1200
+ }
1201
+
1202
+ // Input fields get a submenu for inline editing
1203
+ if (field.control === "input") {
1204
+ item.submenu = (currentValue: string, submenuDone: (selectedValue?: string) => void) => {
1205
+ return createInputSubmenu(field, currentValue, submenuDone);
1206
+ };
1207
+ }
1208
+
1209
+ return item;
1210
+ });
1211
+
1212
+ // Find JSON-only fields for this section's config path (footer note)
1213
+ const jsonOnlyNote = getJsonOnlyFooterForSection(section, mergedConfig);
1214
+
1215
+ return ctx.ui.custom<PendingChange | null>((tui, theme, _kb, done) => {
1216
+ const container = new Container();
1217
+
1218
+ // Top border
1219
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1220
+
1221
+ // Title
1222
+ container.addChild(new Text(theme.fg("accent", theme.bold(section.name)), 1, 0));
1223
+ container.addChild(new Text("", 0, 0));
1224
+
1225
+ const settingsList = new SettingsList(
1226
+ settingsItems,
1227
+ Math.min(settingsItems.length + 2, 20),
1228
+ getSettingsListTheme(),
1229
+ (id, newValue) => {
1230
+ // onChange: a toggle was cycled or an input was submitted
1231
+ // Exit TUI with the change so the caller can handle write-back
1232
+ done({ fieldId: id, rawValue: newValue });
1233
+ },
1234
+ () => done(null), // onCancel → back to section selector
1235
+ { enableSearch: settingsItems.length > 5 },
1236
+ );
1237
+ container.addChild(settingsList);
1238
+
1239
+ // JSON-only footer note
1240
+ if (jsonOnlyNote) {
1241
+ container.addChild(new Text("", 0, 0));
1242
+ container.addChild(new Text(theme.fg("dim", jsonOnlyNote), 1, 0));
1243
+ }
1244
+
1245
+ // Help text
1246
+ container.addChild(new Text("", 0, 0));
1247
+ container.addChild(new Text(
1248
+ theme.fg("dim", "↑↓ navigate • ←→/space cycle • enter edit • esc back"),
1249
+ 1, 0,
1250
+ ));
1251
+
1252
+ // Bottom border
1253
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1254
+
1255
+ return {
1256
+ render: (w: number) => container.render(w),
1257
+ invalidate: () => container.invalidate(),
1258
+ handleInput: (data: string) => { settingsList.handleInput?.(data); tui.requestRender(); },
1259
+ };
1260
+ });
1261
+ }
1262
+
1263
+
1264
+ // ── Input Submenu ────────────────────────────────────────────────────
1265
+
1266
+ /**
1267
+ * Create a submenu component for inline text input editing.
1268
+ * Used by SettingsList's submenu pattern for input-type fields.
1269
+ */
1270
+ function createInputSubmenu(
1271
+ field: FieldDef,
1272
+ currentValue: string,
1273
+ done: (selectedValue?: string) => void,
1274
+ ): any {
1275
+ // Strip source badge from current value for editing
1276
+ const cleanValue = currentValue.replace(/\s+\((?:default|project|user)\)$/, "");
1277
+ let inputBuffer = cleanValue === "(not set)" || cleanValue === "(inherit)" ? "" : cleanValue;
1278
+ let errorMsg = "";
1279
+ let cursorPos = inputBuffer.length;
1280
+
1281
+ const component = {
1282
+ render(width: number): string[] {
1283
+ const lines: string[] = [];
1284
+ const prompt = ` Enter ${field.label}: `;
1285
+ const inputDisplay = inputBuffer + "█"; // Simple cursor
1286
+ lines.push(truncateLine(prompt + inputDisplay, width));
1287
+
1288
+ if (field.optional) {
1289
+ lines.push(truncateLine(" (empty to unset)", width));
1290
+ }
1291
+
1292
+ if (errorMsg) {
1293
+ lines.push(truncateLine(` ❌ ${errorMsg}`, width));
1294
+ }
1295
+
1296
+ lines.push(truncateLine(" enter confirm • esc cancel", width));
1297
+ return lines;
1298
+ },
1299
+
1300
+ invalidate() {},
1301
+
1302
+ handleInput(data: string): void {
1303
+ // Simple input handling — enter, escape, backspace, printable chars
1304
+ if (data === "\r" || data === "\n") {
1305
+ // Validate and confirm
1306
+ const result = validateFieldInput(field, inputBuffer);
1307
+ if (result.valid) {
1308
+ if (inputBuffer.trim() === "" && field.optional) {
1309
+ done("(not set)");
1310
+ } else {
1311
+ done(inputBuffer);
1312
+ }
1313
+ } else {
1314
+ errorMsg = result.error || "Invalid input";
1315
+ }
1316
+ } else if (data === "\x1b" || data === "\x1b\x1b") {
1317
+ // Escape — cancel
1318
+ done(undefined);
1319
+ } else if (data === "\x7f" || data === "\b") {
1320
+ // Backspace
1321
+ if (inputBuffer.length > 0) {
1322
+ inputBuffer = inputBuffer.slice(0, -1);
1323
+ errorMsg = "";
1324
+ }
1325
+ } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
1326
+ // Printable character
1327
+ inputBuffer += data;
1328
+ errorMsg = "";
1329
+ }
1330
+ },
1331
+ };
1332
+
1333
+ return component;
1334
+ }
1335
+
1336
+ /** Simple line truncation for submenu rendering */
1337
+ function truncateLine(text: string, width: number): string {
1338
+ if (text.length <= width) return text;
1339
+ return text.substring(0, width - 3) + "...";
1340
+ }
1341
+
1342
+
1343
+ // ── JSON-Only Footer ─────────────────────────────────────────────────
1344
+
1345
+ /**
1346
+ * Map from section name to the config subsection prefixes it covers.
1347
+ * Used to dynamically discover JSON-only sibling fields.
1348
+ */
1349
+ const SECTION_CONFIG_PREFIXES: Record<string, string[]> = {
1350
+ "Orchestrator": ["orchestrator.orchestrator"],
1351
+ "Dependencies": ["orchestrator.dependencies"],
1352
+ "Assignment": ["orchestrator.assignment"],
1353
+ "Pre-Warm": ["orchestrator.preWarm"],
1354
+ "Merge": ["orchestrator.merge"],
1355
+ "Failure Policy": ["orchestrator.failure"],
1356
+ "Monitoring": ["orchestrator.monitoring"],
1357
+ "Worker": ["taskRunner.worker"],
1358
+ "Reviewer": ["taskRunner.reviewer"],
1359
+ "Context Limits": ["taskRunner.context"],
1360
+ };
1361
+
1362
+ /**
1363
+ * Generate a footer note about JSON-only fields related to a section.
1364
+ *
1365
+ * Dynamically discovers uncovered fields under the same config subsection
1366
+ * prefix, so new fields added to the schema auto-appear in footers.
1367
+ */
1368
+ function getJsonOnlyFooterForSection(section: SectionDef, config: TaskplaneConfig): string | null {
1369
+ const prefixes = SECTION_CONFIG_PREFIXES[section.name];
1370
+ if (!prefixes) return null;
1371
+
1372
+ // Find all uncovered leaf fields under these prefixes
1373
+ const uncoveredFields: string[] = [];
1374
+ walkConfig(config, "", (path, _value) => {
1375
+ if (COVERED_PATHS.has(path)) return; // Already editable
1376
+ for (const prefix of prefixes) {
1377
+ if (path.startsWith(prefix + ".")) {
1378
+ // Extract the field name (last segment)
1379
+ const fieldName = path.split(".").pop() || path;
1380
+ uncoveredFields.push(fieldName);
1381
+ }
1382
+ }
1383
+ });
1384
+
1385
+ if (uncoveredFields.length === 0) return null;
1386
+ return `+ ${uncoveredFields.join(", ")} (edit JSON directly)`;
1387
+ }