@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.
Files changed (70) hide show
  1. package/bin/dex.ts +402 -0
  2. package/package.json +45 -0
  3. package/src/__tests__/command-validation.test.ts +205 -0
  4. package/src/__tests__/history.test.ts +183 -0
  5. package/src/cli-extension.ts +153 -0
  6. package/src/commands/extension-loader.ts +399 -0
  7. package/src/commands/extension.ts +924 -0
  8. package/src/commands/update.ts +419 -0
  9. package/src/env.d.ts +5 -0
  10. package/src/extensions/cli-tui-components/ActivityPanel.vue +24 -0
  11. package/src/extensions/cli-tui-components/ActivityPanel.vue.compiled.ts +96 -0
  12. package/src/extensions/cli-tui-components/App.vue +127 -0
  13. package/src/extensions/cli-tui-components/App.vue.compiled.ts +374 -0
  14. package/src/extensions/cli-tui-components/ApprovalPrompt.vue +30 -0
  15. package/src/extensions/cli-tui-components/ApprovalPrompt.vue.compiled.ts +72 -0
  16. package/src/extensions/cli-tui-components/AskPanel.vue +228 -0
  17. package/src/extensions/cli-tui-components/AskPanel.vue.compiled.ts +419 -0
  18. package/src/extensions/cli-tui-components/CommandPalette.vue +19 -0
  19. package/src/extensions/cli-tui-components/CommandPalette.vue.compiled.ts +65 -0
  20. package/src/extensions/cli-tui-components/ConfirmModal.vue +29 -0
  21. package/src/extensions/cli-tui-components/ConfirmModal.vue.compiled.ts +72 -0
  22. package/src/extensions/cli-tui-components/DiffView.vue +139 -0
  23. package/src/extensions/cli-tui-components/DiffView.vue.compiled.ts +274 -0
  24. package/src/extensions/cli-tui-components/FormModal.vue +58 -0
  25. package/src/extensions/cli-tui-components/FormModal.vue.compiled.ts +156 -0
  26. package/src/extensions/cli-tui-components/Header.vue +13 -0
  27. package/src/extensions/cli-tui-components/Header.vue.compiled.ts +42 -0
  28. package/src/extensions/cli-tui-components/InputArea.vue +202 -0
  29. package/src/extensions/cli-tui-components/InputArea.vue.compiled.ts +243 -0
  30. package/src/extensions/cli-tui-components/InteractivePanel.vue +32 -0
  31. package/src/extensions/cli-tui-components/InteractivePanel.vue.compiled.ts +103 -0
  32. package/src/extensions/cli-tui-components/ListModal.vue +58 -0
  33. package/src/extensions/cli-tui-components/ListModal.vue.compiled.ts +130 -0
  34. package/src/extensions/cli-tui-components/MarkdownContent.ts +54 -0
  35. package/src/extensions/cli-tui-components/Messages.vue +68 -0
  36. package/src/extensions/cli-tui-components/Messages.vue.compiled.ts +253 -0
  37. package/src/extensions/cli-tui-components/Modal.vue +56 -0
  38. package/src/extensions/cli-tui-components/Modal.vue.compiled.ts +61 -0
  39. package/src/extensions/cli-tui-components/SettingsPanel.vue +178 -0
  40. package/src/extensions/cli-tui-components/SettingsPanel.vue.compiled.ts +359 -0
  41. package/src/extensions/cli-tui-components/Spinner.vue +19 -0
  42. package/src/extensions/cli-tui-components/Spinner.vue.compiled.ts +42 -0
  43. package/src/extensions/cli-tui-components/StatusBar.vue +45 -0
  44. package/src/extensions/cli-tui-components/StatusBar.vue.compiled.ts +106 -0
  45. package/src/extensions/cli-tui-components/SteeringPreview.vue +11 -0
  46. package/src/extensions/cli-tui-components/SteeringPreview.vue.compiled.ts +38 -0
  47. package/src/extensions/cli-tui-components/ThinkingBlock.vue +40 -0
  48. package/src/extensions/cli-tui-components/ThinkingBlock.vue.compiled.ts +82 -0
  49. package/src/extensions/cli-tui-components/ToolCall.vue +114 -0
  50. package/src/extensions/cli-tui-components/ToolCall.vue.compiled.ts +319 -0
  51. package/src/extensions/cli-tui-components/UserMessage.vue +40 -0
  52. package/src/extensions/cli-tui-components/UserMessage.vue.compiled.ts +148 -0
  53. package/src/extensions/cli-tui-components/ask-panel-controller.ts +573 -0
  54. package/src/extensions/cli-tui-components/settings-panel-controller.ts +958 -0
  55. package/src/extensions/cli-tui.ts +2349 -0
  56. package/src/extensions/debug.ts +46 -0
  57. package/src/extensions/headless.ts +55 -0
  58. package/src/extensions/modal-system.ts +719 -0
  59. package/src/host.ts +505 -0
  60. package/src/index.ts +9 -0
  61. package/src/input/history.ts +233 -0
  62. package/src/input/index.ts +6 -0
  63. package/src/panels/dynamic-panel.ts +5 -0
  64. package/src/panels/index.ts +43 -0
  65. package/src/panels/state.ts +73 -0
  66. package/src/panels/types.ts +79 -0
  67. package/src/panels/widget.ts +25 -0
  68. package/src/provider-registry.ts +44 -0
  69. package/src/stderr-capture.ts +248 -0
  70. 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
+ }