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.
- package/README.md +15 -0
- package/dist/index.js +11660 -0
- package/dist/index.js.map +1 -0
- package/example-config.json +1 -1
- package/index.ts +37 -63
- package/package.json +21 -9
- package/src/commands/cleanup.ts +279 -240
- package/src/commands/memory.ts +236 -184
- package/src/commands/pi-vcc.ts +202 -152
- package/src/commands/vcc-recall.ts +126 -95
- package/src/core/brief.ts +167 -33
- package/src/core/build-sections.ts +8 -2
- package/src/core/config-env.ts +117 -0
- package/src/core/content.ts +31 -7
- package/src/core/drill-down.ts +41 -11
- package/src/core/filter-noise.ts +9 -3
- package/src/core/format-recall.ts +15 -6
- package/src/core/format.ts +14 -4
- package/src/core/lineage.ts +9 -3
- package/src/core/load-messages.ts +24 -5
- package/src/core/normalize.ts +38 -14
- package/src/core/recall-scope.ts +11 -3
- package/src/core/render-entries.ts +22 -6
- package/src/core/sanitize.ts +5 -1
- package/src/core/search-entries.ts +111 -19
- package/src/core/settings.ts +1 -3
- package/src/core/summarize.ts +42 -21
- package/src/core/unified-config.ts +549 -411
- package/src/extract/commits.ts +4 -2
- package/src/extract/files.ts +10 -5
- package/src/extract/goals.ts +7 -2
- package/src/hooks/before-compact.ts +210 -88
- package/src/om/agents/dropper/agent.ts +380 -265
- package/src/om/agents/dropper/coverage.ts +102 -82
- package/src/om/agents/observer/agent.ts +242 -206
- package/src/om/agents/reflector/agent.ts +212 -153
- package/src/om/cleanup.ts +239 -218
- package/src/om/clipboard.ts +59 -51
- package/src/om/compaction-trigger.ts +448 -333
- package/src/om/config.ts +13 -6
- package/src/om/configure-overlay.ts +518 -355
- package/src/om/consolidation.ts +1460 -953
- package/src/om/cooldown.ts +75 -65
- package/src/om/debug-log.ts +86 -68
- package/src/om/ids.ts +1 -1
- package/src/om/ledger/fold.ts +89 -78
- package/src/om/ledger/progress.ts +181 -153
- package/src/om/ledger/projection.ts +248 -185
- package/src/om/ledger/recall.ts +247 -196
- package/src/om/ledger/render-summary.ts +79 -50
- package/src/om/ledger/types.ts +146 -117
- package/src/om/model-budget.ts +23 -13
- package/src/om/pending.ts +243 -179
- package/src/om/provider-stream.ts +52 -7
- package/src/om/retryable-error.ts +12 -16
- package/src/om/reverse-recall.ts +97 -91
- package/src/om/runtime.ts +474 -375
- package/src/om/serialize.ts +190 -166
- package/src/om/status-overlay.ts +246 -195
- package/src/om/tokens.ts +28 -21
- package/src/pi-base/blackhole-settings.ts +437 -0
- package/src/pi-base/config-manager.ts +440 -0
- package/src/pi-base/config.ts +469 -0
- package/src/pi-base/env.ts +43 -0
- package/src/pi-base/paths.ts +47 -0
- package/src/pi-base/settings/body.ts +1648 -0
- package/src/pi-base/settings/fields/action.ts +43 -0
- package/src/pi-base/settings/fields/boolean.ts +47 -0
- package/src/pi-base/settings/fields/custom.ts +72 -0
- package/src/pi-base/settings/fields/enum.ts +310 -0
- package/src/pi-base/settings/fields/index.ts +46 -0
- package/src/pi-base/settings/fields/model.ts +452 -0
- package/src/pi-base/settings/fields/string.ts +527 -0
- package/src/pi-base/settings/fields/text.ts +115 -0
- package/src/pi-base/settings/frame.ts +197 -0
- package/src/pi-base/settings/index.ts +77 -0
- package/src/pi-base/settings/inline-edit.ts +313 -0
- package/src/pi-base/settings/modal.ts +152 -0
- package/src/pi-base/settings/types.ts +500 -0
- package/src/pi-base/settings/validate-field.ts +113 -0
- package/src/pi-base/shell.ts +117 -0
- package/src/pi-base/types.ts +6 -0
- package/src/pi-base/ui.ts +32 -0
- package/src/tools/recall.ts +347 -225
- package/src/types.ts +20 -3
- package/tsup.config.ts +23 -0
- 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
|
+
}
|