@pellux/goodvibes-tui 0.20.3 → 0.21.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 (118) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +3 -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 +662 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/help.ts +4 -2
  11. package/src/cli/management-commands.ts +1 -1
  12. package/src/cli/management.ts +1 -8
  13. package/src/cli/parser.ts +14 -18
  14. package/src/cli/service-command.ts +1 -1
  15. package/src/cli/surface-command.ts +1 -1
  16. package/src/cli/tui-startup.ts +72 -10
  17. package/src/cli/types.ts +12 -3
  18. package/src/cli-flags.ts +1 -0
  19. package/src/config/atomic-write.ts +70 -0
  20. package/src/config/read-versioned.ts +115 -0
  21. package/src/core/conversation-rendering.ts +49 -15
  22. package/src/core/conversation.ts +101 -16
  23. package/src/core/format-user-error.ts +192 -0
  24. package/src/core/stream-event-wiring.ts +144 -0
  25. package/src/core/stream-stall-watchdog.ts +103 -0
  26. package/src/core/system-message-router.ts +5 -1
  27. package/src/export/cost-utils.ts +71 -0
  28. package/src/export/gist-uploader.ts +136 -0
  29. package/src/input/command-registry.ts +31 -1
  30. package/src/input/commands/control-room-runtime.ts +5 -5
  31. package/src/input/commands/experience-runtime.ts +5 -4
  32. package/src/input/commands/knowledge.ts +1 -1
  33. package/src/input/commands/local-auth-runtime.ts +27 -5
  34. package/src/input/commands/local-setup.ts +4 -6
  35. package/src/input/commands/memory-product-runtime.ts +8 -6
  36. package/src/input/commands/operator-panel-runtime.ts +1 -1
  37. package/src/input/commands/operator-runtime.ts +3 -10
  38. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  39. package/src/input/commands/recall-review.ts +26 -2
  40. package/src/input/commands/services-runtime.ts +2 -2
  41. package/src/input/commands/session-workflow.ts +3 -3
  42. package/src/input/commands/share-runtime.ts +99 -12
  43. package/src/input/commands/tts-runtime.ts +30 -4
  44. package/src/input/commands.ts +2 -2
  45. package/src/input/delete-key-policy.ts +46 -0
  46. package/src/input/feed-context-factory.ts +2 -0
  47. package/src/input/handler-feed.ts +3 -0
  48. package/src/input/handler-interactions.ts +2 -15
  49. package/src/input/handler-modal-routes.ts +91 -12
  50. package/src/input/handler-modal-token-routes.ts +3 -0
  51. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  52. package/src/input/handler-onboarding.ts +55 -69
  53. package/src/input/handler-types.ts +163 -0
  54. package/src/input/handler.ts +5 -2
  55. package/src/input/input-history.ts +76 -6
  56. package/src/input/model-picker-filter.ts +265 -0
  57. package/src/input/model-picker-items.ts +208 -0
  58. package/src/input/model-picker.ts +92 -325
  59. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  60. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  61. package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
  62. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
  63. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  64. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  65. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  66. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  67. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  68. package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
  69. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  70. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  71. package/src/input/settings-modal-data.ts +304 -0
  72. package/src/input/settings-modal-mutations.ts +154 -0
  73. package/src/input/settings-modal.ts +182 -220
  74. package/src/main.ts +57 -57
  75. package/src/panels/builtin/agent.ts +4 -1
  76. package/src/panels/builtin/development.ts +4 -1
  77. package/src/panels/confirm-state.ts +27 -12
  78. package/src/panels/cost-tracker-panel.ts +23 -67
  79. package/src/panels/eval-panel.ts +10 -9
  80. package/src/panels/knowledge-panel.ts +3 -5
  81. package/src/panels/local-auth-panel.ts +124 -4
  82. package/src/panels/project-planning-panel.ts +42 -4
  83. package/src/panels/search-focus.ts +11 -5
  84. package/src/panels/subscription-panel.ts +33 -25
  85. package/src/panels/types.ts +28 -1
  86. package/src/panels/wrfc-panel.ts +224 -41
  87. package/src/renderer/agent-detail-modal.ts +11 -10
  88. package/src/renderer/code-block.ts +10 -2
  89. package/src/renderer/compositor.ts +18 -4
  90. package/src/renderer/context-inspector.ts +1 -5
  91. package/src/renderer/diff.ts +94 -21
  92. package/src/renderer/markdown.ts +29 -13
  93. package/src/renderer/settings-modal-helpers.ts +1 -1
  94. package/src/renderer/settings-modal.ts +77 -8
  95. package/src/renderer/syntax-highlighter.ts +10 -3
  96. package/src/renderer/term-caps.ts +318 -0
  97. package/src/renderer/theme.ts +158 -0
  98. package/src/renderer/tool-call.ts +12 -2
  99. package/src/renderer/ui-factory.ts +50 -6
  100. package/src/runtime/bootstrap-command-context.ts +1 -0
  101. package/src/runtime/bootstrap-command-parts.ts +14 -0
  102. package/src/runtime/bootstrap-core.ts +121 -13
  103. package/src/runtime/bootstrap.ts +2 -0
  104. package/src/runtime/onboarding/apply.ts +4 -6
  105. package/src/runtime/onboarding/index.ts +1 -0
  106. package/src/runtime/onboarding/markers.ts +42 -49
  107. package/src/runtime/onboarding/progress.ts +148 -0
  108. package/src/runtime/onboarding/state.ts +133 -55
  109. package/src/runtime/onboarding/types.ts +20 -0
  110. package/src/runtime/services.ts +21 -0
  111. package/src/runtime/wrfc-persistence.ts +237 -0
  112. package/src/shell/blocking-input.ts +20 -5
  113. package/src/tools/wrfc-agent-guard.ts +64 -3
  114. package/src/utils/format-elapsed.ts +30 -0
  115. package/src/utils/terminal-width.ts +45 -0
  116. package/src/version.ts +1 -1
  117. package/src/work-plans/work-plan-store.ts +4 -6
  118. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -8,13 +8,16 @@
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
22
  import { buildGoodVibesSecretKey, isSecretConfigKey } from '../config/secret-config.ts';
