pi-openai-codex-compat 0.0.15 → 0.0.17
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/CHANGELOG.md +29 -0
- package/README.md +69 -12
- package/extensions/openai-codex-compat/command-tools.ts +2 -0
- package/extensions/openai-codex-compat/config.ts +2 -56
- package/extensions/openai-codex-compat/index.ts +9 -3
- package/extensions/openai-codex-compat/responses-lite.ts +2 -0
- package/extensions/openai-codex-compat/settings-pane.ts +227 -536
- package/extensions/settings-menu.ts +618 -0
- package/extensions/settings-session.ts +130 -0
- package/extensions/settings-store.ts +240 -0
- package/package.json +4 -4
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
decodeKittyPrintable,
|
|
4
|
+
Input,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
SelectList,
|
|
8
|
+
ScrollView,
|
|
9
|
+
Text,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
type Component,
|
|
13
|
+
type Focusable,
|
|
14
|
+
type SelectItem,
|
|
15
|
+
type TUI,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
17
|
+
import {
|
|
18
|
+
SettingsStore,
|
|
19
|
+
sessionBaseline,
|
|
20
|
+
type SettingsSessionState,
|
|
21
|
+
type SettingChanges,
|
|
22
|
+
type SettingsSnapshot,
|
|
23
|
+
type SettingValue,
|
|
24
|
+
type SettingValues,
|
|
25
|
+
} from "./settings-store.ts";
|
|
26
|
+
type MenuFocus = "search" | "results" | "actions";
|
|
27
|
+
function isSearchText(data: string): boolean {
|
|
28
|
+
if (decodeKittyPrintable(data) !== undefined || data.includes("\u001b[200~")) return true;
|
|
29
|
+
for (let index = 0; index < data.length; index++) {
|
|
30
|
+
const code = data.charCodeAt(index);
|
|
31
|
+
if (code < 32 || code === 127 || (code >= 128 && code <= 159)) return false;
|
|
32
|
+
}
|
|
33
|
+
return data.length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Keep this provider-independent contract aligned with pi-anthropic-compat.
|
|
37
|
+
export type SettingsField = {
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
description: string;
|
|
41
|
+
choices: SettingValue[];
|
|
42
|
+
number?: { min: number; max: number; integer?: boolean; exclusiveMin?: boolean; unit: string };
|
|
43
|
+
format?: (value: SettingValue) => string;
|
|
44
|
+
};
|
|
45
|
+
export type SettingsMenuOptions = {
|
|
46
|
+
title: string;
|
|
47
|
+
store: SettingsStore;
|
|
48
|
+
snapshot: SettingsSnapshot;
|
|
49
|
+
current: SettingValues;
|
|
50
|
+
session: SettingsSessionState;
|
|
51
|
+
fields: SettingsField[];
|
|
52
|
+
status: (values: SettingValues) => string;
|
|
53
|
+
prepare: (signal: AbortSignal) => Promise<void>;
|
|
54
|
+
guard: (values: SettingValues) => void;
|
|
55
|
+
apply: (values: SettingValues, session: SettingsSessionState) => void;
|
|
56
|
+
};
|
|
57
|
+
export type SettingsMenuFactory<T> = (
|
|
58
|
+
tui: Pick<TUI, "requestRender"> & { terminal?: { rows: number } },
|
|
59
|
+
theme: Pick<Theme, "fg" | "bold">,
|
|
60
|
+
keys: Pick<KeybindingsManager, "matches" | "getKeys">,
|
|
61
|
+
done: (value: T) => void,
|
|
62
|
+
) => Component & { dispose?: () => void };
|
|
63
|
+
|
|
64
|
+
export function settingsMenu(options: SettingsMenuOptions): SettingsMenuFactory<undefined> {
|
|
65
|
+
return (tui, theme, keys, done) => {
|
|
66
|
+
let baseline = sessionBaseline(options.snapshot, options.session);
|
|
67
|
+
let active = { ...options.current };
|
|
68
|
+
let draft = { ...active };
|
|
69
|
+
let changes: SettingChanges = { ...options.session.changes };
|
|
70
|
+
let notice = Object.keys(changes).length
|
|
71
|
+
? "Session settings shown. Not saved to configuration files."
|
|
72
|
+
: "Draft changes apply only when applied or saved.";
|
|
73
|
+
let failed = false;
|
|
74
|
+
let pendingApply = false;
|
|
75
|
+
let disposed = false;
|
|
76
|
+
let busy = false;
|
|
77
|
+
let operation: "apply" | "save" = "save";
|
|
78
|
+
let saveAbort: AbortController | undefined;
|
|
79
|
+
let focused = false;
|
|
80
|
+
let focus: MenuFocus = "results";
|
|
81
|
+
let selectedId = options.fields[0]?.id;
|
|
82
|
+
let child:
|
|
83
|
+
| {
|
|
84
|
+
field: SettingsField;
|
|
85
|
+
list?: SelectList;
|
|
86
|
+
input?: Input;
|
|
87
|
+
choices?: SelectItem[];
|
|
88
|
+
visible?: number;
|
|
89
|
+
}
|
|
90
|
+
| undefined;
|
|
91
|
+
const search = new Input({
|
|
92
|
+
placeholder: "Search settings",
|
|
93
|
+
placeholderStyle: (text) => theme.fg("muted", text),
|
|
94
|
+
});
|
|
95
|
+
const listTheme = {
|
|
96
|
+
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
97
|
+
selectedText: (text: string) => theme.fg("accent", text),
|
|
98
|
+
description: (text: string) => theme.fg("muted", text),
|
|
99
|
+
scrollInfo: (text: string) => theme.fg("dim", text),
|
|
100
|
+
noMatch: (text: string) => theme.fg("dim", text),
|
|
101
|
+
};
|
|
102
|
+
const focusTheme = (area: MenuFocus) => ({
|
|
103
|
+
...listTheme,
|
|
104
|
+
selectedPrefix: (text: string) => theme.fg(child || focus === area ? "accent" : "dim", text),
|
|
105
|
+
selectedText: (text: string) => theme.fg(child || focus === area ? "accent" : "text", text),
|
|
106
|
+
});
|
|
107
|
+
let list = new SelectList([], 1, focusTheme("results"));
|
|
108
|
+
let listSignature = "";
|
|
109
|
+
let details: ScrollView | undefined;
|
|
110
|
+
const actions = new SelectList(
|
|
111
|
+
[
|
|
112
|
+
{ value: "apply", label: "Apply to session" },
|
|
113
|
+
{ value: "save", label: "Save and close" },
|
|
114
|
+
{ value: "inherit", label: "Use inherited value for selected setting" },
|
|
115
|
+
{ value: "details", label: "Details, errors, and save target" },
|
|
116
|
+
],
|
|
117
|
+
4,
|
|
118
|
+
focusTheme("actions"),
|
|
119
|
+
);
|
|
120
|
+
let zones: { component: Component; top: number; height: number; focus: MenuFocus }[] = [];
|
|
121
|
+
const draftChanged = (id: string) =>
|
|
122
|
+
draft[id] !== active[id] ||
|
|
123
|
+
Object.hasOwn(changes, id) !== Object.hasOwn(options.session.changes, id) ||
|
|
124
|
+
changes[id] !== options.session.changes[id];
|
|
125
|
+
const dirty = () => options.fields.filter((field) => draftChanged(field.id)).length;
|
|
126
|
+
const source = (id: string) =>
|
|
127
|
+
options.store.locked[id]
|
|
128
|
+
? baseline.sources[id]
|
|
129
|
+
: draftChanged(id)
|
|
130
|
+
? "draft"
|
|
131
|
+
: Object.hasOwn(options.session.changes, id) || draft[id] !== baseline.values[id]
|
|
132
|
+
? "session"
|
|
133
|
+
: baseline.sources[id];
|
|
134
|
+
const fieldValue = (field: SettingsField, value: SettingValue | undefined): string =>
|
|
135
|
+
value === undefined
|
|
136
|
+
? ""
|
|
137
|
+
: (field.format?.(value) ??
|
|
138
|
+
(typeof value === "boolean" ? (value ? "on" : "off") : String(value)));
|
|
139
|
+
const keyName = (id: Parameters<typeof keys.getKeys>[0], compact = false) => {
|
|
140
|
+
const bindings = keys.getKeys(id);
|
|
141
|
+
return (compact ? bindings[0] : bindings.join("/")) || "unbound";
|
|
142
|
+
};
|
|
143
|
+
const updateFocus = () => {
|
|
144
|
+
search.focused = focused && focus === "search" && !child && !details;
|
|
145
|
+
if (child?.input) child.input.focused = focused;
|
|
146
|
+
};
|
|
147
|
+
const close = () => {
|
|
148
|
+
if (disposed) return;
|
|
149
|
+
disposed = true;
|
|
150
|
+
saveAbort?.abort();
|
|
151
|
+
done(undefined);
|
|
152
|
+
};
|
|
153
|
+
const change = (field: SettingsField, value: SettingValue | undefined) => {
|
|
154
|
+
if (options.store.locked[field.id]) return;
|
|
155
|
+
const target =
|
|
156
|
+
baseline.file === options.store.globalFile ? baseline.global : baseline.project;
|
|
157
|
+
if (value === undefined) {
|
|
158
|
+
if (Object.hasOwn(target.data, field.id)) changes[field.id] = undefined;
|
|
159
|
+
else delete changes[field.id];
|
|
160
|
+
const inherited = baseline.inherited[field.id];
|
|
161
|
+
if (inherited !== undefined) draft[field.id] = inherited;
|
|
162
|
+
} else {
|
|
163
|
+
draft[field.id] = value;
|
|
164
|
+
if (value === active[field.id]) {
|
|
165
|
+
if (Object.hasOwn(options.session.changes, field.id))
|
|
166
|
+
changes[field.id] = options.session.changes[field.id];
|
|
167
|
+
else delete changes[field.id];
|
|
168
|
+
} else if (value === baseline.values[field.id]) delete changes[field.id];
|
|
169
|
+
else changes[field.id] = value;
|
|
170
|
+
}
|
|
171
|
+
failed = false;
|
|
172
|
+
notice = dirty()
|
|
173
|
+
? `${dirty()} draft change(s). Not applied.`
|
|
174
|
+
: Object.keys(changes).length
|
|
175
|
+
? "No draft changes. Session settings are not saved to configuration files."
|
|
176
|
+
: "No unsaved changes.";
|
|
177
|
+
child = undefined;
|
|
178
|
+
updateFocus();
|
|
179
|
+
};
|
|
180
|
+
const activate = (field: SettingsField) => {
|
|
181
|
+
if (options.store.locked[field.id]) {
|
|
182
|
+
notice = `Locked by ${options.store.locked[field.id]}.`;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const current = draft[field.id];
|
|
186
|
+
if (current === undefined) return;
|
|
187
|
+
if (typeof current === "boolean") {
|
|
188
|
+
change(field, !current);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const choices: SelectItem[] = field.choices.map((value, index) => ({
|
|
192
|
+
value: String(index),
|
|
193
|
+
label: fieldValue(field, value),
|
|
194
|
+
}));
|
|
195
|
+
if (field.number) choices.push({ value: "custom", label: "Custom value…" });
|
|
196
|
+
choices.push({
|
|
197
|
+
value: "inherit",
|
|
198
|
+
label: `Use inherited value (${fieldValue(field, baseline.inherited[field.id])})`,
|
|
199
|
+
});
|
|
200
|
+
const picker = new SelectList(choices, 6, listTheme);
|
|
201
|
+
const index = field.choices.indexOf(current);
|
|
202
|
+
picker.setSelectedIndex(index < 0 ? field.choices.length : index);
|
|
203
|
+
picker.onSelect = (item) => {
|
|
204
|
+
if (item.value === "custom") {
|
|
205
|
+
const input = new Input();
|
|
206
|
+
input.handleInput(`\u001b[200~${current === null ? "" : String(current)}\u001b[201~`);
|
|
207
|
+
child = { field, input };
|
|
208
|
+
updateFocus();
|
|
209
|
+
} else {
|
|
210
|
+
change(field, item.value === "inherit" ? undefined : field.choices[Number(item.value)]);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
child = { field, list: picker, choices };
|
|
214
|
+
notice = "Choose a value. Changes remain a draft.";
|
|
215
|
+
updateFocus();
|
|
216
|
+
};
|
|
217
|
+
const commit = async (mode: "apply" | "save", closeAfter = false) => {
|
|
218
|
+
if (busy || disposed) return;
|
|
219
|
+
if (!dirty() && !pendingApply && (mode === "apply" || !Object.keys(changes).length)) {
|
|
220
|
+
notice = mode === "apply" ? "No draft changes to apply." : "No changes to save.";
|
|
221
|
+
if (closeAfter) close();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
busy = true;
|
|
225
|
+
operation = mode;
|
|
226
|
+
failed = false;
|
|
227
|
+
saveAbort = new AbortController();
|
|
228
|
+
const { signal } = saveAbort;
|
|
229
|
+
notice =
|
|
230
|
+
mode === "save" ? "Waiting for idle, then saving…" : "Waiting for idle, then applying…";
|
|
231
|
+
tui.requestRender();
|
|
232
|
+
try {
|
|
233
|
+
await options.prepare(signal);
|
|
234
|
+
signal.throwIfAborted();
|
|
235
|
+
if (mode === "save") {
|
|
236
|
+
baseline = await options.store.save(baseline, changes, signal, options.guard);
|
|
237
|
+
draft = { ...baseline.values };
|
|
238
|
+
changes = {};
|
|
239
|
+
pendingApply = true;
|
|
240
|
+
}
|
|
241
|
+
signal.throwIfAborted();
|
|
242
|
+
options.guard(draft);
|
|
243
|
+
options.apply(
|
|
244
|
+
{ ...draft },
|
|
245
|
+
{
|
|
246
|
+
changes: { ...changes },
|
|
247
|
+
...(Object.keys(changes).length ? { baseline } : {}),
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
active = { ...draft };
|
|
251
|
+
options.session.changes = { ...changes };
|
|
252
|
+
if (Object.keys(changes).length) options.session.baseline = baseline;
|
|
253
|
+
else delete options.session.baseline;
|
|
254
|
+
pendingApply = false;
|
|
255
|
+
notice =
|
|
256
|
+
mode === "save"
|
|
257
|
+
? "Saved and applied."
|
|
258
|
+
: "Applied to session. Configuration files unchanged.";
|
|
259
|
+
if (closeAfter) close();
|
|
260
|
+
} catch (error) {
|
|
261
|
+
failed = true;
|
|
262
|
+
notice = pendingApply
|
|
263
|
+
? "Saved to disk; session application failed. Ctrl+S retries; reopen to review."
|
|
264
|
+
: signal.aborted
|
|
265
|
+
? `${mode === "save" ? "Save" : "Application"} cancelled. Draft retained.`
|
|
266
|
+
: error instanceof Error
|
|
267
|
+
? error.message
|
|
268
|
+
: "Could not update settings.";
|
|
269
|
+
} finally {
|
|
270
|
+
busy = false;
|
|
271
|
+
if (!disposed) tui.requestRender();
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const saveFailed = (error: unknown) => {
|
|
275
|
+
busy = false;
|
|
276
|
+
failed = true;
|
|
277
|
+
notice = error instanceof Error ? error.message : "Could not finish saving.";
|
|
278
|
+
if (!disposed) tui.requestRender();
|
|
279
|
+
};
|
|
280
|
+
const showDetails = () => {
|
|
281
|
+
const field = options.fields.find((entry) => entry.id === selectedId);
|
|
282
|
+
const content = [
|
|
283
|
+
notice,
|
|
284
|
+
`Save target: ${baseline.file}`,
|
|
285
|
+
options.status(draft),
|
|
286
|
+
...(field
|
|
287
|
+
? [
|
|
288
|
+
`${field.label}: ${fieldValue(field, draft[field.id])}`,
|
|
289
|
+
`Source: ${source(field.id)}`,
|
|
290
|
+
...(draft[field.id] !== baseline.values[field.id]
|
|
291
|
+
? [`Saved value: ${fieldValue(field, baseline.values[field.id])}`]
|
|
292
|
+
: []),
|
|
293
|
+
field.description,
|
|
294
|
+
]
|
|
295
|
+
: []),
|
|
296
|
+
];
|
|
297
|
+
details = new ScrollView(new Text(content.join("\n\n"), 0, 0), { scrollbar: "hidden" });
|
|
298
|
+
search.focused = false;
|
|
299
|
+
};
|
|
300
|
+
actions.onSelect = (item) => {
|
|
301
|
+
if (item.value === "apply") commit("apply").catch(saveFailed);
|
|
302
|
+
else if (item.value === "save") commit("save", true).catch(saveFailed);
|
|
303
|
+
else if (item.value === "details") showDetails();
|
|
304
|
+
else {
|
|
305
|
+
const field = options.fields.find((entry) => entry.id === selectedId);
|
|
306
|
+
if (field) change(field, undefined);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
const selectedField = () => options.fields.find((field) => field.id === selectedId);
|
|
310
|
+
const confirmNumber = () => {
|
|
311
|
+
if (!child?.input || !child.field.number) return;
|
|
312
|
+
const raw = child.input.getValue().trim();
|
|
313
|
+
const value = Number(raw);
|
|
314
|
+
const rule = child.field.number;
|
|
315
|
+
if (
|
|
316
|
+
!/^\d+(?:\.\d+)?$/.test(raw) ||
|
|
317
|
+
!Number.isFinite(value) ||
|
|
318
|
+
(rule.integer && !Number.isInteger(value)) ||
|
|
319
|
+
(rule.exclusiveMin ? value <= rule.min : value < rule.min) ||
|
|
320
|
+
value > rule.max
|
|
321
|
+
) {
|
|
322
|
+
failed = true;
|
|
323
|
+
notice = `Enter ${rule.integer ? "an integer" : "a number"} ${rule.exclusiveMin ? ">" : "≥"} ${rule.min} and ≤ ${rule.max} ${rule.unit}.`;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
change(child.field, value);
|
|
327
|
+
};
|
|
328
|
+
const component: Component & Focusable & { dispose: () => void } = {
|
|
329
|
+
get focused() {
|
|
330
|
+
return focused;
|
|
331
|
+
},
|
|
332
|
+
set focused(value) {
|
|
333
|
+
focused = value;
|
|
334
|
+
updateFocus();
|
|
335
|
+
},
|
|
336
|
+
dispose() {
|
|
337
|
+
disposed = true;
|
|
338
|
+
saveAbort?.abort();
|
|
339
|
+
},
|
|
340
|
+
invalidate() {
|
|
341
|
+
search.invalidate();
|
|
342
|
+
list.invalidate();
|
|
343
|
+
child?.input?.invalidate();
|
|
344
|
+
child?.list?.invalidate();
|
|
345
|
+
},
|
|
346
|
+
render(width) {
|
|
347
|
+
zones = [];
|
|
348
|
+
const lines: string[] = [];
|
|
349
|
+
const height = Math.max(9, (tui.terminal?.rows ?? 24) - 3);
|
|
350
|
+
const text = (value: string, color: "accent" | "dim" | "error" | "muted" = "dim") =>
|
|
351
|
+
lines.push(truncateToWidth(theme.fg(color, value), width));
|
|
352
|
+
const zone = (part: Component, target: typeof focus) => {
|
|
353
|
+
const rendered = part.render(width);
|
|
354
|
+
zones.push({
|
|
355
|
+
component: part,
|
|
356
|
+
top: lines.length,
|
|
357
|
+
height: rendered.length,
|
|
358
|
+
focus: target,
|
|
359
|
+
});
|
|
360
|
+
lines.push(...rendered);
|
|
361
|
+
};
|
|
362
|
+
text(options.title, "accent");
|
|
363
|
+
if (details) {
|
|
364
|
+
const content = details.render(width);
|
|
365
|
+
details.updateLayout(content.length, height - 2, () => tui.requestRender());
|
|
366
|
+
lines.push(...content.slice(details.scrollTop, details.scrollTop + height - 2));
|
|
367
|
+
text(
|
|
368
|
+
`${keyName("tui.select.up")}/${keyName("tui.select.down")} scroll · ${keyName("tui.select.cancel")} back`,
|
|
369
|
+
);
|
|
370
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
371
|
+
}
|
|
372
|
+
if (height > 11) text(`Save to: ${baseline.file}`);
|
|
373
|
+
const statusLines = new Text(notice, 0, 0).render(width);
|
|
374
|
+
const statusBudget = Math.max(1, Math.min(statusLines.length, height - 9));
|
|
375
|
+
for (const line of statusLines.slice(0, statusBudget)) text(line, failed ? "error" : "dim");
|
|
376
|
+
if (child) {
|
|
377
|
+
text(`${child.field.label}: ${fieldValue(child.field, draft[child.field.id])}`, "accent");
|
|
378
|
+
if (child.field.number) {
|
|
379
|
+
const rule = child.field.number;
|
|
380
|
+
text(
|
|
381
|
+
`${rule.exclusiveMin ? ">" : "≥"} ${rule.min} to ${rule.max} ${rule.unit}${rule.integer ? "; whole numbers" : ""}`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const visible = Math.max(1, height - lines.length - 2);
|
|
385
|
+
if (child.list && child.choices && child.visible !== visible) {
|
|
386
|
+
const previous = child.list;
|
|
387
|
+
const selected = previous.getSelectedItem()?.value;
|
|
388
|
+
child.list = new SelectList(child.choices, visible, listTheme);
|
|
389
|
+
child.list.setSelectedIndex(
|
|
390
|
+
Math.max(
|
|
391
|
+
0,
|
|
392
|
+
child.choices.findIndex((item) => item.value === selected),
|
|
393
|
+
),
|
|
394
|
+
);
|
|
395
|
+
if (previous.onSelect) child.list.onSelect = previous.onSelect;
|
|
396
|
+
child.visible = visible;
|
|
397
|
+
}
|
|
398
|
+
const editor = child.input ?? child.list;
|
|
399
|
+
if (editor) zone(editor, "results");
|
|
400
|
+
text(`${keyName("tui.select.confirm")} select · ${keyName("tui.select.cancel")} back`);
|
|
401
|
+
} else {
|
|
402
|
+
const hint = `${keyName("tui.select.confirm")} ${focus === "actions" ? "select" : "change"} · Space ${focus === "search" ? "search" : "select"} · ${keyName("tui.input.tab")} focus · Ctrl+S save · ${keyName("tui.select.cancel")} discard · F1 details`;
|
|
403
|
+
const hints = busy
|
|
404
|
+
? [`${keyName("tui.select.cancel", true)} cancel ${operation}`]
|
|
405
|
+
: visibleWidth(hint) <= width
|
|
406
|
+
? [hint]
|
|
407
|
+
: width >= 40
|
|
408
|
+
? [
|
|
409
|
+
`${keyName("tui.select.confirm", true)} ${focus === "actions" ? "select" : "change"} · ${keyName("tui.input.tab", true)} focus`,
|
|
410
|
+
`Ctrl+S save · ${keyName("tui.select.cancel", true)} discard`,
|
|
411
|
+
]
|
|
412
|
+
: ["Ctrl+S save", `${keyName("tui.select.cancel", true)} discard`];
|
|
413
|
+
const footerRows = 4 + hints.length;
|
|
414
|
+
// Reserve search, up to three settings, a scroll indicator, and two
|
|
415
|
+
// detail rows before spending the remaining height on five section gaps.
|
|
416
|
+
const minimumContentRows = 1 + Math.min(3, options.fields.length) + 1 + 2;
|
|
417
|
+
const gap = height - lines.length - footerRows - minimumContentRows >= 5 ? 1 : 0;
|
|
418
|
+
if (gap) lines.push("");
|
|
419
|
+
zone(search, "search");
|
|
420
|
+
if (gap) lines.push("");
|
|
421
|
+
const query = search.getValue().toLocaleLowerCase().trim().split(/\s+/);
|
|
422
|
+
const fields = options.fields.filter((field) => {
|
|
423
|
+
const content = `${field.label} ${field.id} ${field.description}`.toLocaleLowerCase();
|
|
424
|
+
return query.every((word) => content.includes(word));
|
|
425
|
+
});
|
|
426
|
+
// SelectList reserves two columns for its cursor and two for its right margin.
|
|
427
|
+
// Measure every field, not only matches, so filtering cannot move the value column.
|
|
428
|
+
const contentWidth = Math.max(1, width - 4);
|
|
429
|
+
const widestLabel = Math.max(...options.fields.map((field) => visibleWidth(field.label)));
|
|
430
|
+
const widestValue = Math.max(
|
|
431
|
+
...options.fields.map((field) => {
|
|
432
|
+
const values = [
|
|
433
|
+
...field.choices,
|
|
434
|
+
draft[field.id] ?? null,
|
|
435
|
+
...(field.number ? [field.number.min, field.number.max] : []),
|
|
436
|
+
];
|
|
437
|
+
return (
|
|
438
|
+
Math.max(...values.map((value) => visibleWidth(fieldValue(field, value)))) +
|
|
439
|
+
(options.store.locked[field.id] ? 6 : 2)
|
|
440
|
+
);
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
const labelWidth = Math.min(
|
|
444
|
+
widestLabel,
|
|
445
|
+
Math.max(1, contentWidth - 2 - Math.min(widestValue, Math.floor(contentWidth / 2))),
|
|
446
|
+
);
|
|
447
|
+
const items = fields.map((field) => {
|
|
448
|
+
const label = truncateToWidth(field.label, labelWidth);
|
|
449
|
+
const value = `${fieldValue(field, draft[field.id])}${draftChanged(field.id) ? " *" : source(field.id) === "session" ? " ~" : ""}${options.store.locked[field.id] ? " (env)" : ""}`;
|
|
450
|
+
return {
|
|
451
|
+
value: field.id,
|
|
452
|
+
label: `${label}${" ".repeat(labelWidth - visibleWidth(label) + 2)}${truncateToWidth(value, Math.max(1, contentWidth - labelWidth - 2))}`,
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
const activeId = list.getSelectedItem()?.value ?? selectedId;
|
|
456
|
+
const visible = Math.max(1, height - lines.length - footerRows - 2 - 3 * gap);
|
|
457
|
+
const signature = JSON.stringify([items, visible]);
|
|
458
|
+
if (signature !== listSignature) {
|
|
459
|
+
listSignature = signature;
|
|
460
|
+
list = new SelectList(items, visible, focusTheme("results"), {
|
|
461
|
+
maxPrimaryColumnWidth: 1000,
|
|
462
|
+
});
|
|
463
|
+
list.setSelectedIndex(
|
|
464
|
+
Math.max(
|
|
465
|
+
0,
|
|
466
|
+
items.findIndex((item) => item.value === activeId),
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
selectedId = list.getSelectedItem()?.value;
|
|
471
|
+
list.onSelect = (item) => {
|
|
472
|
+
const field = options.fields.find((entry) => entry.id === item.value);
|
|
473
|
+
if (field) activate(field);
|
|
474
|
+
};
|
|
475
|
+
list.onSelectionChange = (item) => {
|
|
476
|
+
selectedId = item.value;
|
|
477
|
+
};
|
|
478
|
+
zone(list, "results");
|
|
479
|
+
if (gap) lines.push("");
|
|
480
|
+
const field = selectedField();
|
|
481
|
+
if (height - lines.length > footerRows + 2 * gap) {
|
|
482
|
+
text(
|
|
483
|
+
field ? `${source(field.id)} · ${field.description}` : "No matching settings.",
|
|
484
|
+
"muted",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
if (height - lines.length > footerRows + 2 * gap) text(options.status(draft), "muted");
|
|
488
|
+
if (gap) lines.push("");
|
|
489
|
+
zone(actions, "actions");
|
|
490
|
+
if (gap) lines.push("");
|
|
491
|
+
for (const line of hints) text(line);
|
|
492
|
+
}
|
|
493
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
494
|
+
},
|
|
495
|
+
handleInput(data) {
|
|
496
|
+
if (disposed) return;
|
|
497
|
+
if (busy) {
|
|
498
|
+
if (keys.matches(data, "tui.select.cancel")) saveAbort?.abort();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (details) {
|
|
502
|
+
if (keys.matches(data, "tui.select.cancel")) {
|
|
503
|
+
details = undefined;
|
|
504
|
+
updateFocus();
|
|
505
|
+
} else if (keys.matches(data, "tui.select.up")) details.scrollBy(-1);
|
|
506
|
+
else if (keys.matches(data, "tui.select.down")) details.scrollBy(1);
|
|
507
|
+
else if (keys.matches(data, "tui.select.pageUp"))
|
|
508
|
+
details.scrollBy(-details.viewportHeight);
|
|
509
|
+
else if (keys.matches(data, "tui.select.pageDown"))
|
|
510
|
+
details.scrollBy(details.viewportHeight);
|
|
511
|
+
tui.requestRender();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (!child && matchesKey(data, "f1")) {
|
|
515
|
+
showDetails();
|
|
516
|
+
tui.requestRender();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (keys.matches(data, "tui.select.cancel")) {
|
|
520
|
+
if (child) {
|
|
521
|
+
child = undefined;
|
|
522
|
+
failed = false;
|
|
523
|
+
notice = "Field edit cancelled.";
|
|
524
|
+
updateFocus();
|
|
525
|
+
} else close();
|
|
526
|
+
} else if (child) {
|
|
527
|
+
if (matchesKey(data, Key.ctrl("s"))) return;
|
|
528
|
+
if (keys.matches(data, "tui.select.confirm")) {
|
|
529
|
+
if (child.input) confirmNumber();
|
|
530
|
+
else {
|
|
531
|
+
const item = child.list?.getSelectedItem();
|
|
532
|
+
if (item) child.list?.onSelect?.(item);
|
|
533
|
+
}
|
|
534
|
+
} else if (child.input) child.input.handleInput(data);
|
|
535
|
+
else if (matchesKey(data, Key.space)) {
|
|
536
|
+
const item = child.list?.getSelectedItem();
|
|
537
|
+
if (item) child.list?.onSelect?.(item);
|
|
538
|
+
} else child.list?.handleInput(data);
|
|
539
|
+
} else if (matchesKey(data, Key.ctrl("s"))) {
|
|
540
|
+
commit("save").catch(saveFailed);
|
|
541
|
+
} else if (keys.matches(data, "tui.input.tab") || matchesKey(data, Key.shift("tab"))) {
|
|
542
|
+
const order = ["search", "results", "actions"] as const;
|
|
543
|
+
focus =
|
|
544
|
+
order[(order.indexOf(focus) + (matchesKey(data, Key.shift("tab")) ? 2 : 1)) % 3] ??
|
|
545
|
+
"results";
|
|
546
|
+
updateFocus();
|
|
547
|
+
} else if (
|
|
548
|
+
keys.matches(data, "tui.select.confirm") ||
|
|
549
|
+
(focus !== "search" && matchesKey(data, Key.space))
|
|
550
|
+
) {
|
|
551
|
+
const target = focus === "actions" ? actions : list;
|
|
552
|
+
const item = target.getSelectedItem();
|
|
553
|
+
if (item) target.onSelect?.(item);
|
|
554
|
+
} else if (
|
|
555
|
+
keys.matches(data, "tui.select.up") ||
|
|
556
|
+
keys.matches(data, "tui.select.down") ||
|
|
557
|
+
keys.matches(data, "tui.select.pageUp") ||
|
|
558
|
+
keys.matches(data, "tui.select.pageDown")
|
|
559
|
+
) {
|
|
560
|
+
if (focus === "search") focus = "results";
|
|
561
|
+
else (focus === "actions" ? actions : list).handleInput(data);
|
|
562
|
+
updateFocus();
|
|
563
|
+
} else {
|
|
564
|
+
const printable = isSearchText(data);
|
|
565
|
+
if (
|
|
566
|
+
focus === "search" ||
|
|
567
|
+
printable ||
|
|
568
|
+
keys.matches(data, "tui.editor.deleteCharBackward")
|
|
569
|
+
) {
|
|
570
|
+
focus = "search";
|
|
571
|
+
updateFocus();
|
|
572
|
+
search.handleInput(data);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (!disposed) tui.requestRender();
|
|
576
|
+
},
|
|
577
|
+
handleMouse(event) {
|
|
578
|
+
if (disposed || busy) return undefined;
|
|
579
|
+
if (details) {
|
|
580
|
+
if (event.type !== "wheel" || !event.wheelDelta) return undefined;
|
|
581
|
+
details.scrollBy(event.wheelDelta);
|
|
582
|
+
tui.requestRender();
|
|
583
|
+
return { handled: true };
|
|
584
|
+
}
|
|
585
|
+
const target = zones.find(
|
|
586
|
+
(entry) => event.y >= entry.top && event.y < entry.top + entry.height,
|
|
587
|
+
);
|
|
588
|
+
if (!target) return undefined;
|
|
589
|
+
focus = target.focus;
|
|
590
|
+
updateFocus();
|
|
591
|
+
const result = target.component.handleMouse?.({
|
|
592
|
+
...event,
|
|
593
|
+
y: event.y - target.top,
|
|
594
|
+
height: target.height,
|
|
595
|
+
});
|
|
596
|
+
if (result) tui.requestRender();
|
|
597
|
+
return result ? { ...result, focus: true } : undefined;
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
return component;
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Waiting never allows a disposed menu to apply changes to a replacement session. */
|
|
605
|
+
export async function waitForSettingsIdle(
|
|
606
|
+
wait: () => Promise<void>,
|
|
607
|
+
signal: AbortSignal,
|
|
608
|
+
): Promise<void> {
|
|
609
|
+
signal.throwIfAborted();
|
|
610
|
+
await new Promise<void>((resolve, reject) => {
|
|
611
|
+
const abort = () => reject(new Error("Settings update cancelled."));
|
|
612
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
613
|
+
void wait()
|
|
614
|
+
.then(resolve, reject)
|
|
615
|
+
.finally(() => signal.removeEventListener("abort", abort));
|
|
616
|
+
});
|
|
617
|
+
signal.throwIfAborted();
|
|
618
|
+
}
|