pi-blackhole 0.4.2 → 0.4.3

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 (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -411
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. package/vitest.config.ts +15 -15
@@ -0,0 +1,1648 @@
1
+ /**
2
+ * `SettingsModalBody` — the renderable+inputtable component that
3
+ * `createSettingsModal` and `openSettingsModal` mount inside an
4
+ * overlay. Owns:
5
+ *
6
+ * - Tab strip across the top (when `tabs` is non-empty).
7
+ * - Optional fuzzy search bar (when `enableSearch` is true).
8
+ * - The list of rows, with one focused at a time.
9
+ * - Per-row inline-edit state (string/number/secret/path).
10
+ * - Submenu mounting (enum long-list, model widget, custom openSubmenu).
11
+ * - Auto-generated footer hint reflecting the focused row's keybindings.
12
+ *
13
+ * The modal is **stateless about disk** — it calls `onChange` on every
14
+ * commit and lets the caller persist however they like. In buffered
15
+ * mode (`options.mode === "buffered"`), the modal holds edits in
16
+ * memory and persists only on explicit save via `options.onSave`.
17
+ */
18
+
19
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
20
+ import {
21
+ matchesKey,
22
+ truncateToWidth,
23
+ visibleWidth,
24
+ type Component,
25
+ type TUI,
26
+ } from "@earendil-works/pi-tui";
27
+ import { loadConfig, getExtensionsDir } from "../config.ts";
28
+ import { validateFieldValue } from "./validate-field.ts";
29
+ import type {
30
+ Field,
31
+ FieldKeyHint,
32
+ FieldRenderContext,
33
+ SettingsModalOptions,
34
+ Tab,
35
+ VisibilityContext,
36
+ } from "./types";
37
+ import { RENDERERS } from "./fields/index";
38
+ import {
39
+ divider,
40
+ formatHintLine,
41
+ frame,
42
+ frameContentWidth,
43
+ pad,
44
+ responsiveInnerRows,
45
+ wrapLine,
46
+ type FrameOptions,
47
+ } from "./frame";
48
+ import { deleteWordBackward, type InlineEditState } from "./inline-edit";
49
+
50
+ const PREFERRED_INNER_ROWS = 30;
51
+ const LABEL_PAD_TARGET = 28;
52
+
53
+ interface InternalRow {
54
+ field: Field;
55
+ /** Live displayed value, written back through `onChange`. */
56
+ value: unknown;
57
+ globalValue?: unknown;
58
+ projectValue?: unknown;
59
+ /** Inline-edit toggle for editable types. */
60
+ isEditing: boolean;
61
+ /** Pre-computed lowercased search index string for fuzzy filtering. */
62
+ searchIndex: string;
63
+ /** Pre-computed truncated label text. */
64
+ labelText: string;
65
+ /** Pre-computed label padding. */
66
+ labelPadding: string;
67
+ }
68
+
69
+ interface ConfirmSubmenuState {
70
+ selectedIndex: number;
71
+ dirtyKeys: Set<string>;
72
+ initialValues: Map<string, unknown>;
73
+ }
74
+
75
+ interface ConfirmOption {
76
+ label: string;
77
+ action: "save-global" | "save-project" | "discard" | "cancel";
78
+ }
79
+
80
+ const confirmOptions: ConfirmOption[] = [
81
+ { label: "Save to Global", action: "save-global" },
82
+ { label: "Save to Project Local", action: "save-project" },
83
+ { label: "Discard", action: "discard" },
84
+ { label: "Cancel", action: "cancel" },
85
+ ];
86
+
87
+ /**
88
+ * Build the body component. The factory is independent of overlay
89
+ * lifecycle — `createSettingsModal` (in modal.ts) wraps it for the
90
+ * `ctx.ui.custom` shape; advanced callers can mount the body directly.
91
+ */
92
+ export function createSettingsModalBody<F extends Field>(
93
+ options: SettingsModalOptions<F>,
94
+ args: {
95
+ tui: TUI;
96
+ theme: Theme;
97
+ ctx: ExtensionContext;
98
+ /** Called when the user closes (Esc / ctrl+c / outer dismissal). */
99
+ close: () => void;
100
+ },
101
+ ): Component {
102
+ const useScopeTabs = options.configFilename !== undefined;
103
+ const tabs: Tab[] = useScopeTabs
104
+ ? [
105
+ { id: "global", label: "Global" },
106
+ { id: "project", label: "Project Local" },
107
+ ]
108
+ : (options.tabs ?? []);
109
+ const fields: Field[] = options.fields as Field[];
110
+ const mode = options.mode ?? "immediate";
111
+ const isBuffered = mode === "buffered";
112
+
113
+ let globalConfig: Record<string, unknown> = {};
114
+ let projectConfig: Record<string, unknown> = {};
115
+ if (useScopeTabs && options.configFilename) {
116
+ const fn = options.configFilename;
117
+ const defs = options.defaults ?? {};
118
+ const globalDir = options.globalConfigDir ?? getExtensionsDir();
119
+ globalConfig = loadConfig(fn, defs, { configDir: globalDir });
120
+ projectConfig = loadConfig(fn, defs, {
121
+ cwd: args.ctx.cwd,
122
+ configDir: globalDir,
123
+ });
124
+ }
125
+
126
+ let activeTabId: string | undefined = options.initialTab ?? tabs[0]?.id;
127
+
128
+ // Per-row state lives in this array, indexed parallel to a snapshot
129
+ // of `fields`. We never reorder it — search filters use a separate
130
+ // `filteredIndices` view.
131
+ const rows: InternalRow[] = fields.map((field) => {
132
+ let globalVal: unknown;
133
+ let projectVal: unknown;
134
+
135
+ if (useScopeTabs) {
136
+ globalVal = getNestedValue(globalConfig, field.key);
137
+ if (globalVal === undefined) {
138
+ globalVal = getNestedValue(options.defaults ?? {}, field.key);
139
+ }
140
+ if (globalVal === undefined) {
141
+ globalVal = extractInitialValue(field);
142
+ }
143
+
144
+ projectVal = getNestedValue(projectConfig, field.key);
145
+ if (projectVal === undefined) {
146
+ projectVal = globalVal;
147
+ }
148
+ } else {
149
+ globalVal = extractInitialValue(field);
150
+ projectVal = globalVal;
151
+ }
152
+
153
+ const labelText = truncateToWidth(field.label, LABEL_PAD_TARGET, "…");
154
+ const labelPadding = " ".repeat(
155
+ Math.max(1, LABEL_PAD_TARGET - visibleWidth(labelText)),
156
+ );
157
+
158
+ return {
159
+ field,
160
+ get globalValue() {
161
+ return globalVal;
162
+ },
163
+ set globalValue(v) {
164
+ globalVal = v;
165
+ },
166
+ get projectValue() {
167
+ return projectVal;
168
+ },
169
+ set projectValue(v) {
170
+ projectVal = v;
171
+ },
172
+ get value() {
173
+ return activeTabId === "project" ? projectVal : globalVal;
174
+ },
175
+ set value(v) {
176
+ if (activeTabId === "project") {
177
+ projectVal = v;
178
+ } else {
179
+ globalVal = v;
180
+ }
181
+ },
182
+ isEditing: false,
183
+ searchIndex:
184
+ `${field.label}\n${field.description ?? ""}\n${field.key}`.toLowerCase(),
185
+ labelText,
186
+ labelPadding,
187
+ };
188
+ }) as unknown as InternalRow[];
189
+
190
+ // Inline-edit state registry: keyed by field.key. Renderers reach
191
+ // into this through `ctx.editStates` (see fields/string.ts).
192
+ const editStates: Map<string, InlineEditState> = new Map();
193
+
194
+ // Buffered mode: snapshot initial values and track dirty keys.
195
+ const initialGlobalValues = new Map<string, unknown>();
196
+ const initialProjectValues = new Map<string, unknown>();
197
+ const dirtyGlobalKeys = new Set<string>();
198
+ const dirtyProjectKeys = new Set<string>();
199
+
200
+ if (isBuffered) {
201
+ for (const row of rows) {
202
+ const gVal = row.globalValue;
203
+ initialGlobalValues.set(
204
+ row.field.key,
205
+ typeof gVal === "object" && gVal !== null
206
+ ? JSON.parse(JSON.stringify(gVal))
207
+ : gVal,
208
+ );
209
+ const pVal = row.projectValue;
210
+ initialProjectValues.set(
211
+ row.field.key,
212
+ typeof pVal === "object" && pVal !== null
213
+ ? JSON.parse(JSON.stringify(pVal))
214
+ : pVal,
215
+ );
216
+ }
217
+ }
218
+
219
+ function getInitialValues() {
220
+ return activeTabId === "project"
221
+ ? initialProjectValues
222
+ : initialGlobalValues;
223
+ }
224
+
225
+ function getDirtyKeys() {
226
+ return activeTabId === "project" ? dirtyProjectKeys : dirtyGlobalKeys;
227
+ }
228
+ let search = "";
229
+ let fieldSelected = 0;
230
+ let scroll = 0;
231
+ let tabActionFocus = -1;
232
+ let submenu: Component | undefined;
233
+ let submenuKey: string | undefined;
234
+ let confirmState: ConfirmSubmenuState | undefined;
235
+ let scopeActionConfirm:
236
+ { action: "reset" | "delete"; selectedIndex: number } | undefined;
237
+ // Scope default inferred once at mount via the extension's callback.
238
+ const defaultScope: "global" | "project" = (options.inferDefaultScope?.() ??
239
+ "global") as "global" | "project";
240
+
241
+ const fieldRenderContext: FieldRenderContext & {
242
+ editStates: Map<string, InlineEditState>;
243
+ } = {
244
+ theme: args.theme,
245
+ tui: args.tui,
246
+ ctx: args.ctx,
247
+ requestRender: () => args.tui.requestRender(),
248
+ editStates,
249
+ };
250
+
251
+ // Action rows (only visible at the bottom of scope tabs when callbacks are provided)
252
+ const actionRows: { id: string; label: string; description: string }[] = [];
253
+ if (useScopeTabs && (options.onResetScope || options.onDeleteScope)) {
254
+ if (options.onResetScope) {
255
+ actionRows.push({
256
+ id: "reset",
257
+ label: "Reset to defaults",
258
+ description:
259
+ "Remove all config keys for this scope — values fall back to defaults.",
260
+ });
261
+ }
262
+ if (options.onDeleteScope) {
263
+ actionRows.push({
264
+ id: "delete",
265
+ label: "Delete config",
266
+ description: "Remove the entire config file for this scope.",
267
+ });
268
+ }
269
+ }
270
+
271
+ // Optimization: Cache the visible row indices list to avoid redundant array
272
+ // allocations, lowercase conversions, and substring checks inside hot rendering
273
+ // and search filtering loops.
274
+ let cachedVisibleIndices: number[] = [];
275
+
276
+ function buildVisibilityContext(
277
+ _field: Field,
278
+ scope: string,
279
+ ): VisibilityContext {
280
+ return {
281
+ get: (key: string) => {
282
+ const r = rows.find((rr) => rr.field.key === key);
283
+ return r?.value;
284
+ },
285
+ getScoped: (key: string, targetScope?: string) => {
286
+ const r = rows.find((rr) => rr.field.key === key);
287
+ if (!r) return undefined;
288
+ if (!targetScope || targetScope === scope) return r.value;
289
+ return targetScope === "project" ? r.projectValue : r.globalValue;
290
+ },
291
+ scope,
292
+ };
293
+ }
294
+
295
+ function totalVisibleItems(): number {
296
+ return cachedVisibleIndices.length;
297
+ }
298
+
299
+ function updateVisibleIndices(): void {
300
+ const query = search.trim().toLowerCase();
301
+ const out: number[] = [];
302
+ for (let i = 0; i < rows.length; i += 1) {
303
+ const row = rows[i]!;
304
+ if (activeTabId !== undefined && tabs.length > 0 && !useScopeTabs) {
305
+ // When tabs are configured, fields without an explicit `tab` id
306
+ // surface only on the first tab — same convention as a default
307
+ // landing tab in browser-style chrome.
308
+ const fallbackTab = tabs[0]!.id;
309
+ const rowTab = row.field.tab ?? fallbackTab;
310
+ if (rowTab !== activeTabId) continue;
311
+ }
312
+ // visibleWhen: fields can conditionally hide based on sibling values
313
+ if (row.field.visibleWhen) {
314
+ const scope = activeTabId ?? "global";
315
+ if (!row.field.visibleWhen(buildVisibilityContext(row.field, scope)))
316
+ continue;
317
+ }
318
+ if (!query) {
319
+ out.push(i);
320
+ continue;
321
+ }
322
+ if (row.searchIndex.includes(query)) out.push(i);
323
+ }
324
+ cachedVisibleIndices = out;
325
+ }
326
+
327
+ // Compute the initial visible indices.
328
+ updateVisibleIndices();
329
+
330
+ function visibleRowIndices(): number[] {
331
+ return cachedVisibleIndices;
332
+ }
333
+
334
+ function clampSelection(visibleRows: number): void {
335
+ const count = totalVisibleItems();
336
+ fieldSelected = Math.max(
337
+ 0,
338
+ Math.min(fieldSelected, Math.max(0, count - 1)),
339
+ );
340
+ if (fieldSelected < scroll) scroll = fieldSelected;
341
+ else if (fieldSelected >= scroll + visibleRows)
342
+ scroll = fieldSelected - visibleRows + 1;
343
+ scroll = Math.max(0, Math.min(scroll, Math.max(0, count - visibleRows)));
344
+ }
345
+
346
+ function focusedIndex(): number | undefined {
347
+ const indices = visibleRowIndices();
348
+ if (indices.length === 0) return undefined;
349
+ const safe = Math.max(0, Math.min(fieldSelected, indices.length - 1));
350
+ return indices[safe];
351
+ }
352
+
353
+ function focusedRow(): InternalRow | undefined {
354
+ const idx = focusedIndex();
355
+ return idx === undefined ? undefined : rows[idx];
356
+ }
357
+
358
+ function isDirty(): boolean {
359
+ if (!isBuffered) return false;
360
+ return dirtyGlobalKeys.size > 0 || dirtyProjectKeys.size > 0;
361
+ }
362
+
363
+ /** Update dirty state for a key by comparing current value against
364
+ * the initial snapshot. Removes the key if the value has reverted
365
+ * to its original; adds it otherwise. */
366
+ function syncDirtyState(key: string): void {
367
+ if (!isBuffered) return;
368
+ const initial = getInitialValues().get(key);
369
+ const current = rows.find((r) => r.field.key === key)?.value;
370
+ const isClean =
371
+ typeof initial === "object" && initial !== null
372
+ ? JSON.stringify(current) === JSON.stringify(initial)
373
+ : current === initial;
374
+ if (isClean) {
375
+ getDirtyKeys().delete(key);
376
+ } else {
377
+ getDirtyKeys().add(key);
378
+ }
379
+ }
380
+
381
+ function commitValue(row: InternalRow, value: unknown): void {
382
+ const previous = row.value;
383
+ const key = row.field.key;
384
+
385
+ row.value = value;
386
+ if (isBuffered) {
387
+ syncDirtyState(key);
388
+ }
389
+
390
+ try {
391
+ const ret = options.onChange?.(
392
+ row.field.key as never,
393
+ value as never,
394
+ row.field as never,
395
+ );
396
+ if (ret && typeof (ret as Promise<void>).then === "function") {
397
+ // Async onChange: dirty state is already set above. On
398
+ // rejection, roll the row back and re-sync dirty state.
399
+ (ret as Promise<void>)
400
+ .then(() => {
401
+ if (isBuffered) {
402
+ args.tui.requestRender();
403
+ }
404
+ })
405
+ .catch((err) => {
406
+ if (row.value === value) {
407
+ row.value = previous;
408
+ }
409
+ if (isBuffered) {
410
+ syncDirtyState(key);
411
+ }
412
+ notifyError(args.ctx, err);
413
+ args.tui.requestRender();
414
+ });
415
+ }
416
+ } catch (err) {
417
+ row.value = previous;
418
+ if (isBuffered) {
419
+ syncDirtyState(key);
420
+ }
421
+ notifyError(args.ctx, err);
422
+ args.tui.requestRender();
423
+ }
424
+ }
425
+
426
+ function mountSubmenu(
427
+ factory: NonNullable<
428
+ ReturnType<(typeof RENDERERS)[Field["type"]]["handleKey"]>["submenu"]
429
+ >,
430
+ row: InternalRow,
431
+ ): void {
432
+ submenu = factory((value) => {
433
+ submenu = undefined;
434
+ submenuKey = undefined;
435
+ if (value !== undefined) commitValue(row, value);
436
+ args.tui.requestRender();
437
+ });
438
+ submenuKey = row.field.key;
439
+ args.tui.requestRender();
440
+ }
441
+
442
+ function mountConfirmSubmenu(): void {
443
+ const scopeToUse = useScopeTabs ? activeTabId : defaultScope;
444
+ confirmState = {
445
+ selectedIndex: scopeToUse === "project" ? 1 : 0,
446
+ dirtyKeys: new Set(getDirtyKeys()),
447
+ initialValues: new Map(getInitialValues()),
448
+ };
449
+ args.tui.requestRender();
450
+ }
451
+
452
+ function mountScopeActionConfirm(action: "reset" | "delete"): void {
453
+ scopeActionConfirm = { action, selectedIndex: 0 };
454
+ args.tui.requestRender();
455
+ }
456
+
457
+ function setEditing(row: InternalRow, value: boolean): void {
458
+ row.isEditing = value;
459
+ }
460
+
461
+ function rendererFor(field: Field) {
462
+ return RENDERERS[field.type];
463
+ }
464
+
465
+ function dispatchKey(data: string): void {
466
+ const row = focusedRow();
467
+ if (!row) return;
468
+ const renderer = rendererFor(row.field);
469
+ try {
470
+ const result = renderer.handleKey(
471
+ { field: row.field as never, value: row.value as never },
472
+ data,
473
+ {
474
+ isEditing: row.isEditing,
475
+ ctx: fieldRenderContext,
476
+ setEditing: (v) => setEditing(row, v),
477
+ },
478
+ );
479
+ if (result.commit !== undefined) commitValue(row, result.commit);
480
+ if (result.submenu) mountSubmenu(result.submenu, row);
481
+ } catch (err) {
482
+ notifyError(args.ctx, err);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Apply an alt+↑ / alt+↓ reorder request originating from `handleInput`.
488
+ *
489
+ * Returns `true` when the reorder consumed the keystroke (success
490
+ * or graceful no-op at an edge); `false` when the focused row isn't
491
+ * `reorderable` and the modal should fall through to the default
492
+ * alt-arrow handling (which is currently nothing — alt-arrows are
493
+ * inert in non-reorderable contexts).
494
+ *
495
+ * Reorder is restricted to swaps with the immediate `reorderable`
496
+ * neighbour in `visibleRowIndices` order. We do NOT skip over
497
+ * intervening non-reorderable rows: callers are expected to group
498
+ * reorderable rows contiguously, which keeps the visual behaviour
499
+ * predictable.
500
+ */
501
+ function handleReorder(direction: -1 | 1): boolean {
502
+ const focusedIdx = focusedIndex();
503
+ if (focusedIdx === undefined) return false;
504
+ const focusedRowInternal = rows[focusedIdx];
505
+ if (!focusedRowInternal?.field.reorderable) return false;
506
+
507
+ const indices = visibleRowIndices();
508
+ const visiblePos = indices.indexOf(focusedIdx);
509
+ const targetVisiblePos = visiblePos + direction;
510
+ if (targetVisiblePos < 0 || targetVisiblePos >= indices.length) {
511
+ // At the edge — still consume the keystroke so it doesn't bleed
512
+ // into the bare up/down handler below.
513
+ return true;
514
+ }
515
+ const targetIdx = indices[targetVisiblePos]!;
516
+ const targetRow = rows[targetIdx];
517
+ if (!targetRow?.field.reorderable) {
518
+ // Adjacent neighbour isn't reorderable — treat as edge.
519
+ return true;
520
+ }
521
+
522
+ // Compute the from/to indices counting ONLY reorderable peers so
523
+ // callers that interleave non-reorderable rows (e.g. a Separator
524
+ // field at the bottom of a Layout tab) still receive contiguous
525
+ // 0..N-1 positions matching their own data structure.
526
+ const reorderablePeerIdxs = indices.filter(
527
+ (i) => rows[i]?.field.reorderable,
528
+ );
529
+ const fromPeerPos = reorderablePeerIdxs.indexOf(focusedIdx);
530
+ const toPeerPos = reorderablePeerIdxs.indexOf(targetIdx);
531
+
532
+ // Swap the two rows in-place.
533
+ rows[focusedIdx] = targetRow;
534
+ rows[targetIdx] = focusedRowInternal;
535
+
536
+ // Update `fieldSelected` so focus follows the moved row.
537
+ // `fieldSelected` indexes into the visible-rows view; the row
538
+ // we swapped to `targetIdx` is at visible position
539
+ // `targetVisiblePos`.
540
+ fieldSelected = targetVisiblePos;
541
+
542
+ // Recompute cached visible indices as the internal rows order has changed
543
+ updateVisibleIndices();
544
+
545
+ try {
546
+ options.onReorder?.({
547
+ fieldKey: focusedRowInternal.field.key,
548
+ fromIndex: fromPeerPos,
549
+ toIndex: toPeerPos,
550
+ });
551
+ } catch (err) {
552
+ notifyError(args.ctx, err);
553
+ }
554
+
555
+ args.tui.requestRender();
556
+ return true;
557
+ }
558
+
559
+ function allValues(scope?: "global" | "project"): Record<string, unknown> {
560
+ const out: Record<string, unknown> = {};
561
+ for (const row of rows) {
562
+ if (useScopeTabs) {
563
+ out[row.field.key] =
564
+ scope === "project" ? row.projectValue : row.globalValue;
565
+ } else {
566
+ out[row.field.key] = row.value;
567
+ }
568
+ }
569
+ return out;
570
+ }
571
+
572
+ async function performSave(scope: "global" | "project"): Promise<void> {
573
+ if (!options.onSave) return;
574
+ try {
575
+ const result = options.onSave(allValues(scope), scope);
576
+ // If onSave returned a thenable, await it; otherwise close
577
+ // synchronously so synchronous callers (e.g. tests) don't need
578
+ // to flush microtasks.
579
+ const maybeThenable = result as unknown;
580
+ if (
581
+ maybeThenable &&
582
+ typeof (maybeThenable as Promise<void>).then === "function"
583
+ ) {
584
+ await (maybeThenable as Promise<void>);
585
+ }
586
+ args.close();
587
+ } catch (err) {
588
+ notifyError(args.ctx, err);
589
+ // Keep modal open on error — user can retry or cancel.
590
+ }
591
+ }
592
+
593
+ function handleConfirmInput(data: string): void {
594
+ if (!confirmState) return;
595
+ if (matchesKey(data, "up")) {
596
+ confirmState.selectedIndex = Math.max(0, confirmState.selectedIndex - 1);
597
+ args.tui.requestRender();
598
+ return;
599
+ }
600
+ if (matchesKey(data, "down")) {
601
+ const count = confirmOptions.length;
602
+ confirmState.selectedIndex = Math.min(
603
+ count - 1,
604
+ confirmState.selectedIndex + 1,
605
+ );
606
+ args.tui.requestRender();
607
+ return;
608
+ }
609
+ if (matchesKey(data, "escape")) {
610
+ // Cancel: unmount submenu, preserve edits.
611
+ confirmState = undefined;
612
+ args.tui.requestRender();
613
+ return;
614
+ }
615
+ if (matchesKey(data, "enter") || matchesKey(data, "return")) {
616
+ const choice = confirmOptions[confirmState.selectedIndex];
617
+ if (!choice) return;
618
+ if (choice.action === "save-global") {
619
+ void performSave("global");
620
+ } else if (choice.action === "save-project") {
621
+ void performSave("project");
622
+ } else if (choice.action === "discard") {
623
+ try {
624
+ options.onCancel?.();
625
+ } catch {
626
+ // Caller-supplied onCancel must not break the confirm teardown.
627
+ }
628
+ args.close();
629
+ } else {
630
+ // cancel
631
+ confirmState = undefined;
632
+ args.tui.requestRender();
633
+ }
634
+ }
635
+ }
636
+
637
+ function renderConfirmSubmenu(_width: number): string[] {
638
+ const lines: string[] = [];
639
+ lines.push("");
640
+ lines.push(" You have unsaved changes:");
641
+ lines.push("");
642
+
643
+ for (let i = 0; i < confirmOptions.length; i++) {
644
+ const opt = confirmOptions[i]!;
645
+ const isSelected = i === confirmState!.selectedIndex;
646
+ const prefix = isSelected ? args.theme.fg("accent", "▌ ") : " ";
647
+ const label = isSelected
648
+ ? args.theme.fg("text", opt.label)
649
+ : args.theme.fg("muted", opt.label);
650
+ lines.push(`${prefix}${label}`);
651
+ }
652
+
653
+ lines.push("");
654
+ const needsReload = Array.from(confirmState!.dirtyKeys).some((key) => {
655
+ const row = rows.find((r) => r.field.key === key);
656
+ return (
657
+ (row?.field as { requiresReload?: boolean } | undefined)
658
+ ?.requiresReload === true
659
+ );
660
+ });
661
+ if (needsReload) {
662
+ lines.push(
663
+ args.theme.fg(
664
+ "muted",
665
+ " Some changes require /reload to take effect.",
666
+ ),
667
+ );
668
+ }
669
+ lines.push(
670
+ args.theme.fg("muted", " ↑↓ select enter confirm esc cancel"),
671
+ );
672
+
673
+ return lines;
674
+ }
675
+
676
+ function handleScopeActionConfirmInput(data: string): void {
677
+ if (!scopeActionConfirm) return;
678
+ if (matchesKey(data, "up")) {
679
+ scopeActionConfirm.selectedIndex = Math.max(
680
+ 0,
681
+ scopeActionConfirm.selectedIndex - 1,
682
+ );
683
+ args.tui.requestRender();
684
+ return;
685
+ }
686
+ if (matchesKey(data, "down")) {
687
+ const max = 2; // global, project, cancel
688
+ scopeActionConfirm.selectedIndex = Math.min(
689
+ max,
690
+ scopeActionConfirm.selectedIndex + 1,
691
+ );
692
+ args.tui.requestRender();
693
+ return;
694
+ }
695
+ if (matchesKey(data, "escape")) {
696
+ scopeActionConfirm = undefined;
697
+ args.tui.requestRender();
698
+ return;
699
+ }
700
+ if (matchesKey(data, "enter") || matchesKey(data, "return")) {
701
+ const act = scopeActionConfirm.action;
702
+ const options = getScopeActionOptions(act);
703
+ const choice = options[scopeActionConfirm.selectedIndex];
704
+ scopeActionConfirm = undefined;
705
+ if (choice && choice.scope !== null) {
706
+ void handleScopeAction(act, choice.scope);
707
+ } else {
708
+ args.tui.requestRender();
709
+ }
710
+ }
711
+ }
712
+
713
+ function getScopeActionOptions(
714
+ action: "reset" | "delete",
715
+ ): Array<{ label: string; scope: "global" | "project" | null }> {
716
+ const verb = action === "reset" ? "Reset to defaults" : "Delete config";
717
+ return [
718
+ // Cancel first — it is pre-selected (selectedIndex starts at 0), so
719
+ // tabbing into the confirm can never trigger a destructive action
720
+ // by accident.
721
+ { label: "Cancel", scope: null },
722
+ { label: `${verb} (global)`, scope: "global" },
723
+ { label: `${verb} (project-local)`, scope: "project" },
724
+ ];
725
+ }
726
+
727
+ function renderScopeActionConfirm(_width: number): string[] {
728
+ const lines: string[] = [];
729
+ const isReset = scopeActionConfirm!.action === "reset";
730
+ const options = getScopeActionOptions(scopeActionConfirm!.action);
731
+ lines.push("");
732
+ lines.push(isReset ? " Reset config to defaults" : " Delete config file");
733
+ lines.push("");
734
+ // Destructive-action warning — warning color (yellow) so it is
735
+ // visible before confirming.
736
+ lines.push(
737
+ args.theme.fg(
738
+ "warning",
739
+ isReset
740
+ ? " This will reset your config to defaults."
741
+ : " This will permanently delete your config file.",
742
+ ),
743
+ );
744
+ lines.push("");
745
+ lines.push(args.theme.fg("muted", " Select scope:"));
746
+ lines.push("");
747
+
748
+ for (let i = 0; i < options.length; i++) {
749
+ const isSelected = i === scopeActionConfirm!.selectedIndex;
750
+ const prefix = isSelected ? args.theme.fg("accent", "▌ ") : " ";
751
+ const label = isSelected
752
+ ? args.theme.fg("text", options[i]!.label)
753
+ : args.theme.fg("muted", options[i]!.label);
754
+ lines.push(`${prefix}${label}`);
755
+ }
756
+
757
+ lines.push("");
758
+ lines.push(
759
+ args.theme.fg("muted", " ↑↓ select enter confirm esc cancel"),
760
+ );
761
+ return lines;
762
+ }
763
+
764
+ function handleInput(data: string): void {
765
+ if (submenu) {
766
+ submenu.handleInput?.(data);
767
+ return;
768
+ }
769
+ if (confirmState) {
770
+ handleConfirmInput(data);
771
+ return;
772
+ }
773
+ if (scopeActionConfirm) {
774
+ handleScopeActionConfirmInput(data);
775
+ return;
776
+ }
777
+ const row = focusedRow();
778
+
779
+ if (row?.isEditing && matchesKey(data, "ctrl+c")) {
780
+ // ctrl+c always closes the modal, even mid-edit — otherwise a
781
+ // stuck editing state leaves the user with no way out.
782
+ if (isBuffered && isDirty()) {
783
+ mountConfirmSubmenu();
784
+ } else {
785
+ args.close();
786
+ }
787
+ return;
788
+ }
789
+
790
+ if (row?.isEditing) {
791
+ // While editing, only the renderer (and esc/enter) gets to see
792
+ // input — cursor & nav keys are reserved for the inline editor.
793
+ dispatchKey(data);
794
+ args.tui.requestRender();
795
+ return;
796
+ }
797
+
798
+ if (matchesKey(data, "ctrl+s")) {
799
+ if (isBuffered && isDirty()) {
800
+ mountConfirmSubmenu();
801
+ } else if (isBuffered) {
802
+ // No changes — save directly to the inferred default scope.
803
+ // This lets users deploy a fresh config without editing anything.
804
+ const scope: "global" | "project" = useScopeTabs
805
+ ? ((activeTabId ?? defaultScope) as "global" | "project")
806
+ : defaultScope;
807
+ void performSave(scope);
808
+ }
809
+ return;
810
+ }
811
+ // ctrl+r: contextual — field-level reset when on field, scope reset when on action row
812
+ if (matchesKey(data, "ctrl+r")) {
813
+ if (tabActionFocus >= tabs.length && actionRows.length > 0) {
814
+ // Action row focused → scope reset
815
+ if (options.onResetScope) {
816
+ mountScopeActionConfirm("reset");
817
+ }
818
+ } else {
819
+ // Field focused → field-level reset
820
+ const row = focusedRow();
821
+ if (row && !row.field.disabled && !row.isEditing) {
822
+ const def = row.field.default;
823
+ if (def !== undefined) {
824
+ const clonedDef =
825
+ typeof def === "object" && def !== null
826
+ ? JSON.parse(JSON.stringify(def))
827
+ : def;
828
+ commitValue(row, clonedDef);
829
+ }
830
+ }
831
+ }
832
+ args.tui.requestRender();
833
+ return;
834
+ }
835
+ // ctrl+d: field-level reset when on field, scope delete when on action row
836
+ if (matchesKey(data, "ctrl+d")) {
837
+ if (tabActionFocus >= tabs.length && actionRows.length > 0) {
838
+ // Action row focused → scope delete
839
+ if (options.onDeleteScope) {
840
+ mountScopeActionConfirm("delete");
841
+ }
842
+ } else {
843
+ // Field focused → field-level reset (same as ctrl+r)
844
+ const row = focusedRow();
845
+ if (row && !row.field.disabled && !row.isEditing) {
846
+ const def = row.field.default;
847
+ if (def !== undefined) {
848
+ const clonedDef =
849
+ typeof def === "object" && def !== null
850
+ ? JSON.parse(JSON.stringify(def))
851
+ : def;
852
+ commitValue(row, clonedDef);
853
+ }
854
+ }
855
+ }
856
+ args.tui.requestRender();
857
+ return;
858
+ }
859
+ // ctrl+shift+r: always scope reset (from anywhere)
860
+ if (matchesKey(data, "ctrl+shift+r")) {
861
+ if (options.onResetScope) {
862
+ mountScopeActionConfirm("reset");
863
+ }
864
+ args.tui.requestRender();
865
+ return;
866
+ }
867
+ // ctrl+shift+d: always scope delete (from anywhere)
868
+ if (matchesKey(data, "ctrl+shift+d")) {
869
+ if (options.onDeleteScope) {
870
+ mountScopeActionConfirm("delete");
871
+ }
872
+ args.tui.requestRender();
873
+ return;
874
+ }
875
+ if (matchesKey(data, "escape")) {
876
+ // If Tab focus is on action row/tab, first escape returns to field zone
877
+ if (tabActionFocus >= 0) {
878
+ tabActionFocus = -1;
879
+ args.tui.requestRender();
880
+ return;
881
+ }
882
+ if (options.enableSearch && search !== "") {
883
+ search = "";
884
+ fieldSelected = 0;
885
+ updateVisibleIndices();
886
+ args.tui.requestRender();
887
+ return;
888
+ }
889
+ if (isBuffered && isDirty()) {
890
+ mountConfirmSubmenu();
891
+ } else {
892
+ args.close();
893
+ }
894
+ return;
895
+ }
896
+ if (matchesKey(data, "ctrl+c")) {
897
+ if (isBuffered && isDirty()) {
898
+ mountConfirmSubmenu();
899
+ } else {
900
+ args.close();
901
+ }
902
+ return;
903
+ }
904
+ // Tab/Shift+Tab: cycle through tabs + action row
905
+ if (matchesKey(data, "tab")) {
906
+ const stopCount = tabs.length + actionRows.length;
907
+ if (stopCount === 0) {
908
+ args.tui.requestRender();
909
+ return;
910
+ }
911
+ if (tabActionFocus === -1) {
912
+ // Start from the next stop after the current active tab,
913
+ // so Tab never "rewinds" to the first stop.
914
+ const currentTabIdx = tabs.findIndex((t) => t.id === activeTabId);
915
+ if (currentTabIdx >= 0) {
916
+ tabActionFocus = (currentTabIdx + 1) % stopCount;
917
+ } else {
918
+ tabActionFocus = 0;
919
+ }
920
+ } else {
921
+ tabActionFocus = (tabActionFocus + 1) % stopCount;
922
+ }
923
+ // Auto-switch active tab when a tab is focused
924
+ if (tabActionFocus < tabs.length) {
925
+ const tab = tabs[tabActionFocus];
926
+ if (tab && tab.id !== activeTabId) {
927
+ activeTabId = tab.id;
928
+ fieldSelected = 0;
929
+ scroll = 0;
930
+ updateVisibleIndices();
931
+ }
932
+ }
933
+ args.tui.requestRender();
934
+ return;
935
+ }
936
+ if (matchesKey(data, "shift+tab")) {
937
+ const stopCount = tabs.length + actionRows.length;
938
+ if (stopCount === 0) {
939
+ args.tui.requestRender();
940
+ return;
941
+ }
942
+ if (tabActionFocus === -1) {
943
+ // Start from the previous stop before the current active tab.
944
+ const currentTabIdx = tabs.findIndex((t) => t.id === activeTabId);
945
+ if (currentTabIdx >= 0) {
946
+ tabActionFocus = (currentTabIdx - 1 + stopCount) % stopCount;
947
+ } else {
948
+ tabActionFocus = stopCount - 1;
949
+ }
950
+ } else {
951
+ tabActionFocus = (tabActionFocus - 1 + stopCount) % stopCount;
952
+ }
953
+ if (tabActionFocus < tabs.length) {
954
+ const tab = tabs[tabActionFocus];
955
+ if (tab && tab.id !== activeTabId) {
956
+ activeTabId = tab.id;
957
+ fieldSelected = 0;
958
+ scroll = 0;
959
+ updateVisibleIndices();
960
+ }
961
+ }
962
+ args.tui.requestRender();
963
+ return;
964
+ }
965
+ // Alt+↑ / Alt+↓ reorders the focused row among its `reorderable`
966
+ // peers. Has to run before the bare up/down handlers so the
967
+ // modifier prefix is what dispatches — otherwise `matchesKey(data,
968
+ // "up")` could match the alt-modified variant first depending on
969
+ // the terminal's escape sequence.
970
+ if (matchesKey(data, "alt+up")) {
971
+ if (handleReorder(-1)) return;
972
+ }
973
+ if (matchesKey(data, "alt+down")) {
974
+ if (handleReorder(1)) return;
975
+ }
976
+ if (matchesKey(data, "alt+r")) {
977
+ const row = focusedRow();
978
+ if (row && !row.field.disabled && !row.isEditing) {
979
+ const def = row.field.default;
980
+ if (def !== undefined) {
981
+ const clonedDef =
982
+ typeof def === "object" && def !== null
983
+ ? JSON.parse(JSON.stringify(def))
984
+ : def;
985
+ commitValue(row, clonedDef);
986
+ args.tui.requestRender();
987
+ return;
988
+ }
989
+ }
990
+ }
991
+ // Arrow keys navigate fields only (field zone). They also switch
992
+ // Tab focus back to the field zone.
993
+ const totalCount = totalVisibleItems();
994
+ const lastIndex = Math.max(0, totalCount - 1);
995
+ if (
996
+ matchesKey(data, "up") ||
997
+ matchesKey(data, "down") ||
998
+ matchesKey(data, "pageUp") ||
999
+ matchesKey(data, "pageDown")
1000
+ ) {
1001
+ tabActionFocus = -1;
1002
+ }
1003
+ if (matchesKey(data, "up")) {
1004
+ fieldSelected = Math.max(0, fieldSelected - 1);
1005
+ args.tui.requestRender();
1006
+ return;
1007
+ }
1008
+ if (matchesKey(data, "down")) {
1009
+ fieldSelected = Math.min(lastIndex, fieldSelected + 1);
1010
+ args.tui.requestRender();
1011
+ return;
1012
+ }
1013
+ if (matchesKey(data, "pageUp")) {
1014
+ fieldSelected = Math.max(0, fieldSelected - 5);
1015
+ args.tui.requestRender();
1016
+ return;
1017
+ }
1018
+ if (matchesKey(data, "pageDown")) {
1019
+ fieldSelected = Math.min(lastIndex, fieldSelected + 5);
1020
+ args.tui.requestRender();
1021
+ return;
1022
+ }
1023
+ // Enter on a tab switches focus to field zone (tab already auto-switched).
1024
+ // Then lets Enter fall through to dispatchKey for field toggling/editing.
1025
+ // Enter on action row activates the focused action with confirmation.
1026
+ if (
1027
+ (matchesKey(data, "enter") || matchesKey(data, "return")) &&
1028
+ tabActionFocus >= 0
1029
+ ) {
1030
+ if (tabActionFocus < tabs.length) {
1031
+ // Tab focused — return to field zone, then let Enter fall through
1032
+ // to dispatchKey for field toggling/editing.
1033
+ tabActionFocus = -1;
1034
+ // Do NOT return — fall through to dispatchKey below
1035
+ } else if (actionRows.length > 0) {
1036
+ // Action row focused — determine which action by index
1037
+ const actionIdx = tabActionFocus - tabs.length;
1038
+ const action = actionRows[actionIdx];
1039
+ if (action) {
1040
+ const act = action.id === "reset" ? "reset" : "delete";
1041
+ const cb =
1042
+ act === "reset" ? options.onResetScope : options.onDeleteScope;
1043
+ if (cb) {
1044
+ mountScopeActionConfirm(act);
1045
+ }
1046
+ }
1047
+ args.tui.requestRender();
1048
+ return;
1049
+ }
1050
+ }
1051
+
1052
+ if (options.enableSearch) {
1053
+ if (matchesKey(data, "backspace") || matchesKey(data, "ctrl+h")) {
1054
+ search = search.slice(0, -1);
1055
+ fieldSelected = 0;
1056
+ updateVisibleIndices();
1057
+ args.tui.requestRender();
1058
+ return;
1059
+ }
1060
+ if (matchesKey(data, "ctrl+u")) {
1061
+ search = "";
1062
+ fieldSelected = 0;
1063
+ updateVisibleIndices();
1064
+ args.tui.requestRender();
1065
+ return;
1066
+ }
1067
+ if (matchesKey(data, "ctrl+w")) {
1068
+ search = deleteWordBackward(search);
1069
+ fieldSelected = 0;
1070
+ updateVisibleIndices();
1071
+ args.tui.requestRender();
1072
+ return;
1073
+ }
1074
+ }
1075
+
1076
+ // Enter / value-key falls through to the renderer.
1077
+ dispatchKey(data);
1078
+ if (
1079
+ options.enableSearch &&
1080
+ data.length === 1 &&
1081
+ data >= " " &&
1082
+ data !== "\x7f" &&
1083
+ !row?.isEditing
1084
+ ) {
1085
+ // Plain printable input that the renderer didn't claim joins the
1086
+ // search query. Booleans / enums never claim it (their
1087
+ // handleKey returns `consumed:false` for non-Enter), so users
1088
+ // can still type to filter.
1089
+ // We detect "renderer didn't claim it" by checking the row state
1090
+ // didn't change to editing — a heuristic that works for every
1091
+ // built-in. Custom renderers that want to swallow letters should
1092
+ // mark the row as editing first.
1093
+ if (!row || !row.isEditing) {
1094
+ search += data;
1095
+ fieldSelected = 0;
1096
+ updateVisibleIndices();
1097
+ }
1098
+ }
1099
+ args.tui.requestRender();
1100
+ }
1101
+
1102
+ function renderTabBar(width: number): string {
1103
+ if (tabs.length === 0) return "";
1104
+ const cells: string[] = [];
1105
+ for (const tab of tabs) {
1106
+ let label = tab.label;
1107
+ if (isBuffered) {
1108
+ const isTabDirty = useScopeTabs
1109
+ ? tab.id === "project"
1110
+ ? dirtyProjectKeys.size > 0
1111
+ : dirtyGlobalKeys.size > 0
1112
+ : Array.from(getDirtyKeys()).some((key) => {
1113
+ const row = rows.find((r) => r.field.key === key);
1114
+ const fallbackTab = tabs[0]?.id;
1115
+ const rowTab = row?.field.tab ?? fallbackTab;
1116
+ return rowTab === tab.id;
1117
+ });
1118
+ if (isTabDirty) {
1119
+ label += " ● Unsaved";
1120
+ }
1121
+ }
1122
+ if (tab.id === activeTabId) {
1123
+ const padded = ` ▸ ${label} `;
1124
+ cells.push(
1125
+ args.theme.fg("accent", args.theme.inverse(args.theme.bold(padded))),
1126
+ );
1127
+ } else {
1128
+ const padded = ` ${label} `;
1129
+ cells.push(
1130
+ args.theme.bg("selectedBg", args.theme.fg("accent", padded)),
1131
+ );
1132
+ }
1133
+ }
1134
+ return pad(cells.join(" "), width);
1135
+ }
1136
+
1137
+ function renderSearchBar(width: number, hasMatches = true): string {
1138
+ const cursor = args.theme.inverse(" ");
1139
+ let text: string;
1140
+ if (search === "") {
1141
+ const placeholder = args.theme.fg("muted", "Search settings...");
1142
+ text = ` > ${cursor}${placeholder}`;
1143
+ } else {
1144
+ if (!hasMatches) {
1145
+ text = ` > ${args.theme.fg("warning", search)}${cursor}`;
1146
+ } else {
1147
+ text = ` > ${search}${cursor}`;
1148
+ }
1149
+ }
1150
+ return args.theme.bg("toolPendingBg", pad(text, width));
1151
+ }
1152
+
1153
+ function renderFooter(_width: number): string[] {
1154
+ const row = focusedRow();
1155
+ let rowHints: FieldKeyHint[] = [];
1156
+ if (row) {
1157
+ const renderer = rendererFor(row.field);
1158
+ rowHints = renderer.hints(
1159
+ { field: row.field as never, value: row.value as never },
1160
+ { isEditing: row.isEditing },
1161
+ );
1162
+ }
1163
+
1164
+ // Always-present hints: nav, field-reset, save, search
1165
+ const line1: FieldKeyHint[] = [];
1166
+ if (tabs.length > 0 || actionRows.length > 0) {
1167
+ line1.push({ key: "tab/shift+tab", label: "cycle" });
1168
+ }
1169
+ line1.push({ key: "↑↓", label: "move" });
1170
+ if (!row?.isEditing && row?.field.reorderable) {
1171
+ line1.push({ key: "alt+↑↓", label: "reorder" });
1172
+ }
1173
+ const anyFieldHasDefault = fields.some(
1174
+ (f) => (f as { default?: unknown }).default !== undefined,
1175
+ );
1176
+ if (anyFieldHasDefault && !row?.isEditing) {
1177
+ line1.push({ key: "ctrl+r", label: "reset field" });
1178
+ line1.push({ key: "ctrl+d", label: "reset field" });
1179
+ }
1180
+ if (isBuffered) {
1181
+ line1.push({ key: "ctrl+s", label: "save" });
1182
+ }
1183
+ if (options.enableSearch && !row?.isEditing) {
1184
+ if (search === "") {
1185
+ line1.push({ key: "type", label: "to search" });
1186
+ } else {
1187
+ line1.push({ key: "ctrl+u", label: "clear" });
1188
+ }
1189
+ }
1190
+
1191
+ // Line 2: field-specific hints + dismiss
1192
+ const line2: FieldKeyHint[] = [];
1193
+ line2.push(...rowHints);
1194
+ if (confirmState) {
1195
+ line2.push({ key: "↑↓", label: "select" });
1196
+ line2.push({ key: "enter", label: "confirm" });
1197
+ line2.push({ key: "esc", label: "cancel" });
1198
+ } else if (options.enableSearch && search !== "" && !row?.isEditing) {
1199
+ line2.push({ key: "esc", label: "clear search" });
1200
+ } else if (isBuffered && isDirty()) {
1201
+ line2.push({ key: "esc", label: "confirm →" });
1202
+ } else {
1203
+ line2.push({ key: "esc", label: "close" });
1204
+ }
1205
+
1206
+ const lines: string[] = [formatHintLine(line1, args.theme)];
1207
+ if (line2.length > 1 || (line2.length === 1 && line2[0]!.key !== "esc")) {
1208
+ lines.push(formatHintLine(line2, args.theme));
1209
+ }
1210
+ return lines;
1211
+ }
1212
+
1213
+ function renderRow(
1214
+ row: InternalRow,
1215
+ width: number,
1216
+ isSelected: boolean,
1217
+ ): string {
1218
+ const labelText = row.labelText;
1219
+ // Per-field `dim` overrides the default focus-based coloring. The
1220
+ // override is binary — fields that opt in are saying "this row is
1221
+ // semantically active / inactive, color me regardless of focus".
1222
+ // Selection background still applies on top either way, so a
1223
+ // focused dimmed row stays visibly highlighted via the prefix
1224
+ // chip + selectedBg, just with a muted label.
1225
+ const dimRaw = row.field.disabled ? true : row.field.dim;
1226
+ const dimFlag = typeof dimRaw === "function" ? dimRaw() : dimRaw;
1227
+ const labelColor =
1228
+ dimFlag === true
1229
+ ? "muted"
1230
+ : dimFlag === false
1231
+ ? "text"
1232
+ : isSelected
1233
+ ? "text"
1234
+ : "muted";
1235
+ const label = args.theme.fg(labelColor, labelText);
1236
+ const renderer = rendererFor(row.field);
1237
+ const valueText = renderer.renderValue(
1238
+ { field: row.field as never, value: row.value as never },
1239
+ {
1240
+ width: Math.max(1, width - LABEL_PAD_TARGET - 4),
1241
+ selected: isSelected,
1242
+ isEditing: row.isEditing,
1243
+ ctx: fieldRenderContext,
1244
+ },
1245
+ );
1246
+ const padding = row.labelPadding;
1247
+ const depthIndent = " ".repeat(row.field.depth ?? 0);
1248
+ const prefix = isSelected
1249
+ ? args.theme.fg("accent", `${depthIndent}▌ `)
1250
+ : `${depthIndent} `;
1251
+
1252
+ // Scope note: when in a scope tab, show where the value originates
1253
+ // from (other scope or default). E.g. "(Global: on)" or "(default: 10)".
1254
+ let note = "";
1255
+ if (useScopeTabs && activeTabId) {
1256
+ const sn = scopeNoteFor(row, activeTabId);
1257
+ if (sn) note = ` ${args.theme.fg("dim", sn)}`;
1258
+ }
1259
+
1260
+ const composed = `${prefix}${label}${padding}${valueText}${note}`;
1261
+ if (isSelected) return args.theme.bg("selectedBg", pad(composed, width));
1262
+ return truncateToWidth(composed, width, "…");
1263
+ }
1264
+
1265
+ function scopeNoteFor(row: InternalRow, scope: string): string | undefined {
1266
+ if (!useScopeTabs || !options.defaults) return undefined;
1267
+ const defaultValue = (options.defaults as Record<string, unknown>)[
1268
+ row.field.key
1269
+ ];
1270
+ const gv = row.globalValue;
1271
+ const pv = row.projectValue;
1272
+ const format = (v: unknown): string => {
1273
+ if (v === undefined || v === null) return "unset";
1274
+ if (row.field.type === "boolean") return v ? "on" : "off";
1275
+ if (row.field.type === "number") return String(v);
1276
+ if (row.field.type === "string" || row.field.type === "text")
1277
+ return JSON.stringify(String(v));
1278
+ if (row.field.type === "enum") return String(v);
1279
+ return String(v);
1280
+ };
1281
+ if (scope === "project") {
1282
+ if (pv !== undefined) return undefined; // project has explicit value — no note needed
1283
+ if (gv !== undefined) return `(Global: ${format(gv)})`;
1284
+ return `(default: ${format(defaultValue)})`;
1285
+ }
1286
+ // Global tab
1287
+ if (gv !== undefined) return undefined; // global has explicit value
1288
+ return `(default: ${format(defaultValue)})`;
1289
+ }
1290
+
1291
+ function renderBody(width: number, innerRows: number): string[] {
1292
+ const lines: string[] = [];
1293
+ if (tabs.length > 0) {
1294
+ lines.push(renderTabBar(width));
1295
+ }
1296
+ const indices = visibleRowIndices();
1297
+ if (options.enableSearch) {
1298
+ if (lines.length > 0) lines.push("");
1299
+ lines.push(renderSearchBar(width, indices.length > 0));
1300
+ }
1301
+ if (lines.length > 0) {
1302
+ lines.push("");
1303
+ lines.push(divider(width, args.theme));
1304
+ }
1305
+ const visibleListRows = Math.max(
1306
+ 3,
1307
+ innerRows - lines.length - 2 - estimateDescriptionRows(),
1308
+ );
1309
+ clampSelection(visibleListRows);
1310
+
1311
+ const fieldCount = indices.length;
1312
+ const slice = indices.slice(scroll, scroll + visibleListRows);
1313
+ if (slice.length === 0) {
1314
+ lines.push(
1315
+ args.theme.fg("muted", " No matching settings. (press esc to clear)"),
1316
+ );
1317
+ } else {
1318
+ if (scroll > 0) lines.push(args.theme.fg("dim", ` ↑ ${scroll} earlier`));
1319
+ for (const [visIdx, idx] of slice.entries()) {
1320
+ const realIdx = scroll + visIdx;
1321
+ const row = rows[idx]!;
1322
+ lines.push(renderRow(row, width, realIdx === fieldSelected));
1323
+ }
1324
+ const hidden = Math.max(0, fieldCount - (scroll + visibleListRows));
1325
+ if (hidden > 0) lines.push(args.theme.fg("dim", ` ↓ ${hidden} more`));
1326
+ }
1327
+
1328
+ // Description for the focused field
1329
+ const focused = focusedRow();
1330
+ if (focused) {
1331
+ renderFieldDesc(lines, width, focused);
1332
+ }
1333
+
1334
+ // Pad to fill remaining space (footer and actions are added at frame level)
1335
+ while (lines.length < innerRows) lines.push("");
1336
+ return lines;
1337
+ }
1338
+
1339
+ function renderFieldDesc(
1340
+ lines: string[],
1341
+ width: number,
1342
+ focused: InternalRow,
1343
+ ): void {
1344
+ let desc = focused.field.description ?? "";
1345
+ const field = focused.field;
1346
+ if (field.type === "number") {
1347
+ const parts: string[] = [];
1348
+ if (field.values) {
1349
+ parts.push(`values: ${field.values.join(", ")}`);
1350
+ } else {
1351
+ if (typeof field.min === "number" && typeof field.max === "number") {
1352
+ parts.push(`range: ${field.min} to ${field.max}`);
1353
+ } else if (typeof field.min === "number") {
1354
+ parts.push(`min: ${field.min}`);
1355
+ } else if (typeof field.max === "number") {
1356
+ parts.push(`max: ${field.max}`);
1357
+ }
1358
+ if (field.integer) parts.push("integer only");
1359
+ }
1360
+ if (parts.length > 0) {
1361
+ const suffix = `(${parts.join(", ")})`;
1362
+ desc = desc ? `${desc} ${suffix}` : suffix;
1363
+ }
1364
+ }
1365
+
1366
+ if (desc) {
1367
+ lines.push("");
1368
+ for (const line of wrapLine(desc, Math.max(1, width - 4))) {
1369
+ lines.push(args.theme.fg("muted", ` ${line}`));
1370
+ }
1371
+ }
1372
+
1373
+ // valueDescriptions: per-value help text for boolean/enum/number
1374
+ let vdText: string | undefined;
1375
+ if (field.type === "boolean") {
1376
+ const vd = field.valueDescriptions;
1377
+ if (vd) {
1378
+ const key = field.value ? "on" : "off";
1379
+ vdText = vd[key as "on" | "off"];
1380
+ }
1381
+ } else if (field.type === "enum") {
1382
+ const vd = field.valueDescriptions;
1383
+ if (vd) {
1384
+ vdText = vd[field.value];
1385
+ }
1386
+ } else if (field.type === "number") {
1387
+ const vd = field.valueDescriptions;
1388
+ if (vd) {
1389
+ vdText = vd[String(field.value)];
1390
+ }
1391
+ }
1392
+ if (vdText) {
1393
+ lines.push("");
1394
+ for (const line of wrapLine(
1395
+ args.theme.fg("accent", vdText),
1396
+ Math.max(1, width - 4),
1397
+ )) {
1398
+ lines.push(` ${line}`);
1399
+ }
1400
+ }
1401
+
1402
+ // Validation warning: show when the focused row's current value
1403
+ // violates its type constraints (enum membership, number range, etc.).
1404
+ const warning = validateFieldValue(focused.field, focused.value);
1405
+ if (warning) {
1406
+ lines.push("");
1407
+ for (const line of wrapLine(warning, Math.max(1, width - 4))) {
1408
+ lines.push(args.theme.fg("warning", ` ${line}`));
1409
+ }
1410
+ }
1411
+ }
1412
+
1413
+ /** Tiny heuristic so renderBody knows roughly how much room the
1414
+ * description block will eat. Real content is recomputed per render
1415
+ * but we want the list to start scrolling before that math kicks in. */
1416
+ function estimateDescriptionRows(): number {
1417
+ const focused = focusedRow();
1418
+ if (!focused) return 0;
1419
+ let estimate = 0;
1420
+ if (focused.field.description) estimate = 2;
1421
+ const field = focused.field;
1422
+ if (field.type === "number") {
1423
+ if (
1424
+ typeof field.min === "number" ||
1425
+ typeof field.max === "number" ||
1426
+ field.integer
1427
+ ) {
1428
+ estimate = Math.max(estimate, 2);
1429
+ }
1430
+ }
1431
+ // Validation warning
1432
+ const warning = validateFieldValue(focused.field, focused.value);
1433
+ if (warning) estimate = Math.max(estimate, 1);
1434
+ return estimate;
1435
+ }
1436
+
1437
+ /** Handle reset/delete scope actions: call the callback and reload configs. */
1438
+ async function handleScopeAction(
1439
+ action: "reset" | "delete",
1440
+ scope: "global" | "project",
1441
+ ): Promise<void> {
1442
+ const cb =
1443
+ action === "reset" ? options.onResetScope : options.onDeleteScope;
1444
+ if (!cb) return;
1445
+ try {
1446
+ await cb(scope);
1447
+ } catch (err) {
1448
+ notifyError(args.ctx, err);
1449
+ return;
1450
+ }
1451
+ // Reload configs and update rows with fresh values
1452
+ if (useScopeTabs && options.configFilename) {
1453
+ const fn = options.configFilename;
1454
+ const defs = options.defaults ?? {};
1455
+ const freshGlobal = loadConfig<Record<string, unknown>>(fn, defs, {
1456
+ configDir: options.globalConfigDir ?? getExtensionsDir(),
1457
+ });
1458
+ const freshProject = loadConfig<Record<string, unknown>>(fn, defs, {
1459
+ cwd: args.ctx.cwd,
1460
+ configDir: options.globalConfigDir ?? getExtensionsDir(),
1461
+ });
1462
+ for (const rr of rows) {
1463
+ const gv = getNestedValue(freshGlobal, rr.field.key);
1464
+ rr.globalValue =
1465
+ gv !== undefined
1466
+ ? gv
1467
+ : (extractInitialValue(rr.field) ??
1468
+ (options.defaults as Record<string, unknown>)?.[rr.field.key]);
1469
+ const pv = getNestedValue(freshProject, rr.field.key);
1470
+ rr.projectValue = pv !== undefined ? pv : rr.globalValue;
1471
+ }
1472
+ }
1473
+ // Clear dirty state for the affected scope
1474
+ if (isBuffered) {
1475
+ const dk = scope === "project" ? dirtyProjectKeys : dirtyGlobalKeys;
1476
+ dk.clear();
1477
+ const iv =
1478
+ scope === "project" ? initialProjectValues : initialGlobalValues;
1479
+ for (const rr of rows) {
1480
+ const initVal = scope === "project" ? rr.projectValue : rr.globalValue;
1481
+ iv.set(
1482
+ rr.field.key,
1483
+ typeof initVal === "object" && initVal !== null
1484
+ ? JSON.parse(JSON.stringify(initVal))
1485
+ : initVal,
1486
+ );
1487
+ }
1488
+ }
1489
+ args.tui.requestRender();
1490
+ }
1491
+
1492
+ return {
1493
+ render(width: number): string[] {
1494
+ const inner = responsiveInnerRows(
1495
+ args.tui.terminal.rows ?? 24,
1496
+ PREFERRED_INNER_ROWS,
1497
+ 14,
1498
+ );
1499
+ if (submenu) {
1500
+ // Render the submenu inside the same frame so the popup chrome
1501
+ // doesn't change shape mid-flow.
1502
+ const lines = submenu.render(frameContentWidth(width));
1503
+ const opts: FrameOptions = {
1504
+ title: submenuTitle(submenuKey),
1505
+ fixedInnerRows: inner,
1506
+ };
1507
+ return frame(lines, width, args.theme, opts);
1508
+ }
1509
+ if (confirmState) {
1510
+ const lines = renderConfirmSubmenu(frameContentWidth(width));
1511
+ const title = options.title
1512
+ ? `${options.title}${isBuffered && isDirty() ? " " + args.theme.fg("accent", "● Unsaved") : ""} — Save changes?`
1513
+ : "Save changes?";
1514
+ const opts: FrameOptions = {
1515
+ title,
1516
+ fixedInnerRows: inner,
1517
+ };
1518
+ return frame(lines, width, args.theme, opts);
1519
+ }
1520
+ if (scopeActionConfirm) {
1521
+ const lines = renderScopeActionConfirm(frameContentWidth(width));
1522
+ const dialogTitle =
1523
+ scopeActionConfirm.action === "reset"
1524
+ ? "Reset scope?"
1525
+ : "Delete config?";
1526
+ const opts: FrameOptions = {
1527
+ title: options.title
1528
+ ? `${options.title} — ${dialogTitle}`
1529
+ : dialogTitle,
1530
+ fixedInnerRows: inner,
1531
+ };
1532
+ return frame(lines, width, args.theme, opts);
1533
+ }
1534
+
1535
+ // Footer hints + action buttons are rendered OUTSIDE the frame
1536
+ // body truncation so they are ALWAYS visible at the bottom.
1537
+ const contentWidth = frameContentWidth(width);
1538
+ const footerRows = renderFooter(contentWidth);
1539
+ const footerSectionHeight = 1 + footerRows.length; // divider + hints
1540
+ const actionSectionHeight = actionRows.length > 0 ? 2 : 0;
1541
+ const bottomSectionHeight = footerSectionHeight + actionSectionHeight;
1542
+ const bodyInnerRows = inner - bottomSectionHeight;
1543
+ const bodyLines = renderBody(contentWidth, bodyInnerRows);
1544
+
1545
+ const dirtyDot =
1546
+ isBuffered && isDirty()
1547
+ ? ` ${args.theme.fg("accent", "● Unsaved")}`
1548
+ : "";
1549
+ const title = options.title
1550
+ ? `${options.title}${dirtyDot}`
1551
+ : options.title;
1552
+
1553
+ const frameLines = frame(bodyLines, width, args.theme, {
1554
+ title,
1555
+ fixedInnerRows: bodyInnerRows,
1556
+ });
1557
+
1558
+ // Replace the bottom padding + border with the fixed sections
1559
+ const bottomBorder = frameLines.pop()!;
1560
+ const bottomPadding = frameLines.pop()!;
1561
+ const borderAccent = (s: string) => args.theme.fg("borderAccent", s);
1562
+
1563
+ // Footer hint line
1564
+ const footDiv = `${borderAccent("│")} ${args.theme.fg("dim", "─".repeat(Math.max(1, contentWidth)))} ${borderAccent("│")}`;
1565
+ frameLines.push(footDiv);
1566
+ for (const line of footerRows) {
1567
+ const paddedLine = ` ${line}`;
1568
+ frameLines.push(
1569
+ `${borderAccent("│")}${pad(paddedLine, contentWidth + 4)}${borderAccent("│")}`,
1570
+ );
1571
+ }
1572
+
1573
+ // Action buttons — side by side on one line, styled like tabs.
1574
+ // Tab/Shift+Tab cycles through them as individual stops.
1575
+ if (actionRows.length > 0) {
1576
+ const actDiv = `${borderAccent("│")} ${args.theme.fg("dim", "─".repeat(Math.max(1, contentWidth)))} ${borderAccent("│")}`;
1577
+ frameLines.push(actDiv);
1578
+
1579
+ const cells: string[] = [];
1580
+ for (let ai = 0; ai < actionRows.length; ai++) {
1581
+ const isFocused =
1582
+ tabActionFocus >= tabs.length &&
1583
+ tabActionFocus - tabs.length === ai;
1584
+ const keyHint = ai === 0 ? " [ctrl+shift+r]" : " [ctrl+shift+d]";
1585
+ const padded = ` ${actionRows[ai]!.label}${keyHint} `;
1586
+ if (isFocused) {
1587
+ cells.push(
1588
+ args.theme.fg(
1589
+ "accent",
1590
+ args.theme.inverse(args.theme.bold(padded)),
1591
+ ),
1592
+ );
1593
+ } else {
1594
+ cells.push(
1595
+ args.theme.bg("selectedBg", args.theme.fg("accent", padded)),
1596
+ );
1597
+ }
1598
+ }
1599
+ const line = pad(cells.join(" "), contentWidth);
1600
+ frameLines.push(`${borderAccent("│")} ${line} ${borderAccent("│")}`);
1601
+ }
1602
+
1603
+ // Restore bottom padding and border
1604
+ frameLines.push(bottomPadding);
1605
+ frameLines.push(bottomBorder);
1606
+
1607
+ return frameLines;
1608
+ },
1609
+ invalidate(): void {
1610
+ submenu?.invalidate();
1611
+ },
1612
+ handleInput,
1613
+ };
1614
+ }
1615
+
1616
+ function submenuTitle(key: string | undefined): string | undefined {
1617
+ return key ? `${key} →` : undefined;
1618
+ }
1619
+
1620
+ function notifyError(ctx: ExtensionContext, err: unknown): void {
1621
+ const message = err instanceof Error ? err.message : String(err);
1622
+ try {
1623
+ ctx.ui.notify(message, "error");
1624
+ } catch {
1625
+ // Defensive: never let a bad notify call break the modal loop.
1626
+ }
1627
+ }
1628
+
1629
+ function extractInitialValue(field: Field): unknown {
1630
+ if (field.type === "action") return undefined;
1631
+ return (field as { value: unknown }).value;
1632
+ }
1633
+
1634
+ function getNestedValue(obj: unknown, path: string): unknown {
1635
+ const parts = path.split(".");
1636
+ let current = obj;
1637
+ for (const part of parts) {
1638
+ if (
1639
+ current === null ||
1640
+ current === undefined ||
1641
+ typeof current !== "object"
1642
+ ) {
1643
+ return undefined;
1644
+ }
1645
+ current = (current as Record<string, unknown>)[part];
1646
+ }
1647
+ return current;
1648
+ }