@pellux/goodvibes-tui 0.20.3 → 0.22.0

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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +4 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +658 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/entrypoint.ts +6 -0
  11. package/src/cli/help.ts +4 -2
  12. package/src/cli/management-commands.ts +1 -1
  13. package/src/cli/management.ts +1 -8
  14. package/src/cli/parser.ts +31 -18
  15. package/src/cli/service-command.ts +1 -1
  16. package/src/cli/surface-command.ts +1 -1
  17. package/src/cli/tui-startup.ts +72 -10
  18. package/src/cli/types.ts +14 -3
  19. package/src/cli-flags.ts +1 -0
  20. package/src/config/atomic-write.ts +70 -0
  21. package/src/config/goodvibes-home-audit.ts +2 -0
  22. package/src/config/read-versioned.ts +115 -0
  23. package/src/core/context-auto-compact.ts +77 -0
  24. package/src/core/conversation-rendering.ts +49 -15
  25. package/src/core/conversation.ts +101 -16
  26. package/src/core/format-user-error.ts +192 -0
  27. package/src/core/stream-event-wiring.ts +144 -0
  28. package/src/core/stream-stall-watchdog.ts +103 -0
  29. package/src/core/system-message-router.ts +5 -1
  30. package/src/core/turn-event-wiring.ts +124 -0
  31. package/src/daemon/cli.ts +5 -0
  32. package/src/export/cost-utils.ts +71 -0
  33. package/src/export/gist-uploader.ts +136 -0
  34. package/src/input/command-registry.ts +32 -1
  35. package/src/input/commands/control-room-runtime.ts +10 -10
  36. package/src/input/commands/experience-runtime.ts +5 -4
  37. package/src/input/commands/knowledge.ts +1 -1
  38. package/src/input/commands/local-auth-runtime.ts +27 -5
  39. package/src/input/commands/local-setup.ts +4 -6
  40. package/src/input/commands/memory-product-runtime.ts +8 -6
  41. package/src/input/commands/operator-panel-runtime.ts +1 -1
  42. package/src/input/commands/operator-runtime.ts +3 -10
  43. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  44. package/src/input/commands/provider.ts +57 -3
  45. package/src/input/commands/recall-review.ts +26 -2
  46. package/src/input/commands/services-runtime.ts +2 -2
  47. package/src/input/commands/session-workflow.ts +8 -16
  48. package/src/input/commands/session.ts +70 -20
  49. package/src/input/commands/share-runtime.ts +99 -12
  50. package/src/input/commands/tts-runtime.ts +30 -4
  51. package/src/input/commands.ts +2 -4
  52. package/src/input/delete-key-policy.ts +46 -0
  53. package/src/input/feed-context-factory.ts +2 -0
  54. package/src/input/handler-feed.ts +3 -0
  55. package/src/input/handler-interactions.ts +2 -15
  56. package/src/input/handler-modal-routes.ts +128 -12
  57. package/src/input/handler-modal-token-routes.ts +22 -5
  58. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  59. package/src/input/handler-onboarding.ts +73 -69
  60. package/src/input/handler-types.ts +163 -0
  61. package/src/input/handler.ts +6 -2
  62. package/src/input/input-history.ts +76 -6
  63. package/src/input/model-picker-filter.ts +265 -0
  64. package/src/input/model-picker-items.ts +208 -0
  65. package/src/input/model-picker.ts +92 -325
  66. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  67. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  68. package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
  69. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
  70. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  71. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  72. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  73. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  74. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  75. package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
  76. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  77. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  78. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  79. package/src/input/settings-modal-behavior.ts +5 -0
  80. package/src/input/settings-modal-data.ts +378 -0
  81. package/src/input/settings-modal-mutations.ts +157 -0
  82. package/src/input/settings-modal-reset.ts +154 -0
  83. package/src/input/settings-modal.ts +236 -232
  84. package/src/main.ts +93 -85
  85. package/src/panels/agent-inspector-panel.ts +120 -18
  86. package/src/panels/agent-inspector-shared.ts +29 -0
  87. package/src/panels/builtin/agent.ts +4 -1
  88. package/src/panels/builtin/development.ts +5 -1
  89. package/src/panels/builtin/knowledge.ts +14 -13
  90. package/src/panels/builtin/operations.ts +22 -1
  91. package/src/panels/builtin/shared.ts +7 -0
  92. package/src/panels/cockpit-panel.ts +123 -3
  93. package/src/panels/cockpit-read-model.ts +232 -0
  94. package/src/panels/confirm-state.ts +27 -12
  95. package/src/panels/cost-tracker-panel.ts +23 -67
  96. package/src/panels/eval-panel.ts +10 -9
  97. package/src/panels/index.ts +1 -1
  98. package/src/panels/knowledge-graph-panel.ts +84 -0
  99. package/src/panels/local-auth-panel.ts +124 -4
  100. package/src/panels/memory-panel.ts +370 -40
  101. package/src/panels/project-planning-panel.ts +42 -4
  102. package/src/panels/search-focus.ts +11 -5
  103. package/src/panels/session-maintenance.ts +66 -15
  104. package/src/panels/subscription-panel.ts +33 -25
  105. package/src/panels/types.ts +28 -1
  106. package/src/panels/wrfc-panel.ts +224 -41
  107. package/src/renderer/agent-detail-modal.ts +118 -13
  108. package/src/renderer/code-block.ts +10 -2
  109. package/src/renderer/compositor.ts +18 -4
  110. package/src/renderer/context-inspector.ts +1 -5
  111. package/src/renderer/context-status-hint.ts +54 -0
  112. package/src/renderer/diff.ts +94 -21
  113. package/src/renderer/markdown.ts +29 -13
  114. package/src/renderer/settings-modal-helpers.ts +1 -1
  115. package/src/renderer/settings-modal.ts +90 -10
  116. package/src/renderer/shell-surface.ts +10 -0
  117. package/src/renderer/syntax-highlighter.ts +10 -3
  118. package/src/renderer/term-caps.ts +318 -0
  119. package/src/renderer/theme.ts +158 -0
  120. package/src/renderer/tool-call.ts +12 -2
  121. package/src/renderer/ui-factory.ts +50 -6
  122. package/src/runtime/bootstrap-command-context.ts +1 -0
  123. package/src/runtime/bootstrap-command-parts.ts +18 -0
  124. package/src/runtime/bootstrap-core.ts +145 -13
  125. package/src/runtime/bootstrap-shell.ts +11 -0
  126. package/src/runtime/bootstrap.ts +9 -0
  127. package/src/runtime/onboarding/apply.ts +4 -6
  128. package/src/runtime/onboarding/index.ts +1 -0
  129. package/src/runtime/onboarding/markers.ts +42 -49
  130. package/src/runtime/onboarding/progress.ts +148 -0
  131. package/src/runtime/onboarding/state.ts +133 -55
  132. package/src/runtime/onboarding/types.ts +20 -0
  133. package/src/runtime/services.ts +27 -1
  134. package/src/runtime/wrfc-persistence.ts +237 -0
  135. package/src/shell/blocking-input.ts +20 -5
  136. package/src/tools/wrfc-agent-guard.ts +64 -3
  137. package/src/utils/format-elapsed.ts +30 -0
  138. package/src/utils/terminal-width.ts +45 -0
  139. package/src/version.ts +1 -1
  140. package/src/work-plans/work-plan-store.ts +4 -6
  141. package/src/panels/knowledge-panel.ts +0 -345
  142. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -8,15 +8,18 @@
