@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
@@ -0,0 +1,378 @@
1
+ /**
2
+ * settings-modal-data — pure data-assembly helpers for SettingsModal.
3
+ *
4
+ * All functions are stateless: they take dependencies as arguments and return
5
+ * derived data without mutating state. The class in settings-modal.ts delegates
6
+ * to these during open() and tab-switch operations.
7
+ */
8
+
9
+ import { CONFIG_SCHEMA, type ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
10
+ import type { ConfigManager, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
11
+ import { getResolvedSettingLookup } from '@/runtime/index.ts';
12
+ import type { FeatureFlagManager } from '@/runtime/index.ts';
13
+ import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
14
+ import { buildSubscriptionEntries } from './settings-modal-subscriptions.ts';
15
+ import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
16
+ import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
17
+ import {
18
+ SETTINGS_CATEGORIES,
19
+ type FlagEntry,
20
+ type McpEntry,
21
+ type SettingEntry,
22
+ type SettingsCategory,
23
+ type SubscriptionEntry,
24
+ } from './settings-modal-types.ts';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // deepEqual — structural equality for isDefault comparisons
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Structural equality check for setting default comparisons.
32
+ * Handles scalars, arrays, and plain objects. Does NOT support
33
+ * circular references or non-plain prototypes — config defaults
34
+ * are always JSON-safe primitives, arrays, or plain objects.
35
+ */
36
+ export function deepEqual(a: unknown, b: unknown): boolean {
37
+ if (a === b) return true;
38
+ if (a === null || b === null) return false;
39
+ if (a === undefined || b === undefined) return false;
40
+ if (typeof a !== typeof b) return false;
41
+ if (typeof a !== 'object') return false;
42
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
43
+ if (Array.isArray(a) && Array.isArray(b)) {
44
+ if (a.length !== b.length) return false;
45
+ for (let i = 0; i < a.length; i++) {
46
+ if (!deepEqual(a[i], b[i])) return false;
47
+ }
48
+ return true;
49
+ }
50
+ const ao = a as Record<string, unknown>;
51
+ const bo = b as Record<string, unknown>;
52
+ const aKeys = Object.keys(ao);
53
+ const bKeys = Object.keys(bo);
54
+ if (aKeys.length !== bKeys.length) return false;
55
+ for (const key of aKeys) {
56
+ if (!Object.prototype.hasOwnProperty.call(bo, key)) return false;
57
+ if (!deepEqual(ao[key], bo[key])) return false;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // buildSettingGroups — loads CONFIG_SCHEMA into per-category SettingEntry maps
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export function buildSettingGroups(
67
+ configManager: ConfigManager,
68
+ ): Map<SettingsCategory, SettingEntry[]> {
69
+ const groups = new Map<SettingsCategory, SettingEntry[]>();
70
+ for (const cat of SETTINGS_CATEGORIES) {
71
+ if (cat === 'flags') continue;
72
+ groups.set(cat, []);
73
+ }
74
+
75
+ for (const setting of CONFIG_SCHEMA) {
76
+ const rawCat = setting.key.split('.')[0] as string;
77
+ const cat = rawCat as SettingsCategory;
78
+ const currentValue = configManager.get(setting.key as ConfigKey);
79
+ const resolved = getResolvedSettingLookup(configManager, setting.key as ConfigKey)?.entry;
80
+ const entry: SettingEntry = {
81
+ setting,
82
+ currentValue,
83
+ isDefault: deepEqual(currentValue, setting.default),
84
+ effectiveSource: resolved?.effectiveSource,
85
+ locked: resolved?.locked,
86
+ conflict: resolved?.conflict,
87
+ sourceLabel: resolved?.sourceLabel,
88
+ lockReason: resolved?.lockReason,
89
+ };
90
+ if (groups.has(cat)) groups.get(cat)!.push(entry);
91
+ if ((rawCat === 'controlPlane' || rawCat === 'httpListener' || rawCat === 'web') && groups.has('network')) {
92
+ groups.get('network')!.push(entry);
93
+ }
94
+ }
95
+
96
+ const uiEntries = groups.get('ui');
97
+ if (uiEntries) {
98
+ const uiPriority: Record<string, number> = {
99
+ 'ui.systemMessages': 0,
100
+ 'ui.operationalMessages': 1,
101
+ 'ui.wrfcMessages': 2,
102
+ 'ui.voiceEnabled': 3,
103
+ };
104
+ uiEntries.sort((a, b) => (uiPriority[a.setting.key] ?? 99) - (uiPriority[b.setting.key] ?? 99));
105
+ }
106
+
107
+ // Cross-list ui.voiceEnabled into the 'tts' category so that /config tts
108
+ // shows the always-speak toggle alongside the other TTS settings.
109
+ const ttsEntries = groups.get('tts');
110
+ if (ttsEntries && uiEntries) {
111
+ const voiceEnabledEntry = uiEntries.find((e) => e.setting.key === 'ui.voiceEnabled');
112
+ if (voiceEnabledEntry && !ttsEntries.some((e) => e.setting.key === 'ui.voiceEnabled')) {
113
+ ttsEntries.unshift(voiceEnabledEntry);
114
+ }
115
+ }
116
+
117
+ // Inject the synthetic tts.speed entry into the tts category.
118
+ // tts.speed is not yet a ConfigKey in the SDK schema (pending SDK addition).
119
+ // The entry is surfaced here with an honest description caveat so users can
120
+ // see and understand the setting before the SDK schema catches up.
121
+ if (ttsEntries && !ttsEntries.some((e) => e.setting.key === ('tts.speed' as ConfigKey))) {
122
+ ttsEntries.push(buildTtsSpeedSyntheticEntry(configManager));
123
+ }
124
+
125
+ return groups;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // TTS_SPEED_DEFAULT — the pending-SDK default for tts.speed
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Pending default for tts.speed. Matches the value the SDK will use once
134
+ * the schema field is added: 1 (normal speed, provider default).
135
+ * Used for the synthetic settings-modal entry and isDefault comparisons.
136
+ */
137
+ export const TTS_SPEED_DEFAULT = 1;
138
+
139
+ /**
140
+ * The synthetic ConfigSetting descriptor for tts.speed.
141
+ * `tts.speed` is not yet a ConfigKey in the SDK schema. This descriptor is
142
+ * TUI-local and is injected into the tts settings group so users can see
143
+ * and interact with the setting before the SDK schema catches up.
144
+ *
145
+ * The key is cast to ConfigKey because ConfigSetting requires it and the SDK
146
+ * will add this key in a future release. The cast is safe: configManager.get
147
+ * returns undefined for unknown keys rather than throwing.
148
+ */
149
+ export const TTS_SPEED_SYNTHETIC_SETTING: ConfigSetting = {
150
+ key: 'tts.speed' as ConfigKey,
151
+ type: 'number',
152
+ default: TTS_SPEED_DEFAULT,
153
+ description: 'Playback speed multiplier passed to the TTS provider (1.0 = normal). Takes effect immediately via the TUI bridge; SDK schema registration is pending (native typing only).',
154
+ };
155
+
156
+ /**
157
+ * Build the synthetic SettingEntry for tts.speed.
158
+ *
159
+ * Reads the raw value from configManager using a cast key (tts.speed is not
160
+ * yet a valid ConfigKey). If the value is absent or not a positive finite
161
+ * number, falls back to TTS_SPEED_DEFAULT and marks isDefault true.
162
+ */
163
+ export function buildTtsSpeedSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
164
+ const raw = configManager.get('tts.speed' as ConfigKey);
165
+ const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
166
+ const currentValue: number = isFinite(parsed) && parsed > 0 ? parsed : TTS_SPEED_DEFAULT;
167
+ return {
168
+ setting: TTS_SPEED_SYNTHETIC_SETTING,
169
+ currentValue,
170
+ isDefault: deepEqual(currentValue, TTS_SPEED_DEFAULT),
171
+ };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // buildFlagEntries — snapshot of current feature flag states
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export function buildFlagEntries(featureFlagManager: FeatureFlagManager | null): FlagEntry[] {
179
+ if (!featureFlagManager) return [];
180
+ return Array.from(featureFlagManager.getAll().values()).map(({ flag, state }) => ({
181
+ flag,
182
+ state,
183
+ }));
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // buildMcpEntries — snapshot of current MCP server security entries
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export function buildMcpEntries(mcpRegistry: McpRegistry | null): McpEntry[] {
191
+ if (!mcpRegistry) return [];
192
+ return mcpRegistry.listServerSecurity().map((entry) => ({
193
+ name: entry.name,
194
+ connected: entry.connected,
195
+ role: entry.role,
196
+ trustMode: entry.trustMode,
197
+ allowedPaths: [...entry.allowedPaths],
198
+ allowedHosts: [...entry.allowedHosts],
199
+ }));
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // buildSubscriptionEntries — re-export for use by SettingsModal
204
+ // ---------------------------------------------------------------------------
205
+
206
+ export { buildSubscriptionEntries };
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // buildNetworkFilteredItems — applies host-mode visibility rules for 'network' tab
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export function buildNetworkFilteredItems(
213
+ items: SettingEntry[],
214
+ configManager: ConfigManager | null,
215
+ ): SettingEntry[] {
216
+ return items.filter(entry => {
217
+ if (entry.setting.key === 'controlPlane.host') {
218
+ return configManager?.get('controlPlane.hostMode') === 'custom';
219
+ }
220
+ if (entry.setting.key === 'httpListener.host') {
221
+ return configManager?.get('httpListener.hostMode') === 'custom';
222
+ }
223
+ if (entry.setting.key === 'web.host') {
224
+ return configManager?.get('web.hostMode') === 'custom';
225
+ }
226
+ return true;
227
+ });
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // refreshEntryValues — re-reads currentValue/isDefault for all loaded entries
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /**
235
+ * Normalize a raw config value for the tts.speed synthetic entry.
236
+ * Returns the raw value if it is a positive finite number, otherwise falls
237
+ * back to TTS_SPEED_DEFAULT. Mirrors the logic in buildTtsSpeedSyntheticEntry.
238
+ */
239
+ function normalizeTtsSpeedValue(raw: unknown): number {
240
+ const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
241
+ return isFinite(parsed) && parsed > 0 ? parsed : TTS_SPEED_DEFAULT;
242
+ }
243
+
244
+ export function refreshEntryValues(
245
+ groups: Map<SettingsCategory, SettingEntry[]>,
246
+ configManager: ConfigManager,
247
+ ): void {
248
+ for (const entries of groups.values()) {
249
+ for (const entry of entries) {
250
+ const raw = configManager.get(entry.setting.key as ConfigKey);
251
+ // Synthetic entries (e.g. tts.speed) that have no SDK schema key return
252
+ // undefined from configManager. Normalize using the same logic used at
253
+ // construction time so isDefault stays accurate.
254
+ if (entry.setting.key === ('tts.speed' as ConfigKey)) {
255
+ entry.currentValue = normalizeTtsSpeedValue(raw);
256
+ } else {
257
+ entry.currentValue = raw;
258
+ }
259
+ entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
260
+ }
261
+ }
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // updateEntryForKey — updates a single setting entry after a value change
266
+ // ---------------------------------------------------------------------------
267
+
268
+ export function updateEntryForKey(
269
+ groups: Map<SettingsCategory, SettingEntry[]>,
270
+ key: ConfigKey,
271
+ configManager: ConfigManager,
272
+ ): void {
273
+ for (const entries of groups.values()) {
274
+ const entry = entries.find((candidate) => candidate.setting.key === key);
275
+ if (entry) {
276
+ const raw = configManager.get(key);
277
+ // Synthetic tts.speed entry: normalize using the same fallback logic.
278
+ entry.currentValue = key === ('tts.speed' as ConfigKey) ? normalizeTtsSpeedValue(raw) : raw;
279
+ entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
280
+ }
281
+ }
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // fuzzyScoreSettingEntry — score an entry against a query for ranked search
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * Score a single SettingEntry against a search query.
290
+ *
291
+ * Score tiers (higher = better match):
292
+ * - 3000–3999: exact key substring match (3000 + position bonus 0–999)
293
+ * - 2000–2999: exact label substring match (2000 + position bonus 0–999)
294
+ * - 1000–1999: exact description substring match (1000 + position bonus 0–999)
295
+ * - 1–99: subsequence match across key+label+description
296
+ *
297
+ * Returns null when the query does not match at all.
298
+ *
299
+ * @param query - The search string (already lowercased).
300
+ * @param entry - The setting entry to test.
301
+ * @param getLabel - Pure function mapping an entry to its display label.
302
+ */
303
+ export function fuzzyScoreSettingEntry(
304
+ query: string,
305
+ entry: SettingEntry,
306
+ getLabel: (e: SettingEntry) => string,
307
+ ): number | null {
308
+ if (query.length === 0) return 0;
309
+ const lq = query.toLowerCase();
310
+ const key = entry.setting.key.toLowerCase();
311
+ const label = getLabel(entry).toLowerCase();
312
+ const description = (entry.setting.description ?? '').toLowerCase();
313
+
314
+ // Tier 1: key substring — base 3000, position bonus up to 999
315
+ // A key match at position 0 scores 3999; at position 999 scores 3000.
316
+ const keyIdx = key.indexOf(lq);
317
+ if (keyIdx !== -1) return 3000 + Math.max(0, 999 - keyIdx);
318
+
319
+ // Tier 2: label substring — base 2000, position bonus up to 999
320
+ const labelIdx = label.indexOf(lq);
321
+ if (labelIdx !== -1) return 2000 + Math.max(0, 999 - labelIdx);
322
+
323
+ // Tier 3: description substring — base 1000, position bonus up to 999
324
+ const descIdx = description.indexOf(lq);
325
+ if (descIdx !== -1) return 1000 + Math.max(0, 999 - descIdx);
326
+
327
+ // Tier 4: subsequence across concatenated key + label + description — 1..99
328
+ const haystack = `${key} ${label} ${description}`;
329
+ let qi = 0;
330
+ let score = 0;
331
+ for (let ci = 0; ci < haystack.length && qi < lq.length; ci++) {
332
+ if (haystack[ci] === lq[qi]) {
333
+ qi++;
334
+ score++;
335
+ }
336
+ }
337
+ if (qi === lq.length) return Math.min(99, score);
338
+ return null;
339
+ }
340
+
341
+ /**
342
+ * Search all setting entries across all groups, returning results ranked by
343
+ * relevance score (highest first). Excludes the flags, mcp, and subscriptions
344
+ * special categories (which have their own entry types).
345
+ *
346
+ * @param query - User input string. Empty string returns [].
347
+ * @param groups - The settings group map from buildSettingGroups.
348
+ * @param getLabel - Pure function mapping an entry to its display label.
349
+ */
350
+ export function searchSettingEntries(
351
+ query: string,
352
+ groups: Map<SettingsCategory, SettingEntry[]>,
353
+ getLabel: (e: SettingEntry) => string,
354
+ ): SettingEntry[] {
355
+ if (query.trim().length === 0) return [];
356
+ const lq = query.trim().toLowerCase();
357
+ const seen = new Set<string>();
358
+ const scored: Array<{ entry: SettingEntry; score: number }> = [];
359
+ for (const entries of groups.values()) {
360
+ for (const entry of entries) {
361
+ // Deduplicate: network tab cross-lists keys already in controlPlane/httpListener/web
362
+ if (seen.has(entry.setting.key)) continue;
363
+ seen.add(entry.setting.key);
364
+ const score = fuzzyScoreSettingEntry(lq, entry, getLabel);
365
+ if (score !== null) scored.push({ entry, score });
366
+ }
367
+ }
368
+ scored.sort((a, b) => b.score - a.score);
369
+ return scored.map(r => r.entry);
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Re-export SubscriptionEntry for convenience
374
+ // ---------------------------------------------------------------------------
375
+
376
+ export type { SubscriptionEntry } from './settings-modal-types.ts';
377
+ export type { SubscriptionManager };
378
+ export type { ServiceInspectionQuery };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * settings-modal-mutations — pure mutation helpers for SettingsModal.
3
+ *
4
+ * These functions encapsulate the side-effectful write operations:
5
+ * applying config values, persisting feature flag state, and applying flag
6
+ * runtime toggles. Each function takes its dependencies as explicit arguments
7
+ * rather than accessing class-level state.
8
+ */
9
+
10
+ import type { ConfigKey, PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config';
11
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
12
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
+ import type { FeatureFlagManager } from '@/runtime/index.ts';
14
+ import type { FeatureFlag, FlagState } from '@/runtime/index.ts';
15
+ import type { FlagEntry, SettingEntry } from './settings-modal-types.ts';
16
+ import { deepEqual } from './settings-modal-data.ts';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // ApplyValueResult — returned by applySettingValue so the caller can react
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface ApplyValueResult {
23
+ /** Restart domain that was triggered, if any. */
24
+ readonly restartDomain: 'control-plane' | 'http-listener' | 'web' | null;
25
+ /** Message from onSettingApplied handler, if any. */
26
+ readonly effectMessage: string | null;
27
+ /** Whether the value actually changed (false = no-op). */
28
+ readonly changed: boolean;
29
+ }
30
+
31
+ export type SettingAppliedCallback = (change: {
32
+ readonly key: ConfigKey;
33
+ readonly previousValue: unknown;
34
+ readonly value: unknown;
35
+ }) => { readonly message?: string } | void;
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // applySettingValue
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export function applySettingValue({
42
+ key,
43
+ value,
44
+ configManager,
45
+ groups,
46
+ onSettingApplied,
47
+ refreshGroups,
48
+ }: {
49
+ key: ConfigKey;
50
+ value: unknown;
51
+ configManager: ConfigManager;
52
+ groups: Map<string, SettingEntry[]>;
53
+ onSettingApplied: SettingAppliedCallback | null;
54
+ /** Called after applying the value so the caller can re-read currentValues. */
55
+ refreshGroups: () => void;
56
+ }): ApplyValueResult {
57
+ const previousValue = configManager.get(key);
58
+ // REQUIRES_RESTART: SDK's ConfigSetting has no requiresRestart field yet (see
59
+ // goodvibes-sdk HANDOFF-FROM-TUI-SESSION-20260611.md §Item 8). Until it does,
60
+ // we detect restart-triggering keys by sub-key name heuristic below.
61
+ const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
62
+
63
+ try {
64
+ configManager.setDynamic(key, value);
65
+ } catch (e) {
66
+ logger.error('SettingsModal: failed to set config value', { key, error: summarizeError(e) });
67
+ return {
68
+ restartDomain: null,
69
+ effectMessage: `Save failed: ${summarizeError(e)}`,
70
+ changed: false,
71
+ };
72
+ }
73
+
74
+ // Update the entry in the groups map
75
+ for (const entries of groups.values()) {
76
+ const entry = entries.find((candidate) => candidate.setting.key === key);
77
+ if (entry) {
78
+ entry.currentValue = configManager.get(key);
79
+ entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
80
+ }
81
+ }
82
+
83
+ // Determine restart domain
84
+ let restartDomain: 'control-plane' | 'http-listener' | 'web' | null = null;
85
+ if (previousValue !== value && isRestartKey) {
86
+ const rawCat = key.split('.')[0] as string;
87
+ if (rawCat === 'controlPlane') restartDomain = 'control-plane';
88
+ else if (rawCat === 'httpListener') restartDomain = 'http-listener';
89
+ else if (rawCat === 'web') restartDomain = 'web';
90
+ }
91
+
92
+ // Fire change callback
93
+ let effectMessage: string | null = null;
94
+ if (previousValue !== value && onSettingApplied) {
95
+ const result = onSettingApplied({ key, previousValue, value });
96
+ effectMessage = result?.message ?? null;
97
+ refreshGroups();
98
+ }
99
+
100
+ return { restartDomain, effectMessage, changed: previousValue !== value };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // persistFlagState — write a flag override to config
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export function persistFlagState(
108
+ configManager: ConfigManager,
109
+ flagId: string,
110
+ newState: FlagState,
111
+ defaultState: FlagState,
112
+ ): void {
113
+ if (newState === 'killed') return; // never persist killed state
114
+
115
+ try {
116
+ const current = (configManager.getCategory('featureFlags') as Record<string, PersistedFlagState>) ?? {};
117
+ if (newState === defaultState) {
118
+ delete current[flagId];
119
+ } else {
120
+ current[flagId] = newState;
121
+ }
122
+ configManager.mergeCategory('featureFlags', current);
123
+ } catch (e) {
124
+ logger.error('SettingsModal: failed to persist flag state', { flagId, error: summarizeError(e) });
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // applyFlagState — toggle a feature flag (runtime + persist)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export function applyFlagState(
133
+ flagEntry: FlagEntry,
134
+ newState: FlagState,
135
+ featureFlagManager: FeatureFlagManager,
136
+ configManager: ConfigManager,
137
+ ): void {
138
+ const flag: FeatureFlag = flagEntry.flag;
139
+
140
+ if (!flag.runtimeToggleable) {
141
+ persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
142
+ flagEntry.state = newState;
143
+ return;
144
+ }
145
+
146
+ try {
147
+ if (newState === 'enabled') {
148
+ featureFlagManager.enable(flag.id);
149
+ } else {
150
+ featureFlagManager.disable(flag.id);
151
+ }
152
+ persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
153
+ flagEntry.state = newState;
154
+ } catch (e) {
155
+ logger.error('SettingsModal: failed to toggle feature flag', { flag: flag.id, error: summarizeError(e) });
156
+ }
157
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * settings-modal-reset — pure reset helpers for SettingsModal.
3
+ *
4
+ * These functions encapsulate the three reset operations:
5
+ * - resetSelected: reset the currently selected setting to its schema default
6
+ * - initiateResetCategory: arm the category-reset confirmation gate
7
+ * - initiateResetAll: arm the reset-all confirmation gate
8
+ * - handleResetConfirmKey: route a keypress through the active gate
9
+ *
10
+ * Each function takes its dependencies as explicit arguments rather than
11
+ * accessing class-level state. resetCategoryConfirm and resetAllConfirm
12
+ * remain public class fields on SettingsModal — the renderer reads them
13
+ * directly to decide the footer state.
14
+ */
15
+
16
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
17
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
+ import { buildGoodVibesSecretKey, isSecretConfigKey } from '../config/secret-config.ts';
19
+ import type { SettingEntry, SettingsCategory } from './settings-modal-types.ts';
20
+ import type { SettingsSecretsManager } from './settings-modal-secrets.ts';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // resetSelected
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function resetSelected({
27
+ editingMode,
28
+ hasConfigManager,
29
+ selected,
30
+ secretsManager,
31
+ setValue,
32
+ }: {
33
+ editingMode: boolean;
34
+ hasConfigManager: boolean;
35
+ selected: SettingEntry | null;
36
+ secretsManager: SettingsSecretsManager | null;
37
+ setValue: (key: ConfigKey, value: unknown) => void;
38
+ }): { key: ConfigKey; value: unknown } | null {
39
+ if (editingMode || !hasConfigManager) return null;
40
+ if (!selected) return null;
41
+ const key = selected.setting.key as ConfigKey;
42
+ setValue(key, selected.setting.default);
43
+ if (isSecretConfigKey(key) && secretsManager) {
44
+ void secretsManager.delete(buildGoodVibesSecretKey(key), { scope: 'user' }).catch((error) => {
45
+ logger.error('SettingsModal: failed to clear secret while resetting setting', { key, error: summarizeError(error) });
46
+ });
47
+ }
48
+ return { key, value: selected.setting.default };
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // initiateResetCategory
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export function initiateResetCategory({
56
+ hasConfigManager,
57
+ currentCategory,
58
+ setResetCategoryConfirm,
59
+ setResetAllConfirm,
60
+ }: {
61
+ hasConfigManager: boolean;
62
+ currentCategory: string;
63
+ setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
64
+ setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
65
+ }): void {
66
+ if (!hasConfigManager) return;
67
+ setResetCategoryConfirm({ subject: currentCategory });
68
+ setResetAllConfirm(null);
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // initiateResetAll
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export function initiateResetAll({
76
+ hasConfigManager,
77
+ setResetCategoryConfirm,
78
+ setResetAllConfirm,
79
+ }: {
80
+ hasConfigManager: boolean;
81
+ setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
82
+ setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
83
+ }): void {
84
+ if (!hasConfigManager) return;
85
+ setResetAllConfirm({ subject: 'all' });
86
+ setResetCategoryConfirm(null);
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // handleResetConfirmKey
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export type ResetConfirmKeyResult =
94
+ | { result: 'confirmed'; entries: ReadonlyArray<{ key: string; value: unknown }> }
95
+ | 'cancelled'
96
+ | 'absorbed'
97
+ | 'inactive';
98
+
99
+ export function handleResetConfirmKey({
100
+ key,
101
+ resetCategoryConfirm,
102
+ resetAllConfirm,
103
+ hasConfigManager,
104
+ currentItems,
105
+ groups,
106
+ setValue,
107
+ setResetCategoryConfirm,
108
+ setResetAllConfirm,
109
+ }: {
110
+ key: string;
111
+ resetCategoryConfirm: { readonly subject: string } | null;
112
+ resetAllConfirm: { readonly subject: 'all' } | null;
113
+ hasConfigManager: boolean;
114
+ currentItems: () => SettingEntry[];
115
+ groups: Map<SettingsCategory, SettingEntry[]>;
116
+ setValue: (key: ConfigKey, value: unknown) => void;
117
+ setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
118
+ setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
119
+ }): ResetConfirmKeyResult {
120
+ const gate = resetCategoryConfirm ?? resetAllConfirm;
121
+ if (!gate || !hasConfigManager) return 'inactive';
122
+
123
+ if (key === 'enter' || key === 'y') {
124
+ const entries: Array<{ key: string; value: unknown }> = [];
125
+ if (resetCategoryConfirm) {
126
+ // Reset all settings in the current category to defaults.
127
+ const items = currentItems();
128
+ for (const item of items) {
129
+ setValue(item.setting.key as ConfigKey, item.setting.default);
130
+ entries.push({ key: item.setting.key, value: item.setting.default });
131
+ }
132
+ setResetCategoryConfirm(null);
133
+ } else {
134
+ // Reset ALL settings across all categories to defaults.
135
+ for (const [, items] of groups) {
136
+ for (const item of items) {
137
+ setValue(item.setting.key as ConfigKey, item.setting.default);
138
+ entries.push({ key: item.setting.key, value: item.setting.default });
139
+ }
140
+ }
141
+ setResetAllConfirm(null);
142
+ }
143
+ return { result: 'confirmed', entries };
144
+ }
145
+
146
+ if (key === 'escape' || key === 'n') {
147
+ setResetCategoryConfirm(null);
148
+ setResetAllConfirm(null);
149
+ return 'cancelled';
150
+ }
151
+
152
+ // All other keys are absorbed while the gate is active.
153
+ return 'absorbed';
154
+ }