20
23
  import {
@@ -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
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
35
36
  import {
36
37
  SETTINGS_CATEGORIES,
37
38
  SETTINGS_CATEGORY_GROUPS,
@@ -42,6 +43,23 @@ 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';
45
63
 
46
64
  export interface SettingsModalChange {
47
65
  readonly key: ConfigKey;
@@ -116,6 +134,27 @@ export class SettingsModal {
116
134
  /** Provider subscription entries (populated when subscriptions tab is active). */
117
135
  public subscriptionEntries: SubscriptionEntry[] = [];
118
136
 
137
+ /**
138
+ * Whether the user has entered search mode (pressed / or a printable key
139
+ * in the search input). Distinct from searchQuery.length > 0 because the
140
+ * user may have deleted all chars while remaining in search mode.
141
+ * Renderer should display the search prompt when this is true.
142
+ */
143
+ public searchFocused = false;
144
+
145
+ /**
146
+ * Current search query. Non-empty activates cross-category search mode.
147
+ * Renderer should display searchResults instead of the per-category list
148
+ * when searchQuery is non-empty.
149
+ */
150
+ public searchQuery = '';
151
+
152
+ /**
153
+ * Ranked cross-category results when searchQuery is non-empty.
154
+ * Populated by setSearchQuery(); empty when searchQuery is ''.
155
+ */
156
+ public searchResults: SettingEntry[] = [];
157
+
119
158
  /**
120
159
  * Set after a network-category save that touches controlPlane or httpListener
121
160
  * config keys. Renderer reads this to display a transient restart notice.
@@ -154,10 +193,10 @@ export class SettingsModal {
154
193
  this.serviceRegistry = serviceRegistry;
155
194
  this.mcpRegistry = mcpRegistry ?? null;
156
195
  this.onSettingApplied = options?.onSettingApplied ?? null;
157
- this._loadGroups(configManager);
158
- this._loadFlagEntries();
159
- this._loadMcpEntries();
160
- this._loadSubscriptionEntries();
196
+ this.groups = buildSettingGroups(configManager);
197
+ this.flagEntries = buildFlagEntries(featureFlagManager);
198
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
199
+ this.subscriptionEntries = buildSubscriptionEntries(subscriptionManager, serviceRegistry);
161
200
  this.categoryIndex = 0;
162
201
  this.selectedIndex = 0;
163
202
  this.focusPane = 'categories';
@@ -170,6 +209,9 @@ export class SettingsModal {
170
209
  this.subscriptionLogoutConfirmationTarget = null;
171
210
  this.lastSaveTriggeredRestart = null;
172
211
  this.lastSettingEffectMessage = null;
212
+ this.searchQuery = '';
213
+ this.searchResults = [];
214
+ this.searchFocused = false;
173
215
  this.active = true;
174
216
  }
175
217
 
@@ -184,25 +226,55 @@ export class SettingsModal {
184
226
  this.subscriptionLogoutConfirmationTarget = null;
185
227
  this.lastSaveTriggeredRestart = null;
186
228
  this.lastSettingEffectMessage = null;
229
+ this.searchQuery = '';
230
+ this.searchResults = [];
231
+ this.searchFocused = false;
187
232
  this.serviceRegistry = null;
188
233
  this.secretsManager = null;
189
234
  this.onSettingApplied = null;
190
235
  this.focusPane = 'settings';
191
236
  }
192
237
 
238
+ /** Enter search mode (focus the search input bar). */
239
+ focusSearch(): void {
240
+ this.searchFocused = true;
241
+ this.selectedIndex = 0;
242
+ }
243
+
244
+ /** Exit search mode without clearing the query. */
245
+ blurSearch(): void {
246
+ this.searchFocused = false;
247
+ }
248
+
249
+ /**
250
+ * Update the search query and recompute cross-category ranked results.
251
+ * Setting an empty string clears search mode.
252
+ */
253
+ setSearchQuery(query: string): void {
254
+ this.searchQuery = query;
255
+ this.searchFocused = true;
256
+ if (query.trim().length === 0) {
257
+ this.searchResults = [];
258
+ } else {
259
+ this.searchResults = searchSettingEntries(query, this.groups, getSettingLabel);
260
+ }
261
+ this.selectedIndex = 0;
262
+ }
263
+
264
+ /** Clear search query, results, and exit search focus mode. */
265
+ clearSearch(): void {
266
+ this.searchQuery = '';
267
+ this.searchResults = [];
268
+ this.searchFocused = false;
269
+ }
270
+
193
271
  /** Cycle to the next category (Tab). */
194
272
  nextCategory(): void {
195
273
  if (this.editingMode) return;
196
274
  this.categoryIndex = (this.categoryIndex + 1) % SETTINGS_CATEGORIES.length;
197
275
  this.selectedIndex = 0;
198
276
  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
- }
277
+ this._reloadTabEntries();
206
278
  }
207
279
 
208
280
  /** Cycle to the previous category (Shift+Tab). */
@@ -211,13 +283,7 @@ export class SettingsModal {
211
283
  this.categoryIndex = (this.categoryIndex - 1 + SETTINGS_CATEGORIES.length) % SETTINGS_CATEGORIES.length;
212
284
  this.selectedIndex = 0;
213
285
  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
- }
286
+ this._reloadTabEntries();
221
287
  }
222
288
 
223
289
  focusCategories(): void {
@@ -247,6 +313,12 @@ export class SettingsModal {
247
313
 
248
314
  moveUp(): void {
249
315
  if (this.editingMode) return;
316
+ if (this.searchFocused) {
317
+ if (this.searchResults.length > 0) {
318
+ this.selectedIndex = (this.selectedIndex - 1 + this.searchResults.length) % this.searchResults.length;
319
+ }
320
+ return;
321
+ }
250
322
  const items = this._currentItems();
251
323
  if (items.length === 0) {
252
324
  if (this.currentCategory === 'flags' && this.flagEntries.length > 0) {
@@ -265,6 +337,12 @@ export class SettingsModal {
265
337
 
266
338
  moveDown(): void {
267
339
  if (this.editingMode) return;
340
+ if (this.searchFocused) {
341
+ if (this.searchResults.length > 0) {
342
+ this.selectedIndex = (this.selectedIndex + 1) % this.searchResults.length;
343
+ }
344
+ return;
345
+ }
268
346
  const items = this._currentItems();
269
347
  if (items.length === 0) {
270
348
  if (this.currentCategory === 'flags' && this.flagEntries.length > 0) {
@@ -282,6 +360,9 @@ export class SettingsModal {
282
360
  }
283
361
 
284
362
  getSelected(): SettingEntry | null {
363
+ if (this.searchFocused && this.searchResults.length > 0) {
364
+ return this.searchResults[Math.max(0, Math.min(this.searchResults.length - 1, this.selectedIndex))] ?? null;
365
+ }
285
366
  const items = this._currentItems();
286
367
  if (items.length === 0) return null;
287
368
  return items[Math.max(0, Math.min(items.length - 1, this.selectedIndex))] ?? null;
@@ -356,13 +437,9 @@ export class SettingsModal {
356
437
  const entry = this.getSelectedSubscription();
357
438
  if (!entry) return;
358
439
  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;
440
+ // First press: arm the confirm gate. Subsequent key handling routes
441
+ // through handleSubscriptionLogoutKey() before normal dispatch.
442
+ this.subscriptionLogoutConfirmationTarget = entry.provider;
366
443
  }
367
444
  return;
368
445
  }
@@ -394,11 +471,11 @@ export class SettingsModal {
394
471
 
395
472
  if (setting.type === 'boolean') {
396
473
  const newVal = !entry.currentValue;
397
- this._setValue(setting.key, newVal);
474
+ this._setValue(setting.key as ConfigKey, newVal);
398
475
  } else if (setting.type === 'enum' && setting.enumValues) {
399
476
  const idx = setting.enumValues.indexOf(entry.currentValue as string);
400
477
  const nextIdx = (idx + 1) % setting.enumValues.length;
401
- this._setValue(setting.key, setting.enumValues[nextIdx]);
478
+ this._setValue(setting.key as ConfigKey, setting.enumValues[nextIdx]);
402
479
  } else if (setting.type === 'string' || setting.type === 'number') {
403
480
  // Enter inline edit mode
404
481
  this.editingMode = true;
@@ -406,6 +483,31 @@ export class SettingsModal {
406
483
  }
407
484
  }
408
485
 
486
+ /**
487
+ * Handle a keystroke while a subscription logout confirm is pending.
488
+ *
489
+ * Follows the project-standard confirm contract (confirm-state.ts):
490
+ * - CONFIRM: Enter, Return, or y → executes logout, clears target
491
+ * - CANCEL: Esc or n → clears target, no logout
492
+ * - ABSORBED: any other key → keeps confirm pending, swallows key
493
+ * - INACTIVE: no confirm pending → returns 'inactive' (caller continues)
494
+ */
495
+ handleSubscriptionLogoutKey(key: string): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
496
+ const target = this.subscriptionLogoutConfirmationTarget;
497
+ if (!target) return 'inactive';
498
+ const confirmState = { subject: target, label: target };
499
+ const result = handleConfirmInput(confirmState, key);
500
+ if (result === 'confirmed') {
501
+ this.subscriptionManager?.logout(target);
502
+ this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
503
+ this.subscriptionLogoutConfirmationTarget = null;
504
+ } else if (result === 'cancelled') {
505
+ this.subscriptionLogoutConfirmationTarget = null;
506
+ }
507
+ // 'absorbed': confirm remains pending
508
+ return result;
509
+ }
510
+
409
511
  adjustSelected(direction: 'left' | 'right', step = 1): void {
410
512
  if (this.editingMode) return;
411
513
 
@@ -413,7 +515,7 @@ export class SettingsModal {
413
515
  const flagEntry = this.getSelectedFlag();
414
516
  if (!flagEntry || flagEntry.state === 'killed' || !this.featureFlagManager || !this.configManager) return;
415
517
  const targetState: FlagState = direction === 'right' ? 'enabled' : 'disabled';
416
- if (flagEntry.state !== targetState) this._setSelectedFlagState(flagEntry, targetState);
518
+ if (flagEntry.state !== targetState) applyFlagState(flagEntry, targetState, this.featureFlagManager, this.configManager);
417
519
  return;
418
520
  }
419
521
 
@@ -426,7 +528,7 @@ export class SettingsModal {
426
528
  ? (currentIndex + 1) % modes.length
427
529
  : (currentIndex - 1 + modes.length) % modes.length;
428
530
  this.mcpRegistry.setServerTrustMode(entry.name, modes[nextIndex]!);
429
- this._loadMcpEntries();
531
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
430
532
  this.mcpAllowAllConfirmationTarget = null;
431
533
  return;
432
534
  }
@@ -436,7 +538,7 @@ export class SettingsModal {
436
538
  const { setting } = entry;
437
539
 
438
540
  if (setting.type === 'boolean') {
439
- this._setValue(setting.key, direction === 'right');
541
+ this._setValue(setting.key as ConfigKey, direction === 'right');
440
542
  return;
441
543
  }
442
544
 
@@ -445,7 +547,7 @@ export class SettingsModal {
445
547
  const nextIndex = direction === 'right'
446
548
  ? (currentIndex + 1) % setting.enumValues.length
447
549
  : (currentIndex - 1 + setting.enumValues.length) % setting.enumValues.length;
448
- this._setValue(setting.key, setting.enumValues[nextIndex]!);
550
+ this._setValue(setting.key as ConfigKey, setting.enumValues[nextIndex]!);
449
551
  return;
450
552
  }
451
553
 
@@ -460,7 +562,7 @@ export class SettingsModal {
460
562
  Math.max(adjustment.min ?? rounded, rounded),
461
563
  );
462
564
  if (setting.validate && !setting.validate(nextValue)) return;
463
- this._setValue(setting.key, nextValue);
565
+ this._setValue(setting.key as ConfigKey, nextValue);
464
566
  }
465
567
  }
466
568
 
@@ -474,38 +576,13 @@ export class SettingsModal {
474
576
  const flagEntry = this.getSelectedFlag();
475
577
  if (!flagEntry || !this.featureFlagManager || !this.configManager) return;
476
578
 
477
- const { flag, state } = flagEntry;
579
+ const { state } = flagEntry;
478
580
 
479
581
  // Killed flags are blocked
480
582
  if (state === 'killed') return;
481
583
 
482
584
  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
- }
585
+ applyFlagState(flagEntry, newState, this.featureFlagManager, this.configManager);
509
586
  }
510
587
 
511
588
  /**
@@ -524,7 +601,7 @@ export class SettingsModal {
524
601
  return false;
525
602
  }
526
603
  this.mcpRegistry.setServerTrustMode(entry.name, 'allow-all');
527
- this._loadMcpEntries();
604
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
528
605
  this.editingMode = false;
529
606
  this.editBuffer = '';
530
607
  this.mcpAllowAllConfirmationTarget = null;
@@ -545,7 +622,7 @@ export class SettingsModal {
545
622
  return false;
546
623
  }
547
624
  this.mcpRegistry.setServerTrustMode(entry.name, nextMode);
548
- this._loadMcpEntries();
625
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
549
626
  this.editingMode = false;
550
627
  this.editBuffer = '';
551
628
  this.mcpAllowAllConfirmationTarget = null;
@@ -582,7 +659,7 @@ export class SettingsModal {
582
659
  setConfigValue: (key, value) => this._setValue(key, value),
583
660
  });
584
661
  } else {
585
- this._setValue(setting.key, parsed);
662
+ this._setValue(setting.key as ConfigKey, parsed);
586
663
  }
587
664
  this.editingMode = false;
588
665
  this.editBuffer = '';
@@ -621,173 +698,58 @@ export class SettingsModal {
621
698
  this.editBuffer = this.editBuffer.slice(0, -1);
622
699
  }
623
700
 
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
701
+ // ── Private helpers ────────────────────────────────────────────────────────────────
704
702
 
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) });
703
+ /** Reload flag/mcp/subscription entries when the active tab changes. */
704
+ private _reloadTabEntries(): void {
705
+ if (this.currentCategory === 'flags') {
706
+ this.flagEntries = buildFlagEntries(this.featureFlagManager);
707
+ } else if (this.currentCategory === 'mcp') {
708
+ this.mcpEntries = buildMcpEntries(this.mcpRegistry);
709
+ } else if (this.currentCategory === 'subscriptions') {
710
+ this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
716
711
  }
717
712
  }
718
713
 
719
- /** Returns [] for the flags category (flags use flagEntries instead). */
714
+ /** Returns [] for the flags/mcp/subscriptions categories. */
720
715
  private _currentItems(): SettingEntry[] {
721
- if (this.currentCategory === 'flags' || this.currentCategory === 'mcp' || this.currentCategory === 'subscriptions') return [];
716
+ if (
717
+ this.currentCategory === 'flags'
718
+ || this.currentCategory === 'mcp'
719
+ || this.currentCategory === 'subscriptions'
720
+ ) return [];
722
721
  const items = this.groups.get(this.currentCategory) ?? [];
723
722
  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
- });
723
+ return buildNetworkFilteredItems(items, this.configManager);
739
724
  }
740
725
  return items;
741
726
  }
742
727
 
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
728
  private _setValue(key: ConfigKey, value: unknown): void {
754
729
  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
730
 
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)}`;
731
+ const callback: SettingAppliedCallback | null = this.onSettingApplied
732
+ ? (change) => this.onSettingApplied!(change)
733
+ : null;
734
+
735
+ const result = applySettingValue({
736
+ key,
737
+ value,
738
+ configManager: this.configManager,
739
+ groups: this.groups,
740
+ onSettingApplied: callback,
741
+ refreshGroups: () => {
742
+ if (this.configManager) refreshEntryValues(this.groups, this.configManager);
743
+ },
744
+ });
745
+
746
+ if (result.restartDomain !== null) {
747
+ this.lastSaveTriggeredRestart = result.restartDomain;
748
+ }
749
+ if (result.effectMessage !== null) {
750
+ this.lastSettingEffectMessage = result.effectMessage;
790
751
  }
752
+ // No-op (result.changed === false, effectMessage === null): leave lastSettingEffectMessage untouched.
791
753
  }
792
754
 
793
755
  }