@dex-ai/coding-agent 0.1.92
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/bin/dex.ts +402 -0
- package/package.json +45 -0
- package/src/__tests__/command-validation.test.ts +205 -0
- package/src/__tests__/history.test.ts +183 -0
- package/src/cli-extension.ts +153 -0
- package/src/commands/extension-loader.ts +399 -0
- package/src/commands/extension.ts +924 -0
- package/src/commands/update.ts +419 -0
- package/src/env.d.ts +5 -0
- package/src/extensions/cli-tui-components/ActivityPanel.vue +24 -0
- package/src/extensions/cli-tui-components/ActivityPanel.vue.compiled.ts +96 -0
- package/src/extensions/cli-tui-components/App.vue +127 -0
- package/src/extensions/cli-tui-components/App.vue.compiled.ts +374 -0
- package/src/extensions/cli-tui-components/ApprovalPrompt.vue +30 -0
- package/src/extensions/cli-tui-components/ApprovalPrompt.vue.compiled.ts +72 -0
- package/src/extensions/cli-tui-components/AskPanel.vue +228 -0
- package/src/extensions/cli-tui-components/AskPanel.vue.compiled.ts +419 -0
- package/src/extensions/cli-tui-components/CommandPalette.vue +19 -0
- package/src/extensions/cli-tui-components/CommandPalette.vue.compiled.ts +65 -0
- package/src/extensions/cli-tui-components/ConfirmModal.vue +29 -0
- package/src/extensions/cli-tui-components/ConfirmModal.vue.compiled.ts +72 -0
- package/src/extensions/cli-tui-components/DiffView.vue +139 -0
- package/src/extensions/cli-tui-components/DiffView.vue.compiled.ts +274 -0
- package/src/extensions/cli-tui-components/FormModal.vue +58 -0
- package/src/extensions/cli-tui-components/FormModal.vue.compiled.ts +156 -0
- package/src/extensions/cli-tui-components/Header.vue +13 -0
- package/src/extensions/cli-tui-components/Header.vue.compiled.ts +42 -0
- package/src/extensions/cli-tui-components/InputArea.vue +202 -0
- package/src/extensions/cli-tui-components/InputArea.vue.compiled.ts +243 -0
- package/src/extensions/cli-tui-components/InteractivePanel.vue +32 -0
- package/src/extensions/cli-tui-components/InteractivePanel.vue.compiled.ts +103 -0
- package/src/extensions/cli-tui-components/ListModal.vue +58 -0
- package/src/extensions/cli-tui-components/ListModal.vue.compiled.ts +130 -0
- package/src/extensions/cli-tui-components/MarkdownContent.ts +54 -0
- package/src/extensions/cli-tui-components/Messages.vue +68 -0
- package/src/extensions/cli-tui-components/Messages.vue.compiled.ts +253 -0
- package/src/extensions/cli-tui-components/Modal.vue +56 -0
- package/src/extensions/cli-tui-components/Modal.vue.compiled.ts +61 -0
- package/src/extensions/cli-tui-components/SettingsPanel.vue +178 -0
- package/src/extensions/cli-tui-components/SettingsPanel.vue.compiled.ts +359 -0
- package/src/extensions/cli-tui-components/Spinner.vue +19 -0
- package/src/extensions/cli-tui-components/Spinner.vue.compiled.ts +42 -0
- package/src/extensions/cli-tui-components/StatusBar.vue +45 -0
- package/src/extensions/cli-tui-components/StatusBar.vue.compiled.ts +106 -0
- package/src/extensions/cli-tui-components/SteeringPreview.vue +11 -0
- package/src/extensions/cli-tui-components/SteeringPreview.vue.compiled.ts +38 -0
- package/src/extensions/cli-tui-components/ThinkingBlock.vue +40 -0
- package/src/extensions/cli-tui-components/ThinkingBlock.vue.compiled.ts +82 -0
- package/src/extensions/cli-tui-components/ToolCall.vue +114 -0
- package/src/extensions/cli-tui-components/ToolCall.vue.compiled.ts +319 -0
- package/src/extensions/cli-tui-components/UserMessage.vue +40 -0
- package/src/extensions/cli-tui-components/UserMessage.vue.compiled.ts +148 -0
- package/src/extensions/cli-tui-components/ask-panel-controller.ts +573 -0
- package/src/extensions/cli-tui-components/settings-panel-controller.ts +958 -0
- package/src/extensions/cli-tui.ts +2349 -0
- package/src/extensions/debug.ts +46 -0
- package/src/extensions/headless.ts +55 -0
- package/src/extensions/modal-system.ts +719 -0
- package/src/host.ts +505 -0
- package/src/index.ts +9 -0
- package/src/input/history.ts +233 -0
- package/src/input/index.ts +6 -0
- package/src/panels/dynamic-panel.ts +5 -0
- package/src/panels/index.ts +43 -0
- package/src/panels/state.ts +73 -0
- package/src/panels/types.ts +79 -0
- package/src/panels/widget.ts +25 -0
- package/src/provider-registry.ts +44 -0
- package/src/stderr-capture.ts +248 -0
- package/src/types.ts +20 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Settings Panel Controller
|
|
3
|
+
//
|
|
4
|
+
// Manages state for the SettingsPanel.vue component:
|
|
5
|
+
// - Hardcoded core sections (General, Permissions, Providers)
|
|
6
|
+
// - Extension-contributed sections (via CLIExtension.settings)
|
|
7
|
+
// - Tab navigation (←→)
|
|
8
|
+
// - Field navigation (↑↓)
|
|
9
|
+
// - Inline editing (enter, esc, typing)
|
|
10
|
+
// - Checkbox toggling (space)
|
|
11
|
+
// - Select cycling (enter)
|
|
12
|
+
// - Save (ctrl+s) per-section and close (esc)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import type { SettingsSection, SettingsField } from "../../cli-extension";
|
|
16
|
+
import {
|
|
17
|
+
loadGlobalSettings,
|
|
18
|
+
saveGlobalSettings,
|
|
19
|
+
type GlobalSettings,
|
|
20
|
+
type ProviderSettingsConfig,
|
|
21
|
+
} from "@dex-ai/coding-agent-sdk";
|
|
22
|
+
import { PROVIDER_DESCRIPTORS } from "../../provider-registry";
|
|
23
|
+
|
|
24
|
+
type PermissionMode = "read" | "auto" | "yolo";
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
/* Types (exported for the Vue component) */
|
|
28
|
+
/* ------------------------------------------------------------------ */
|
|
29
|
+
|
|
30
|
+
export interface SettingsTab {
|
|
31
|
+
key: string;
|
|
32
|
+
label: string;
|
|
33
|
+
fields: SettingsField[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SettingsPanelState {
|
|
37
|
+
open: boolean;
|
|
38
|
+
tabs: SettingsTab[];
|
|
39
|
+
activeTabIndex: number;
|
|
40
|
+
focusedFieldIndex: number;
|
|
41
|
+
values: Record<string, string>;
|
|
42
|
+
error: string;
|
|
43
|
+
/** Search/filter text for the current tab's fields. */
|
|
44
|
+
searchFilter: string;
|
|
45
|
+
/** Which zone has focus: tabs bar, search input, or field list. */
|
|
46
|
+
focusZone: "tabs" | "search" | "fields";
|
|
47
|
+
/** Whether the focused text/password field is in edit mode. */
|
|
48
|
+
editing: boolean;
|
|
49
|
+
/** Scroll offset for visible fields. */
|
|
50
|
+
scrollOffset: number;
|
|
51
|
+
/** Max visible rows (set externally based on terminal height). */
|
|
52
|
+
visibleRows: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SettingsPanelActions {
|
|
56
|
+
open(): void;
|
|
57
|
+
close(): void;
|
|
58
|
+
handleKey(key: {
|
|
59
|
+
name: string;
|
|
60
|
+
char?: string | undefined;
|
|
61
|
+
ctrl?: boolean | undefined;
|
|
62
|
+
shift?: boolean | undefined;
|
|
63
|
+
}): boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------------------------------------------------------------------ */
|
|
67
|
+
/* Core Settings Sections */
|
|
68
|
+
/* ------------------------------------------------------------------ */
|
|
69
|
+
|
|
70
|
+
function createCoreSections(): SettingsSection[] {
|
|
71
|
+
return [
|
|
72
|
+
createGeneralSection(),
|
|
73
|
+
createPermissionsSection(),
|
|
74
|
+
createProvidersSection(),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createGeneralSection(): SettingsSection {
|
|
79
|
+
return {
|
|
80
|
+
id: "general",
|
|
81
|
+
label: "General",
|
|
82
|
+
fields: [
|
|
83
|
+
{
|
|
84
|
+
key: "defaultProvider",
|
|
85
|
+
label: "Default Provider",
|
|
86
|
+
type: "select",
|
|
87
|
+
get options() {
|
|
88
|
+
const settings = loadGlobalSettings();
|
|
89
|
+
const names = Object.keys(settings.providers ?? {});
|
|
90
|
+
return names.length > 0 ? names : ["(none)"];
|
|
91
|
+
},
|
|
92
|
+
placeholder: "select provider",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
key: "defaultModel",
|
|
96
|
+
label: "Default Model",
|
|
97
|
+
type: "text",
|
|
98
|
+
placeholder: "e.g. claude-sonnet-4-20250514",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
key: "defaultThinking",
|
|
102
|
+
label: "Thinking Level",
|
|
103
|
+
type: "select",
|
|
104
|
+
options: ["off", "min", "low", "med", "high", "max"],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
key: "hideThinking",
|
|
108
|
+
label: "Hide Thinking",
|
|
109
|
+
type: "checkbox",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: "debug",
|
|
113
|
+
label: "Debug Mode",
|
|
114
|
+
type: "checkbox",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
load() {
|
|
118
|
+
const settings = loadGlobalSettings();
|
|
119
|
+
return {
|
|
120
|
+
defaultProvider: settings.defaultProvider ?? "",
|
|
121
|
+
defaultModel: settings.defaultModel ?? "",
|
|
122
|
+
defaultThinking: settings.defaultThinking ?? "high",
|
|
123
|
+
hideThinking: settings.hideThinking ? "true" : "false",
|
|
124
|
+
debug: settings.debug ? "true" : "false",
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
save(values) {
|
|
128
|
+
const settings = loadGlobalSettings();
|
|
129
|
+
const updated = {
|
|
130
|
+
...settings,
|
|
131
|
+
permissions: {
|
|
132
|
+
...settings.permissions,
|
|
133
|
+
allowedTools: settings.permissions.allowedTools ?? [],
|
|
134
|
+
deniedTools: settings.permissions.deniedTools ?? [],
|
|
135
|
+
},
|
|
136
|
+
...(values.defaultProvider
|
|
137
|
+
? { defaultProvider: values.defaultProvider }
|
|
138
|
+
: {}),
|
|
139
|
+
...(values.defaultModel ? { defaultModel: values.defaultModel } : {}),
|
|
140
|
+
...(values.defaultThinking && values.defaultThinking !== "off"
|
|
141
|
+
? { defaultThinking: values.defaultThinking as any }
|
|
142
|
+
: {}),
|
|
143
|
+
...(values.hideThinking === "true"
|
|
144
|
+
? { hideThinking: true as const }
|
|
145
|
+
: {}),
|
|
146
|
+
...(values.debug === "true" ? { debug: true as const } : {}),
|
|
147
|
+
} satisfies GlobalSettings;
|
|
148
|
+
saveGlobalSettings(updated);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function createPermissionsSection(): SettingsSection {
|
|
154
|
+
return {
|
|
155
|
+
id: "permissions",
|
|
156
|
+
label: "Permissions",
|
|
157
|
+
fields: [
|
|
158
|
+
{
|
|
159
|
+
key: "mode",
|
|
160
|
+
label: "Permission Mode",
|
|
161
|
+
type: "select",
|
|
162
|
+
options: ["read", "auto", "yolo"],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
key: "allowedTools",
|
|
166
|
+
label: "Allowed Tools",
|
|
167
|
+
type: "text",
|
|
168
|
+
readonly: true,
|
|
169
|
+
placeholder: "(none)",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
key: "deniedTools",
|
|
173
|
+
label: "Denied Tools",
|
|
174
|
+
type: "text",
|
|
175
|
+
readonly: true,
|
|
176
|
+
placeholder: "(none)",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
load() {
|
|
180
|
+
const settings = loadGlobalSettings();
|
|
181
|
+
return {
|
|
182
|
+
mode: settings.permissions.mode,
|
|
183
|
+
allowedTools: (settings.permissions.allowedTools ?? []).join(", "),
|
|
184
|
+
deniedTools: (settings.permissions.deniedTools ?? []).join(", "),
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
save(values) {
|
|
188
|
+
const settings = loadGlobalSettings();
|
|
189
|
+
const updated = {
|
|
190
|
+
...settings,
|
|
191
|
+
permissions: {
|
|
192
|
+
...settings.permissions,
|
|
193
|
+
mode: (values.mode as PermissionMode) || settings.permissions.mode,
|
|
194
|
+
allowedTools: settings.permissions.allowedTools ?? [],
|
|
195
|
+
deniedTools: settings.permissions.deniedTools ?? [],
|
|
196
|
+
},
|
|
197
|
+
} satisfies GlobalSettings;
|
|
198
|
+
saveGlobalSettings(updated);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function createProvidersSection(): SettingsSection {
|
|
204
|
+
const settings = loadGlobalSettings();
|
|
205
|
+
const providers = settings.providers ?? {};
|
|
206
|
+
const providerNames = Object.keys(providers);
|
|
207
|
+
|
|
208
|
+
// Also include defaultProvider if it's not already in the providers list
|
|
209
|
+
// (e.g. "claude-proxy" may be referenced but not yet configured)
|
|
210
|
+
const defaultProvider = settings.defaultProvider;
|
|
211
|
+
if (defaultProvider && !providers[defaultProvider]) {
|
|
212
|
+
// Attempt to find a matching descriptor by type or name
|
|
213
|
+
const desc = PROVIDER_DESCRIPTORS.find(
|
|
214
|
+
(d) =>
|
|
215
|
+
d.type === defaultProvider ||
|
|
216
|
+
d.label.toLowerCase() === defaultProvider.toLowerCase(),
|
|
217
|
+
);
|
|
218
|
+
// Add it as an unconfigured provider so the user can set it up
|
|
219
|
+
if (!providerNames.includes(defaultProvider)) {
|
|
220
|
+
providerNames.push(defaultProvider);
|
|
221
|
+
// We'll handle missing config in field generation below
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const fields: SettingsField[] = [];
|
|
226
|
+
|
|
227
|
+
// Add provider action at the top
|
|
228
|
+
fields.push({
|
|
229
|
+
key: "_add_provider",
|
|
230
|
+
label: "+ Add Provider",
|
|
231
|
+
type: "action" as const,
|
|
232
|
+
});
|
|
233
|
+
fields.push({
|
|
234
|
+
key: "_sep_top",
|
|
235
|
+
label: "",
|
|
236
|
+
type: "separator" as const,
|
|
237
|
+
readonly: true,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (providerNames.length > 0) {
|
|
241
|
+
// Partition: new (unconfigured) providers first, then existing
|
|
242
|
+
const newProviders = providerNames.filter((n) =>
|
|
243
|
+
n.startsWith("new-provider"),
|
|
244
|
+
);
|
|
245
|
+
const existingProviders = providerNames.filter(
|
|
246
|
+
(n) => !n.startsWith("new-provider"),
|
|
247
|
+
);
|
|
248
|
+
const ordered = [...newProviders, ...existingProviders];
|
|
249
|
+
|
|
250
|
+
let addedNewSeparator = false;
|
|
251
|
+
for (let pi = 0; pi < ordered.length; pi++) {
|
|
252
|
+
const name = ordered[pi]!;
|
|
253
|
+
const isNew = newProviders.includes(name);
|
|
254
|
+
const cfg = providers[name];
|
|
255
|
+
const providerType = cfg?.type ?? "(not configured)";
|
|
256
|
+
const desc = cfg
|
|
257
|
+
? PROVIDER_DESCRIPTORS.find((d) => d.type === cfg.type)
|
|
258
|
+
: undefined;
|
|
259
|
+
|
|
260
|
+
// Insert a divider between new and existing providers
|
|
261
|
+
if (!isNew && !addedNewSeparator && newProviders.length > 0) {
|
|
262
|
+
addedNewSeparator = true;
|
|
263
|
+
fields.push({
|
|
264
|
+
key: "_sep_existing",
|
|
265
|
+
label: "Configured Providers",
|
|
266
|
+
type: "separator" as const,
|
|
267
|
+
readonly: true,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Separator header: "ProviderName (type)"
|
|
272
|
+
fields.push({
|
|
273
|
+
key: `_sep_${name}`,
|
|
274
|
+
label: isNew
|
|
275
|
+
? `★ ${name} · ${desc?.label ?? providerType} (configure below)`
|
|
276
|
+
: `${name} · ${desc?.label ?? providerType}`,
|
|
277
|
+
type: "separator" as const,
|
|
278
|
+
readonly: true,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Name (editable for renaming)
|
|
282
|
+
fields.push({
|
|
283
|
+
key: `${name}._name`,
|
|
284
|
+
label: "Name",
|
|
285
|
+
type: "text" as const,
|
|
286
|
+
placeholder: name,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Type selector
|
|
290
|
+
fields.push({
|
|
291
|
+
key: `${name}.type`,
|
|
292
|
+
label: "Type",
|
|
293
|
+
type: "select" as const,
|
|
294
|
+
options: PROVIDER_DESCRIPTORS.map((d) => d.type),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Config fields
|
|
298
|
+
if (desc && cfg) {
|
|
299
|
+
for (const f of desc.fields) {
|
|
300
|
+
const field: SettingsField = {
|
|
301
|
+
key: `${name}.${f.key}`,
|
|
302
|
+
label: f.label,
|
|
303
|
+
type: f.secret ? "password" : "text",
|
|
304
|
+
required: f.required,
|
|
305
|
+
};
|
|
306
|
+
const placeholder = f.hint ?? f.default;
|
|
307
|
+
if (placeholder) {
|
|
308
|
+
fields.push({ ...field, placeholder });
|
|
309
|
+
} else {
|
|
310
|
+
fields.push(field);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} else if (cfg) {
|
|
314
|
+
// No descriptor — show generic fields
|
|
315
|
+
fields.push(
|
|
316
|
+
{
|
|
317
|
+
key: `${name}.apiKey`,
|
|
318
|
+
label: "API Key",
|
|
319
|
+
type: "password" as const,
|
|
320
|
+
placeholder: "sk-...",
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
key: `${name}.baseUrl`,
|
|
324
|
+
label: "Base URL",
|
|
325
|
+
type: "text" as const,
|
|
326
|
+
placeholder: "Custom endpoint",
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
} else {
|
|
330
|
+
// Provider referenced but not configured — show basic fields
|
|
331
|
+
fields.push(
|
|
332
|
+
{
|
|
333
|
+
key: `${name}.apiKey`,
|
|
334
|
+
label: "API Key",
|
|
335
|
+
type: "password" as const,
|
|
336
|
+
placeholder: "sk-...",
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
key: `${name}.baseUrl`,
|
|
340
|
+
label: "Base URL",
|
|
341
|
+
type: "text" as const,
|
|
342
|
+
placeholder: "Custom endpoint",
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Remove action for this provider
|
|
348
|
+
fields.push({
|
|
349
|
+
key: `_remove_${name}`,
|
|
350
|
+
label: `Remove ${name}`,
|
|
351
|
+
type: "action" as const,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
fields.push({
|
|
356
|
+
key: "_empty",
|
|
357
|
+
label: "No providers configured",
|
|
358
|
+
type: "text" as const,
|
|
359
|
+
readonly: true as const,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
id: "providers",
|
|
365
|
+
label: "Providers",
|
|
366
|
+
fields,
|
|
367
|
+
load() {
|
|
368
|
+
const current = loadGlobalSettings();
|
|
369
|
+
const provs = current.providers ?? {};
|
|
370
|
+
const values: Record<string, string> = {};
|
|
371
|
+
for (const [name, cfg] of Object.entries(provs)) {
|
|
372
|
+
values[`${name}._name`] = name;
|
|
373
|
+
values[`${name}.type`] = cfg.type;
|
|
374
|
+
values[`${name}.apiKey`] = cfg.apiKey ?? "";
|
|
375
|
+
values[`${name}.baseUrl`] = cfg.baseUrl ?? "";
|
|
376
|
+
// Load all descriptor fields
|
|
377
|
+
const desc = PROVIDER_DESCRIPTORS.find((d) => d.type === cfg.type);
|
|
378
|
+
if (desc) {
|
|
379
|
+
for (const f of desc.fields) {
|
|
380
|
+
values[`${name}.${f.key}`] = (cfg as any)[f.key] ?? "";
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Also load unconfigured providers (e.g. from defaultProvider)
|
|
385
|
+
const dp = current.defaultProvider;
|
|
386
|
+
if (dp && !provs[dp]) {
|
|
387
|
+
values[`${dp}._name`] = dp;
|
|
388
|
+
values[`${dp}.type`] = "";
|
|
389
|
+
}
|
|
390
|
+
return values;
|
|
391
|
+
},
|
|
392
|
+
save(values) {
|
|
393
|
+
const current = loadGlobalSettings();
|
|
394
|
+
const existingProviders = { ...(current.providers ?? {}) };
|
|
395
|
+
const newProviders: Record<string, ProviderSettingsConfig> = {};
|
|
396
|
+
|
|
397
|
+
for (const originalName of Object.keys(existingProviders)) {
|
|
398
|
+
// Check if this provider was renamed
|
|
399
|
+
const newName = values[`${originalName}._name`] || originalName;
|
|
400
|
+
const existing = existingProviders[originalName]!;
|
|
401
|
+
const updated: Record<string, unknown> = { type: existing.type };
|
|
402
|
+
|
|
403
|
+
// If the type was changed via a select field, use that
|
|
404
|
+
if (values[`${originalName}.type`]) {
|
|
405
|
+
updated.type = values[`${originalName}.type`];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Collect all values for this provider (skip internal keys)
|
|
409
|
+
for (const [key, val] of Object.entries(values)) {
|
|
410
|
+
if (key.startsWith(`${originalName}.`) && val) {
|
|
411
|
+
const fieldKey = key.slice(originalName.length + 1);
|
|
412
|
+
if (fieldKey === "_name") continue; // skip internal
|
|
413
|
+
updated[fieldKey] = val;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
newProviders[newName] = updated as unknown as ProviderSettingsConfig;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const updated = {
|
|
421
|
+
...current,
|
|
422
|
+
permissions: {
|
|
423
|
+
...current.permissions,
|
|
424
|
+
allowedTools: current.permissions.allowedTools ?? [],
|
|
425
|
+
deniedTools: current.permissions.deniedTools ?? [],
|
|
426
|
+
},
|
|
427
|
+
providers: newProviders,
|
|
428
|
+
} satisfies GlobalSettings;
|
|
429
|
+
saveGlobalSettings(updated);
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ------------------------------------------------------------------ */
|
|
435
|
+
/* Controller */
|
|
436
|
+
/* ------------------------------------------------------------------ */
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Create the settings panel controller.
|
|
440
|
+
*
|
|
441
|
+
* @param getExtensionSections — callback that collects SettingsSections from
|
|
442
|
+
* loaded CLIExtensions. Called when the panel opens so it always reflects
|
|
443
|
+
* the current set of loaded extensions.
|
|
444
|
+
*/
|
|
445
|
+
export function createSettingsPanelController(
|
|
446
|
+
getExtensionSections: () => SettingsSection[],
|
|
447
|
+
): {
|
|
448
|
+
state: SettingsPanelState;
|
|
449
|
+
actions: SettingsPanelActions;
|
|
450
|
+
} {
|
|
451
|
+
let sections: SettingsSection[] = [];
|
|
452
|
+
|
|
453
|
+
const state: SettingsPanelState = {
|
|
454
|
+
open: false,
|
|
455
|
+
tabs: [],
|
|
456
|
+
activeTabIndex: 0,
|
|
457
|
+
focusedFieldIndex: 0,
|
|
458
|
+
values: {},
|
|
459
|
+
error: "",
|
|
460
|
+
searchFilter: "",
|
|
461
|
+
focusZone: "fields",
|
|
462
|
+
editing: false,
|
|
463
|
+
scrollOffset: 0,
|
|
464
|
+
visibleRows: 20,
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
function activeFields(): readonly SettingsField[] {
|
|
468
|
+
const tab = state.tabs[state.activeTabIndex];
|
|
469
|
+
if (!tab) return [];
|
|
470
|
+
if (!state.searchFilter) return tab.fields;
|
|
471
|
+
const filter = state.searchFilter.toLowerCase();
|
|
472
|
+
return tab.fields.filter(
|
|
473
|
+
(f) =>
|
|
474
|
+
f.label.toLowerCase().includes(filter) ||
|
|
475
|
+
(state.values[f.key] ?? "").toLowerCase().includes(filter),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function currentField(): SettingsField | undefined {
|
|
480
|
+
return activeFields()[state.focusedFieldIndex];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function buildTabs(): void {
|
|
484
|
+
state.tabs = sections.map((s) => ({
|
|
485
|
+
key: s.id,
|
|
486
|
+
label: s.label,
|
|
487
|
+
fields: s.fields.map((f) => ({
|
|
488
|
+
...f,
|
|
489
|
+
// Namespace key by section so values don't collide across tabs
|
|
490
|
+
key: `${s.id}.${f.key}`,
|
|
491
|
+
})),
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function loadAllValues(): void {
|
|
496
|
+
state.values = {};
|
|
497
|
+
for (const section of sections) {
|
|
498
|
+
const loaded = section.load();
|
|
499
|
+
for (const field of section.fields) {
|
|
500
|
+
state.values[`${section.id}.${field.key}`] = loaded[field.key] ?? "";
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function open(): void {
|
|
506
|
+
// Rebuild sections: core + extension-contributed
|
|
507
|
+
sections = [...createCoreSections(), ...getExtensionSections()];
|
|
508
|
+
buildTabs();
|
|
509
|
+
loadAllValues();
|
|
510
|
+
|
|
511
|
+
state.activeTabIndex = 0;
|
|
512
|
+
state.focusedFieldIndex = firstFocusableIndex();
|
|
513
|
+
state.error = "";
|
|
514
|
+
state.searchFilter = "";
|
|
515
|
+
state.focusZone = "fields";
|
|
516
|
+
state.scrollOffset = 0;
|
|
517
|
+
state.open = true;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function close(): void {
|
|
521
|
+
state.error = "";
|
|
522
|
+
state.searchFilter = "";
|
|
523
|
+
state.focusZone = "fields";
|
|
524
|
+
state.scrollOffset = 0;
|
|
525
|
+
state.open = false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function switchTab(dir: -1 | 1): void {
|
|
529
|
+
const count = state.tabs.length;
|
|
530
|
+
if (count === 0) return;
|
|
531
|
+
state.activeTabIndex = (state.activeTabIndex + dir + count) % count;
|
|
532
|
+
state.focusedFieldIndex = firstFocusableIndex();
|
|
533
|
+
state.scrollOffset = 0;
|
|
534
|
+
state.searchFilter = "";
|
|
535
|
+
state.error = "";
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Find the first non-separator field index. */
|
|
539
|
+
function firstFocusableIndex(): number {
|
|
540
|
+
const fields = activeFields();
|
|
541
|
+
for (let i = 0; i < fields.length; i++) {
|
|
542
|
+
if (fields[i]?.type !== "separator") return i;
|
|
543
|
+
}
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function ensureVisible(): void {
|
|
548
|
+
// Adjust scroll so focusedFieldIndex is in view
|
|
549
|
+
if (state.focusedFieldIndex < state.scrollOffset) {
|
|
550
|
+
state.scrollOffset = state.focusedFieldIndex;
|
|
551
|
+
} else if (
|
|
552
|
+
state.focusedFieldIndex >=
|
|
553
|
+
state.scrollOffset + state.visibleRows
|
|
554
|
+
) {
|
|
555
|
+
state.scrollOffset = state.focusedFieldIndex - state.visibleRows + 1;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function focusUp(): void {
|
|
560
|
+
state.editing = false;
|
|
561
|
+
const fields = activeFields();
|
|
562
|
+
if (fields.length === 0) {
|
|
563
|
+
state.focusZone = "tabs";
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (state.focusedFieldIndex === 0) {
|
|
567
|
+
state.focusZone = "tabs";
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
// Skip separators
|
|
571
|
+
let next = state.focusedFieldIndex - 1;
|
|
572
|
+
while (next > 0 && fields[next]?.type === "separator") next--;
|
|
573
|
+
if (fields[next]?.type === "separator") {
|
|
574
|
+
// All above are separators — go to tabs
|
|
575
|
+
state.focusZone = "tabs";
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
state.focusedFieldIndex = next;
|
|
579
|
+
ensureVisible();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function focusDown(): void {
|
|
583
|
+
state.editing = false;
|
|
584
|
+
const fields = activeFields();
|
|
585
|
+
if (fields.length === 0) return;
|
|
586
|
+
if (state.focusedFieldIndex >= fields.length - 1) return;
|
|
587
|
+
// Skip separators
|
|
588
|
+
let next = state.focusedFieldIndex + 1;
|
|
589
|
+
while (next < fields.length - 1 && fields[next]?.type === "separator")
|
|
590
|
+
next++;
|
|
591
|
+
if (fields[next]?.type === "separator") return;
|
|
592
|
+
state.focusedFieldIndex = next;
|
|
593
|
+
ensureVisible();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function toggleCheckbox(dir: 1 | -1 = 1): void {
|
|
597
|
+
const field = currentField();
|
|
598
|
+
if (!field || field.readonly || field.type !== "checkbox") return;
|
|
599
|
+
const key = field.key;
|
|
600
|
+
state.values[key] = state.values[key] === "true" ? "false" : "true";
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function cycleSelect(dir: 1 | -1 = 1): void {
|
|
604
|
+
const field = currentField();
|
|
605
|
+
if (!field || field.readonly || field.type !== "select" || !field.options)
|
|
606
|
+
return;
|
|
607
|
+
const current = state.values[field.key] ?? "";
|
|
608
|
+
const options = field.options as readonly string[];
|
|
609
|
+
const idx = options.indexOf(current);
|
|
610
|
+
const nextIdx = (idx + dir + options.length) % options.length;
|
|
611
|
+
state.values[field.key] = options[nextIdx] ?? "";
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function executeAction(field: SettingsField): void {
|
|
615
|
+
const key = field.key;
|
|
616
|
+
|
|
617
|
+
if (key.endsWith("_add_provider")) {
|
|
618
|
+
// Add a new empty provider instance
|
|
619
|
+
const settings = loadGlobalSettings();
|
|
620
|
+
const existing = Object.keys(settings.providers ?? {});
|
|
621
|
+
// Generate a default name
|
|
622
|
+
let name = "new-provider";
|
|
623
|
+
let i = 1;
|
|
624
|
+
while (existing.includes(name)) {
|
|
625
|
+
name = `new-provider-${i++}`;
|
|
626
|
+
}
|
|
627
|
+
const defaultType = PROVIDER_DESCRIPTORS[0]?.type ?? "openai";
|
|
628
|
+
// Put new provider FIRST in the providers object
|
|
629
|
+
const updated = {
|
|
630
|
+
...settings,
|
|
631
|
+
permissions: {
|
|
632
|
+
...settings.permissions,
|
|
633
|
+
allowedTools: settings.permissions.allowedTools ?? [],
|
|
634
|
+
deniedTools: settings.permissions.deniedTools ?? [],
|
|
635
|
+
},
|
|
636
|
+
providers: {
|
|
637
|
+
[name]: { type: defaultType } as unknown as ProviderSettingsConfig,
|
|
638
|
+
...(settings.providers ?? {}),
|
|
639
|
+
},
|
|
640
|
+
} satisfies GlobalSettings;
|
|
641
|
+
saveGlobalSettings(updated);
|
|
642
|
+
// Reopen to show new provider
|
|
643
|
+
const savedTab = state.activeTabIndex;
|
|
644
|
+
open();
|
|
645
|
+
state.activeTabIndex = savedTab;
|
|
646
|
+
// Focus the new provider's Name field (after "+ Add Provider", separator, provider separator)
|
|
647
|
+
// Find the Name field for the new provider
|
|
648
|
+
const fields = activeFields();
|
|
649
|
+
const nameFieldIdx = fields.findIndex((f) =>
|
|
650
|
+
f.key.endsWith(`${name}._name`),
|
|
651
|
+
);
|
|
652
|
+
if (nameFieldIdx >= 0) {
|
|
653
|
+
state.focusedFieldIndex = nameFieldIdx;
|
|
654
|
+
state.editing = true;
|
|
655
|
+
// Pre-fill with empty so user starts fresh
|
|
656
|
+
const stateKey = `providers.${name}._name`;
|
|
657
|
+
state.values[stateKey] = "";
|
|
658
|
+
}
|
|
659
|
+
ensureVisible();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (key.includes("_remove_")) {
|
|
664
|
+
const providerName = key.slice(
|
|
665
|
+
key.indexOf("_remove_") + "_remove_".length,
|
|
666
|
+
);
|
|
667
|
+
const settings = loadGlobalSettings();
|
|
668
|
+
const providers = { ...(settings.providers ?? {}) };
|
|
669
|
+
delete providers[providerName];
|
|
670
|
+
const updated = {
|
|
671
|
+
...settings,
|
|
672
|
+
permissions: {
|
|
673
|
+
...settings.permissions,
|
|
674
|
+
allowedTools: settings.permissions.allowedTools ?? [],
|
|
675
|
+
deniedTools: settings.permissions.deniedTools ?? [],
|
|
676
|
+
},
|
|
677
|
+
providers,
|
|
678
|
+
} satisfies GlobalSettings;
|
|
679
|
+
saveGlobalSettings(updated);
|
|
680
|
+
// Reopen to reflect removal
|
|
681
|
+
const savedTab = state.activeTabIndex;
|
|
682
|
+
open();
|
|
683
|
+
state.activeTabIndex = savedTab;
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function save(): void {
|
|
689
|
+
// Save the currently active tab's section
|
|
690
|
+
const section = sections[state.activeTabIndex];
|
|
691
|
+
if (!section) return;
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
// Extract values for this section (strip the section.id prefix)
|
|
695
|
+
const sectionValues: Record<string, string> = {};
|
|
696
|
+
for (const field of section.fields) {
|
|
697
|
+
const stateKey = `${section.id}.${field.key}`;
|
|
698
|
+
sectionValues[field.key] = state.values[stateKey] ?? "";
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
section.save(sectionValues);
|
|
702
|
+
state.error = "";
|
|
703
|
+
close();
|
|
704
|
+
} catch (err) {
|
|
705
|
+
state.error = `Save failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function handleKey(key: {
|
|
710
|
+
name: string;
|
|
711
|
+
char?: string | undefined;
|
|
712
|
+
ctrl?: boolean | undefined;
|
|
713
|
+
shift?: boolean | undefined;
|
|
714
|
+
}): boolean {
|
|
715
|
+
if (!state.open) return false;
|
|
716
|
+
|
|
717
|
+
// --- Search bar focused ---
|
|
718
|
+
if (state.focusZone === "search") {
|
|
719
|
+
if (key.name === "escape") {
|
|
720
|
+
state.searchFilter = "";
|
|
721
|
+
state.focusZone = "fields";
|
|
722
|
+
state.focusedFieldIndex = firstFocusableIndex();
|
|
723
|
+
state.scrollOffset = 0;
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
if (key.name === "enter" || key.name === "down") {
|
|
727
|
+
state.focusZone = "fields";
|
|
728
|
+
state.focusedFieldIndex = firstFocusableIndex();
|
|
729
|
+
state.scrollOffset = 0;
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
if (key.name === "up") {
|
|
733
|
+
state.focusZone = "tabs";
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
if (key.name === "backspace") {
|
|
737
|
+
state.searchFilter = state.searchFilter.slice(0, -1);
|
|
738
|
+
state.focusedFieldIndex = firstFocusableIndex();
|
|
739
|
+
state.scrollOffset = 0;
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
if (key.name === "char" && key.char) {
|
|
743
|
+
state.searchFilter += key.char;
|
|
744
|
+
state.focusedFieldIndex = firstFocusableIndex();
|
|
745
|
+
state.scrollOffset = 0;
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// --- Tabs bar focused ---
|
|
752
|
+
if (state.focusZone === "tabs") {
|
|
753
|
+
if (key.name === "left") {
|
|
754
|
+
switchTab(-1);
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
if (key.name === "right") {
|
|
758
|
+
switchTab(1);
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
if (key.name === "down" || key.name === "enter") {
|
|
762
|
+
state.focusZone = "fields";
|
|
763
|
+
state.focusedFieldIndex = firstFocusableIndex();
|
|
764
|
+
state.scrollOffset = 0;
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
if (key.name === "escape") {
|
|
768
|
+
close();
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
if (key.name === "char" && key.char === "/") {
|
|
772
|
+
state.focusZone = "search";
|
|
773
|
+
state.searchFilter = "";
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// --- Fields zone ---
|
|
780
|
+
const field = currentField();
|
|
781
|
+
|
|
782
|
+
// --- Editing mode (text/password input) ---
|
|
783
|
+
if (state.editing) {
|
|
784
|
+
if (key.name === "escape" || key.name === "enter") {
|
|
785
|
+
// Exit edit mode
|
|
786
|
+
state.editing = false;
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
if (key.name === "up") {
|
|
790
|
+
state.editing = false;
|
|
791
|
+
focusUp();
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
if (key.name === "down") {
|
|
795
|
+
state.editing = false;
|
|
796
|
+
focusDown();
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
if (key.name === "backspace") {
|
|
800
|
+
if (field) {
|
|
801
|
+
const val = state.values[field.key] ?? "";
|
|
802
|
+
state.values[field.key] = val.slice(0, -1);
|
|
803
|
+
}
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
if (key.name === "char" && key.char && !key.ctrl) {
|
|
807
|
+
if (field) {
|
|
808
|
+
state.values[field.key] = (state.values[field.key] ?? "") + key.char;
|
|
809
|
+
}
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
if (key.name === "s" && key.ctrl) {
|
|
813
|
+
state.editing = false;
|
|
814
|
+
save();
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
// Paste in edit mode
|
|
818
|
+
if (key.name === "paste" && key.char && field) {
|
|
819
|
+
// Strip newlines from pasted content (single-line field)
|
|
820
|
+
const cleaned = key.char.replace(/[\r\n]/g, "").trim();
|
|
821
|
+
state.values[field.key] = (state.values[field.key] ?? "") + cleaned;
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
return true; // consume all keys in editing mode
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// --- Normal field navigation ---
|
|
828
|
+
if (key.name === "up") {
|
|
829
|
+
focusUp();
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
if (key.name === "down") {
|
|
833
|
+
focusDown();
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
// Left/right arrows: toggle/cycle for checkbox/select fields
|
|
837
|
+
if (key.name === "left") {
|
|
838
|
+
if (!field || field.readonly) return true;
|
|
839
|
+
if (field.type === "checkbox") {
|
|
840
|
+
toggleCheckbox(-1);
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
if (field.type === "select") {
|
|
844
|
+
cycleSelect(-1);
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
if (key.name === "right") {
|
|
850
|
+
if (!field || field.readonly) return true;
|
|
851
|
+
if (field.type === "checkbox") {
|
|
852
|
+
toggleCheckbox(1);
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
if (field.type === "select") {
|
|
856
|
+
cycleSelect(1);
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
if (key.name === "enter") {
|
|
862
|
+
if (!field || field.readonly) return true;
|
|
863
|
+
if (field.type === "separator") return true;
|
|
864
|
+
if (field.type === "action") {
|
|
865
|
+
executeAction(field);
|
|
866
|
+
return true;
|
|
867
|
+
}
|
|
868
|
+
if (field.type === "checkbox") {
|
|
869
|
+
toggleCheckbox();
|
|
870
|
+
return true;
|
|
871
|
+
}
|
|
872
|
+
if (field.type === "select") {
|
|
873
|
+
cycleSelect();
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
// text/password: enter edit mode
|
|
877
|
+
state.editing = true;
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
if (key.name === "char" && key.char === " ") {
|
|
881
|
+
if (!field || field.readonly) return true;
|
|
882
|
+
if (field.type === "separator" || field.type === "action") return true;
|
|
883
|
+
if (field.type === "checkbox") {
|
|
884
|
+
toggleCheckbox();
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
if (field.type === "select") {
|
|
888
|
+
cycleSelect();
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
// Space on text/password enters edit mode and adds space
|
|
892
|
+
state.editing = true;
|
|
893
|
+
state.values[field.key] = (state.values[field.key] ?? "") + " ";
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
if (key.name === "backspace") {
|
|
897
|
+
if (!field || field.readonly) return true;
|
|
898
|
+
if (field.type === "separator" || field.type === "action") return true;
|
|
899
|
+
// Backspace enters edit mode and deletes
|
|
900
|
+
state.editing = true;
|
|
901
|
+
const val = state.values[field.key] ?? "";
|
|
902
|
+
state.values[field.key] = val.slice(0, -1);
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
// Any printable char
|
|
906
|
+
if (key.name === "char" && key.char) {
|
|
907
|
+
// "/" activates search
|
|
908
|
+
if (key.char === "/") {
|
|
909
|
+
state.focusZone = "search";
|
|
910
|
+
state.searchFilter = "";
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
if (!field || field.readonly) return true;
|
|
914
|
+
// Don't type into non-text fields
|
|
915
|
+
if (
|
|
916
|
+
field.type === "checkbox" ||
|
|
917
|
+
field.type === "select" ||
|
|
918
|
+
field.type === "separator" ||
|
|
919
|
+
field.type === "action"
|
|
920
|
+
)
|
|
921
|
+
return true;
|
|
922
|
+
// Start editing and append char
|
|
923
|
+
state.editing = true;
|
|
924
|
+
state.values[field.key] = (state.values[field.key] ?? "") + key.char;
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
if (key.name === "s" && key.ctrl) {
|
|
928
|
+
save();
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
// Paste in normal mode — auto-enter edit and paste
|
|
932
|
+
if (key.name === "paste" && key.char && field) {
|
|
933
|
+
if (field.readonly) return true;
|
|
934
|
+
if (
|
|
935
|
+
field.type === "checkbox" ||
|
|
936
|
+
field.type === "select" ||
|
|
937
|
+
field.type === "separator" ||
|
|
938
|
+
field.type === "action"
|
|
939
|
+
)
|
|
940
|
+
return true;
|
|
941
|
+
state.editing = true;
|
|
942
|
+
const cleaned = key.char.replace(/[\r\n]/g, "").trim();
|
|
943
|
+
state.values[field.key] = (state.values[field.key] ?? "") + cleaned;
|
|
944
|
+
return true;
|
|
945
|
+
}
|
|
946
|
+
if (key.name === "escape") {
|
|
947
|
+
close();
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return {
|
|
955
|
+
state,
|
|
956
|
+
actions: { open, close, handleKey },
|
|
957
|
+
};
|
|
958
|
+
}
|