8
8
  * - Feature flags tab with runtime toggle support
9
9
  *
10
10
  * Saves changes via configManager.set(key, value) or featureFlagManager methods.
11
+ *
12
+ * Data assembly delegates to settings-modal-data.ts (buildSettingGroups, etc.).
13
+ * Mutation logic delegates to settings-modal-mutations.ts (applySettingValue, etc.).
11
14
  */
12
15
 
13
- import { CONFIG_SCHEMA, type ConfigKey, type PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config';
16
+ import { type ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
17
+ import { handleConfirmInput } from '../panels/confirm-state.ts';
14
18
  import type { ModelPickerTarget } from './model-picker.ts';
15
19
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
16
20
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
17
- import { getResolvedSettingLookup } from '@/runtime/index.ts';
18
21
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
19
- import { buildGoodVibesSecretKey, isSecretConfigKey } from '../config/secret-config.ts';
22
+ import { isSecretConfigKey } from '../config/secret-config.ts';
20
23
  import {
21
24
  getNumericAdjustmentMeta,
22
25
  modelPickerLaunchForKey,
@@ -26,12 +29,10 @@ import {
26
29
  setSecretBackedSettingValue,
27
30
  type SettingsSecretsManager,
28
31
  } from './settings-modal-secrets.ts';
29
- import { buildSubscriptionEntries } from './settings-modal-subscriptions.ts';
30
32
  import type { FeatureFlagManager } from '@/runtime/index.ts';
31
- import type { FeatureFlag, FlagState } from '@/runtime/index.ts';
33
+ import type { FlagState } from '@/runtime/index.ts';
32
34
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
33
- import { logger } from '@pellux/goodvibes-sdk/platform/utils';
34
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
35
+
35
36
  import {
36
37
  SETTINGS_CATEGORIES,
37
38
  SETTINGS_CATEGORY_GROUPS,
@@ -42,6 +43,30 @@ import {
42
43
  type SettingsFocusPane,
43
44
  type SubscriptionEntry,
44
45
  } from './settings-modal-types.ts';
46
+ import {
47
+ buildSettingGroups,
48
+ buildFlagEntries,
49
+ buildMcpEntries,
50
+ buildSubscriptionEntries,
51
+ buildNetworkFilteredItems,
52
+ refreshEntryValues,
53
+ updateEntryForKey,
54
+ searchSettingEntries,
55
+ } from './settings-modal-data.ts';
56
+ import { getSettingLabel } from '../renderer/settings-modal-helpers.ts';
57
+ import {
58
+ applySettingValue,
59
+ applyFlagState,
60
+ persistFlagState,
61
+ type SettingAppliedCallback,
62
+ } from './settings-modal-mutations.ts';
63
+ import {
64
+ resetSelected as _resetSelected,
65
+ initiateResetCategory as _initiateResetCategory,
66
+ initiateResetAll as _initiateResetAll,
67
+ handleResetConfirmKey as _handleResetConfirmKey,
68
+ type ResetConfirmKeyResult,
69
+ } from './settings-modal-reset.ts';
45
70
 
46
71
  export interface SettingsModalChange {
47
72
  readonly key: ConfigKey;
@@ -106,6 +131,11 @@ export class SettingsModal {
106
131
  /** Provider awaiting explicit logout confirmation, if any. */
107
132
  public subscriptionLogoutConfirmationTarget: string | null = null;
108
133
 
134
+ /** Pending category-reset confirmation gate, or null when inactive. */
135
+ public resetCategoryConfirm: { readonly subject: string } | null = null;
136
+ /** Pending reset-all confirmation gate, or null when inactive. */
137
+ public resetAllConfirm: { readonly subject: 'all' } | null = null;
138
+
109
139
  /** Settings grouped by category. */
110
140
  public groups: Map<SettingsCategory, SettingEntry[]> = new Map();
111
141
 
@@ -116,6 +146,27 @@ export class SettingsModal {
116
146
  /** Provider subscription entries (populated when subscriptions tab is active). */
117
147
  public subscriptionEntries: SubscriptionEntry[] = [];
118
148
 
149
+ /**
150
+ * Whether the user has entered search mode (pressed / or a printable key
151
+ * in the search input). Distinct from searchQuery.length > 0 because the
152
+ * user may have deleted all chars while remaining in search mode.
153
+ * Renderer should display the search prompt when this is true.
154
+ */
155
+ public searchFocused = false;
156
+
157
+ /**
158
+ * Current search query. Non-empty activates cross-category search mode.
159
+ * Renderer should display searchResults instead of the per-category list
160
+ * when searchQuery is non-empty.
161
+ */
162
+ public searchQuery = '';
163
+
164
+ /**
165
+ * Ranked cross-category results when searchQuery is non-empty.
166
+ * Populated by setSearchQuery(); empty when searchQuery is ''.
167
+ */
168
+ public searchResults: SettingEntry[] = [];
169
+
119
170
  /**
120
171
  * Set after a network-category save that touches controlPlane or httpListener
121
172
  * config keys. Renderer reads this to display a transient restart notice.
@@ -154,10 +205,10 @@ export class SettingsModal {
154
205
  this.serviceRegistry = serviceRegistry;
155
206
  this.mcpRegistry = mcpRegistry ?? null;
156
207
  this.onSettingApplied = options?.onSettingApplied ?? null;
157
- this._loadGroups(configManager);
158
- this._loadFlagEntries();
159
- this._loadMcpEntries();
160
- this._loadSubscriptionEntries();
208
+ this.groups = buildSettingGroups(configManager);
209
+ this.flagEntries = buildFlagEntries(featureFlagManager);
210
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
211
+ this.subscriptionEntries = buildSubscriptionEntries(subscriptionManager, serviceRegistry);
161
212
  this.categoryIndex = 0;
162
213
  this.selectedIndex = 0;
163
214
  this.focusPane = 'categories';
@@ -170,6 +221,9 @@ export class SettingsModal {
170
221
  this.subscriptionLogoutConfirmationTarget = null;
171
222
  this.lastSaveTriggeredRestart = null;
172
223
  this.lastSettingEffectMessage = null;
224
+ this.searchQuery = '';
225
+ this.searchResults = [];
226
+ this.searchFocused = false;
173
227
  this.active = true;
174
228
  }
175
229
 
@@ -184,25 +238,55 @@ export class SettingsModal {
184
238
  this.subscriptionLogoutConfirmationTarget = null;
185
239
  this.lastSaveTriggeredRestart = null;
186
240
  this.lastSettingEffectMessage = null;
241
+ this.searchQuery = '';
242
+ this.searchResults = [];
243
+ this.searchFocused = false;
187
244
  this.serviceRegistry = null;
188
245
  this.secretsManager = null;
189
246
  this.onSettingApplied = null;
190
247
  this.focusPane = 'settings';
191
248
  }
192
249
 
250
+ /** Enter search mode (focus the search input bar). */
251
+ focusSearch(): void {
252
+ this.searchFocused = true;
253
+ this.selectedIndex = 0;
254
+ }
255
+
256
+ /** Exit search mode without clearing the query. */
257
+ blurSearch(): void {
258
+ this.searchFocused = false;
259
+ }
260
+
261
+ /**
262
+ * Update the search query and recompute cross-category ranked results.
263
+ * Setting an empty string clears search mode.
264
+ */
265
+ setSearchQuery(query: string): void {
266
+ this.searchQuery = query;
267
+ this.searchFocused = true;
268
+ if (query.trim().length === 0) {
269
+ this.searchResults = [];
270
+ } else {
271
+ this.searchResults = searchSettingEntries(query, this.groups, getSettingLabel);
272
+ }
273
+ this.selectedIndex = 0;
274
+ }
275
+
276
+ /** Clear search query, results, and exit search focus mode. */
277
+ clearSearch(): void {
278
+ this.searchQuery = '';
279
+ this.searchResults = [];
280
+ this.searchFocused = false;
281
+ }
282
+
193
283
  /** Cycle to the next category (Tab). */
194
284
  nextCategory(): void {
195
285
  if (this.editingMode) return;
196
286
  this.categoryIndex = (this.categoryIndex + 1) % SETTINGS_CATEGORIES.length;
197
287
  this.selectedIndex = 0;
198
288
  this.subscriptionLogoutConfirmationTarget = null;
199
- if (this.currentCategory === 'flags') {
200
- this._loadFlagEntries();
201
- } else if (this.currentCategory === 'mcp') {
202
- this._loadMcpEntries();
203
- } else if (this.currentCategory === 'subscriptions') {
204
- this._loadSubscriptionEntries();
205
- }
289
+ this._reloadTabEntries();
206
290
  }
207
291
 
208
292
  /** Cycle to the previous category (Shift+Tab). */
@@ -211,13 +295,7 @@ export class SettingsModal {
211
295
  this.categoryIndex = (this.categoryIndex - 1 + SETTINGS_CATEGORIES.length) % SETTINGS_CATEGORIES.length;
212
296
  this.selectedIndex = 0;
213
297
  this.subscriptionLogoutConfirmationTarget = null;
214
- if (this.currentCategory === 'flags') {
215
- this._loadFlagEntries();
216
- } else if (this.currentCategory === 'mcp') {
217
- this._loadMcpEntries();
218
- } else if (this.currentCategory === 'subscriptions') {
219
- this._loadSubscriptionEntries();
220
- }
298
+ this._reloadTabEntries();
221
299
  }
222
300
 
223
301
  focusCategories(): void {
@@ -247,6 +325,12 @@ export class SettingsModal {
247
325
 
248
326
  moveUp(): void {
249
327
  if (this.editingMode) return;
328
+ if (this.searchFocused) {
329
+ if (this.searchResults.length > 0) {
330
+ this.selectedIndex = (this.selectedIndex - 1 + this.searchResults.length) % this.searchResults.length;
331
+ }
332
+ return;
333
+ }
250
334
  const items = this._currentItems();
251
335
  if (items.length === 0) {
252
336
  if (this.currentCategory === 'flags' && this.flagEntries.length > 0) {
@@ -265,6 +349,12 @@ export class SettingsModal {
265
349
 
266
350
  moveDown(): void {
267
351
  if (this.editingMode) return;
352
+ if (this.searchFocused) {
353
+ if (this.searchResults.length > 0) {
354
+ this.selectedIndex = (this.selectedIndex + 1) % this.searchResults.length;
355
+ }
356
+ return;
357
+ }
268
358
  const items = this._currentItems();
269
359
  if (items.length === 0) {
270
360
  if (this.currentCategory === 'flags' && this.flagEntries.length > 0) {
@@ -282,6 +372,9 @@ export class SettingsModal {
282
372
  }
283
373
 
284
374
  getSelected(): SettingEntry | null {
375
+ if (this.searchFocused && this.searchResults.length > 0) {
376
+ return this.searchResults[Math.max(0, Math.min(this.searchResults.length - 1, this.selectedIndex))] ?? null;
377
+ }
285
378
  const items = this._currentItems();
286
379
  if (items.length === 0) return null;
287
380
  return items[Math.max(0, Math.min(items.length - 1, this.selectedIndex))] ?? null;
@@ -356,13 +449,9 @@ export class SettingsModal {
356
449
  const entry = this.getSelectedSubscription();
357
450
  if (!entry) return;
358
451
  if (entry.state === 'active' || entry.state === 'pending') {
359
- if (this.subscriptionLogoutConfirmationTarget !== entry.provider) {
360
- this.subscriptionLogoutConfirmationTarget = entry.provider;
361
- return;
362
- }
363
- this.subscriptionManager?.logout(entry.provider);
364
- this._loadSubscriptionEntries();
365
- this.subscriptionLogoutConfirmationTarget = null;
452
+ // First press: arm the confirm gate. Subsequent key handling routes
453
+ // through handleSubscriptionLogoutKey() before normal dispatch.
454
+ this.subscriptionLogoutConfirmationTarget = entry.provider;
366
455
  }
367
456
  return;
368
457
  }
@@ -394,11 +483,11 @@ export class SettingsModal {
394
483
 
395
484
  if (setting.type === 'boolean') {
396
485
  const newVal = !entry.currentValue;
397
- this._setValue(setting.key, newVal);
486
+ this._setValue(setting.key as ConfigKey, newVal);
398
487
  } else if (setting.type === 'enum' && setting.enumValues) {
399
488
  const idx = setting.enumValues.indexOf(entry.currentValue as string);
400
489
  const nextIdx = (idx + 1) % setting.enumValues.length;
401
- this._setValue(setting.key, setting.enumValues[nextIdx]);
490
+ this._setValue(setting.key as ConfigKey, setting.enumValues[nextIdx]);
402
491
  } else if (setting.type === 'string' || setting.type === 'number') {
403
492
  // Enter inline edit mode
404
493
  this.editingMode = true;
@@ -406,6 +495,31 @@ export class SettingsModal {
406
495
  }
407
496
  }
408
497
 
498
+ /**
499
+ * Handle a keystroke while a subscription logout confirm is pending.
500
+ *
501
+ * Follows the project-standard confirm contract (confirm-state.ts):
502
+ * - CONFIRM: Enter, Return, or y → executes logout, clears target
503
+ * - CANCEL: Esc or n → clears target, no logout
504
+ * - ABSORBED: any other key → keeps confirm pending, swallows key
505
+ * - INACTIVE: no confirm pending → returns 'inactive' (caller continues)
506
+ */
507
+ handleSubscriptionLogoutKey(key: string): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
508
+ const target = this.subscriptionLogoutConfirmationTarget;
509
+ if (!target) return 'inactive';
510
+ const confirmState = { subject: target, label: target };
511
+ const result = handleConfirmInput(confirmState, key);
512
+ if (result === 'confirmed') {
513
+ this.subscriptionManager?.logout(target);
514
+ this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
515
+ this.subscriptionLogoutConfirmationTarget = null;
516
+ } else if (result === 'cancelled') {
517
+ this.subscriptionLogoutConfirmationTarget = null;
518
+ }
519
+ // 'absorbed': confirm remains pending
520
+ return result;
521
+ }
522
+
409
523
  adjustSelected(direction: 'left' | 'right', step = 1): void {
410
524
  if (this.editingMode) return;
411
525
 
@@ -413,7 +527,7 @@ export class SettingsModal {
413
527
  const flagEntry = this.getSelectedFlag();
414
528
  if (!flagEntry || flagEntry.state === 'killed' || !this.featureFlagManager || !this.configManager) return;
415
529
  const targetState: FlagState = direction === 'right' ? 'enabled' : 'disabled';
416
- if (flagEntry.state !== targetState) this._setSelectedFlagState(flagEntry, targetState);
530
+ if (flagEntry.state !== targetState) applyFlagState(flagEntry, targetState, this.featureFlagManager, this.configManager);
417
531
  return;
418
532
  }
419
533
 
@@ -426,7 +540,7 @@ export class SettingsModal {
426
540
  ? (currentIndex + 1) % modes.length
427
541
  : (currentIndex - 1 + modes.length) % modes.length;
428
542
  this.mcpRegistry.setServerTrustMode(entry.name, modes[nextIndex]!);
429
- this._loadMcpEntries();
543
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
430
544
  this.mcpAllowAllConfirmationTarget = null;
431
545
  return;
432
546
  }
@@ -436,7 +550,7 @@ export class SettingsModal {
436
550
  const { setting } = entry;
437
551
 
438
552
  if (setting.type === 'boolean') {
439
- this._setValue(setting.key, direction === 'right');
553
+ this._setValue(setting.key as ConfigKey, direction === 'right');
440
554
  return;
441
555
  }
442
556
 
@@ -445,7 +559,7 @@ export class SettingsModal {
445
559
  const nextIndex = direction === 'right'
446
560
  ? (currentIndex + 1) % setting.enumValues.length
447
561
  : (currentIndex - 1 + setting.enumValues.length) % setting.enumValues.length;
448
- this._setValue(setting.key, setting.enumValues[nextIndex]!);
562
+ this._setValue(setting.key as ConfigKey, setting.enumValues[nextIndex]!);
449
563
  return;
450
564
  }
451
565
 
@@ -460,7 +574,7 @@ export class SettingsModal {
460
574
  Math.max(adjustment.min ?? rounded, rounded),
461
575
  );
462
576
  if (setting.validate && !setting.validate(nextValue)) return;
463
- this._setValue(setting.key, nextValue);
577
+ this._setValue(setting.key as ConfigKey, nextValue);
464
578
  }
465
579
  }
466
580
 
@@ -474,38 +588,13 @@ export class SettingsModal {
474
588
  const flagEntry = this.getSelectedFlag();
475
589
  if (!flagEntry || !this.featureFlagManager || !this.configManager) return;
476
590
 
477
- const { flag, state } = flagEntry;
591
+ const { state } = flagEntry;
478
592
 
479
593
  // Killed flags are blocked
480
594
  if (state === 'killed') return;
481
595
 
482
596
  const newState: FlagState = state === 'enabled' ? 'disabled' : 'enabled';
483
-
484
- this._setSelectedFlagState(flagEntry, newState);
485
- }
486
-
487
- private _setSelectedFlagState(flagEntry: FlagEntry, newState: FlagState): void {
488
- if (!this.featureFlagManager || !this.configManager) return;
489
- const { flag } = flagEntry;
490
-
491
- if (!flag.runtimeToggleable) {
492
- // Persist to config only — takes effect on restart
493
- this._persistFlagState(flag.id, newState, flag.defaultState as FlagState);
494
- flagEntry.state = newState;
495
- } else {
496
- // Toggle immediately in manager
497
- try {
498
- if (newState === 'enabled') {
499
- this.featureFlagManager.enable(flag.id);
500
- } else {
501
- this.featureFlagManager.disable(flag.id);
502
- }
503
- this._persistFlagState(flag.id, newState, flag.defaultState as FlagState);
504
- flagEntry.state = newState;
505
- } catch (e) {
506
- logger.error('SettingsModal: failed to toggle feature flag', { flag: flag.id, error: summarizeError(e) });
507
- }
508
- }
597
+ applyFlagState(flagEntry, newState, this.featureFlagManager, this.configManager);
509
598
  }
510
599
 
511
600
  /**
@@ -524,7 +613,7 @@ export class SettingsModal {
524
613
  return false;
525
614
  }
526
615
  this.mcpRegistry.setServerTrustMode(entry.name, 'allow-all');
527
- this._loadMcpEntries();
616
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
528
617
  this.editingMode = false;
529
618
  this.editBuffer = '';
530
619
  this.mcpAllowAllConfirmationTarget = null;
@@ -545,7 +634,7 @@ export class SettingsModal {
545
634
  return false;
546
635
  }
547
636
  this.mcpRegistry.setServerTrustMode(entry.name, nextMode);
548
- this._loadMcpEntries();
637
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
549
638
  this.editingMode = false;
550
639
  this.editBuffer = '';
551
640
  this.mcpAllowAllConfirmationTarget = null;
@@ -582,7 +671,7 @@ export class SettingsModal {
582
671
  setConfigValue: (key, value) => this._setValue(key, value),
583
672
  });
584
673
  } else {
585
- this._setValue(setting.key, parsed);
674
+ this._setValue(setting.key as ConfigKey, parsed);
586
675
  }
587
676
  this.editingMode = false;
588
677
  this.editBuffer = '';
@@ -597,17 +686,47 @@ export class SettingsModal {
597
686
  }
598
687
 
599
688
  resetSelected(): { key: ConfigKey; value: unknown } | null {
600
- if (this.editingMode || !this.configManager) return null;
601
- const entry = this.getSelected();
602
- if (!entry) return null;
603
- const key = entry.setting.key as ConfigKey;
604
- this._setValue(key, entry.setting.default);
605
- if (isSecretConfigKey(key) && this.secretsManager) {
606
- void this.secretsManager.delete(buildGoodVibesSecretKey(key), { scope: 'user' }).catch((error) => {
607
- logger.error('SettingsModal: failed to clear secret while resetting setting', { key, error: summarizeError(error) });
608
- });
609
- }
610
- return { key, value: entry.setting.default };
689
+ return _resetSelected({
690
+ editingMode: this.editingMode,
691
+ hasConfigManager: this.configManager !== null,
692
+ selected: this.getSelected(),
693
+ secretsManager: this.secretsManager,
694
+ setValue: (key, value) => this._setValue(key, value),
695
+ });
696
+ }
697
+
698
+ /** Arm a category-reset confirmation gate for the current category. */
699
+ initiateResetCategory(): void {
700
+ _initiateResetCategory({
701
+ hasConfigManager: this.configManager !== null,
702
+ currentCategory: this.currentCategory,
703
+ setResetCategoryConfirm: (v) => { this.resetCategoryConfirm = v; },
704
+ setResetAllConfirm: (v) => { this.resetAllConfirm = v; },
705
+ });
706
+ }
707
+
708
+ /** Arm a reset-all confirmation gate. */
709
+ initiateResetAll(): void {
710
+ _initiateResetAll({
711
+ hasConfigManager: this.configManager !== null,
712
+ setResetCategoryConfirm: (v) => { this.resetCategoryConfirm = v; },
713
+ setResetAllConfirm: (v) => { this.resetAllConfirm = v; },
714
+ });
715
+ }
716
+
717
+ /** Route a key through the active reset confirm gate. See ResetConfirmKeyResult for the return contract. */
718
+ handleResetConfirmKey(key: string): ResetConfirmKeyResult {
719
+ return _handleResetConfirmKey({
720
+ key,
721
+ resetCategoryConfirm: this.resetCategoryConfirm,
722
+ resetAllConfirm: this.resetAllConfirm,
723
+ hasConfigManager: this.configManager !== null,
724
+ currentItems: () => this._currentItems(),
725
+ groups: this.groups,
726
+ setValue: (k, value) => this._setValue(k, value),
727
+ setResetCategoryConfirm: (v) => { this.resetCategoryConfirm = v; },
728
+ setResetAllConfirm: (v) => { this.resetAllConfirm = v; },
729
+ });
611
730
  }
612
731
 
613
732
  /** Handle a keystroke in edit mode: regular chars appended, Backspace removes last char. */
@@ -621,173 +740,58 @@ export class SettingsModal {
621
740
  this.editBuffer = this.editBuffer.slice(0, -1);
622
741
  }
623
742
 
624
- // ── Private helpers ────────────────────────────────────────────
625
-
626
- private _loadGroups(configManager: ConfigManager): void {
627
- this.groups.clear();
628
- for (const cat of SETTINGS_CATEGORIES) {
629
- if (cat === 'flags') continue; // flags tab handled separately
630
- this.groups.set(cat, []);
631
- }
632
-
633
- for (const setting of CONFIG_SCHEMA) {
634
- const rawCat = setting.key.split('.')[0] as string;
635
- const cat = rawCat as SettingsCategory;
636
- const currentValue = configManager.get(setting.key as ConfigKey);
637
- const resolved = getResolvedSettingLookup(configManager, setting.key as ConfigKey)?.entry;
638
- const entry: SettingEntry = {
639
- setting,
640
- currentValue,
641
- isDefault: currentValue === setting.default,
642
- effectiveSource: resolved?.effectiveSource,
643
- locked: resolved?.locked,
644
- conflict: resolved?.conflict,
645
- sourceLabel: resolved?.sourceLabel,
646
- lockReason: resolved?.lockReason,
647
- };
648
- if (this.groups.has(cat)) this.groups.get(cat)!.push(entry);
649
- if ((rawCat === 'controlPlane' || rawCat === 'httpListener' || rawCat === 'web') && this.groups.has('network')) {
650
- this.groups.get('network')!.push(entry);
651
- }
652
- }
653
-
654
- const uiEntries = this.groups.get('ui');
655
- if (uiEntries) {
656
- const uiPriority: Record<string, number> = {
657
- 'ui.systemMessages': 0,
658
- 'ui.operationalMessages': 1,
659
- 'ui.wrfcMessages': 2,
660
- 'ui.voiceEnabled': 3,
661
- };
662
- uiEntries.sort((a, b) => (uiPriority[a.setting.key] ?? 99) - (uiPriority[b.setting.key] ?? 99));
663
- }
664
- }
665
-
666
- /** Load or refresh the flags tab entries from the feature flag manager. */
667
- private _loadFlagEntries(): void {
668
- if (!this.featureFlagManager) {
669
- this.flagEntries = [];
670
- return;
671
- }
672
- this.flagEntries = Array.from(this.featureFlagManager.getAll().values()).map(({ flag, state }) => ({
673
- flag,
674
- state,
675
- }));
676
- }
677
-
678
- private _loadMcpEntries(): void {
679
- if (!this.mcpRegistry) {
680
- this.mcpEntries = [];
681
- return;
682
- }
683
- this.mcpEntries = this.mcpRegistry.listServerSecurity().map((entry) => ({
684
- name: entry.name,
685
- connected: entry.connected,
686
- role: entry.role,
687
- trustMode: entry.trustMode,
688
- allowedPaths: [...entry.allowedPaths],
689
- allowedHosts: [...entry.allowedHosts],
690
- }));
691
- }
692
-
693
- private _loadSubscriptionEntries(): void {
694
- this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
695
- }
696
-
697
- /**
698
- * Persist a flag state override to config.
699
- * Deletes the entry when reverting to defaultState. Skips killed state.
700
- */
701
- private _persistFlagState(flagId: string, newState: FlagState, defaultState: FlagState): void {
702
- if (!this.configManager) return;
703
- if (newState === 'killed') return; // never persist killed state
743
+ // ── Private helpers ────────────────────────────────────────────────────────────────
704
744
 
705
- try {
706
- const current = (this.configManager.getCategory('featureFlags') as Record<string, PersistedFlagState>) ?? {};
707
- if (newState === defaultState) {
708
- // Revert to default — remove override
709
- delete current[flagId];
710
- } else {
711
- current[flagId] = newState;
712
- }
713
- this.configManager.mergeCategory('featureFlags', current);
714
- } catch (e) {
715
- logger.error('SettingsModal: failed to persist flag state', { flagId, error: summarizeError(e) });
745
+ /** Reload flag/mcp/subscription entries when the active tab changes. */
746
+ private _reloadTabEntries(): void {
747
+ if (this.currentCategory === 'flags') {
748
+ this.flagEntries = buildFlagEntries(this.featureFlagManager);
749
+ } else if (this.currentCategory === 'mcp') {
750
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
751
+ } else if (this.currentCategory === 'subscriptions') {
752
+ this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
716
753
  }
717
754
  }
718
755
 
719
- /** Returns [] for the flags category (flags use flagEntries instead). */
756
+ /** Returns [] for the flags/mcp/subscriptions categories. */
720
757
  private _currentItems(): SettingEntry[] {
721
- if (this.currentCategory === 'flags' || this.currentCategory === 'mcp' || this.currentCategory === 'subscriptions') return [];
758
+ if (
759
+ this.currentCategory === 'flags'
760
+ || this.currentCategory === 'mcp'
761
+ || this.currentCategory === 'subscriptions'
762
+ ) return [];
722
763
  const items = this.groups.get(this.currentCategory) ?? [];
723
764
  if (this.currentCategory === 'network') {
724
- return items.filter(entry => {
725
- if (entry.setting.key === 'controlPlane.host') {
726
- const hostMode = this.configManager?.get('controlPlane.hostMode');
727
- return hostMode === 'custom';
728
- }
729
- if (entry.setting.key === 'httpListener.host') {
730
- const hostMode = this.configManager?.get('httpListener.hostMode');
731
- return hostMode === 'custom';
732
- }
733
- if (entry.setting.key === 'web.host') {
734
- const hostMode = this.configManager?.get('web.hostMode');
735
- return hostMode === 'custom';
736
- }
737
- return true;
738
- });
765
+ return buildNetworkFilteredItems(items, this.configManager);
739
766
  }
740
767
  return items;
741
768
  }
742
769
 
743
- private _refreshAllEntries(): void {
744
- if (!this.configManager) return;
745
- for (const entries of this.groups.values()) {
746
- for (const entry of entries) {
747
- entry.currentValue = this.configManager.get(entry.setting.key as ConfigKey);
748
- entry.isDefault = entry.currentValue === entry.setting.default;
749
- }
750
- }
751
- }
752
-
753
770
  private _setValue(key: ConfigKey, value: unknown): void {
754
771
  if (!this.configManager) return;
755
- // Diff previous value before writing — avoids false restart notices on no-op saves
756
- const previousValue = this.configManager.get(key);
757
- const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
758
- try {
759
- this.configManager.setDynamic(key, value);
760
- const rawCat = key.split('.')[0] as string;
761
- if (rawCat === 'controlPlane') {
762
- if (isRestartKey && previousValue !== value) {
763
- this.lastSaveTriggeredRestart = 'control-plane';
764
- }
765
- } else if (rawCat === 'httpListener') {
766
- if (isRestartKey && previousValue !== value) {
767
- this.lastSaveTriggeredRestart = 'http-listener';
768
- }
769
- } else if (rawCat === 'web') {
770
- if (isRestartKey && previousValue !== value) {
771
- this.lastSaveTriggeredRestart = 'web';
772
- }
773
- }
774
772
 
775
- for (const entries of this.groups.values()) {
776
- const entry = entries.find((candidate) => candidate.setting.key === key);
777
- if (entry) {
778
- entry.currentValue = this.configManager!.get(key);
779
- entry.isDefault = entry.currentValue === entry.setting.default;
780
- }
781
- }
782
- if (previousValue !== value && this.onSettingApplied) {
783
- const result = this.onSettingApplied({ key, previousValue, value });
784
- this.lastSettingEffectMessage = result?.message ?? null;
785
- this._refreshAllEntries();
786
- }
787
- } catch (e) {
788
- logger.error('SettingsModal: failed to set config value', { key, error: summarizeError(e) });
789
- this.lastSettingEffectMessage = `Save failed: ${summarizeError(e)}`;
773
+ const callback: SettingAppliedCallback | null = this.onSettingApplied
774
+ ? (change) => this.onSettingApplied!(change)
775
+ : null;
776
+
777
+ const result = applySettingValue({
778
+ key,
779
+ value,
780
+ configManager: this.configManager,
781
+ groups: this.groups,
782
+ onSettingApplied: callback,
783
+ refreshGroups: () => {
784
+ if (this.configManager) refreshEntryValues(this.groups, this.configManager);
785
+ },
786
+ });
787
+
788
+ if (result.restartDomain !== null) {
789
+ this.lastSaveTriggeredRestart = result.restartDomain;
790
+ }
791
+ if (result.effectMessage !== null) {
792
+ this.lastSettingEffectMessage = result.effectMessage;
790
793
  }
794
+ // No-op (result.changed === false, effectMessage === null): leave lastSettingEffectMessage untouched.
791
795
  }
792
796
 
793
797
  }