@pellux/goodvibes-tui 1.16.1 → 1.17.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 (219) hide show
  1. package/CHANGELOG.md +89 -9
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1091 -271
  4. package/package.json +7 -7
  5. package/src/cli/completions/generate.ts +5 -4
  6. package/src/cli/config-overrides.ts +51 -11
  7. package/src/cli/doctor.ts +4 -5
  8. package/src/cli/entrypoint.ts +13 -8
  9. package/src/cli/help.ts +7 -6
  10. package/src/cli/launch-auto-update.ts +214 -0
  11. package/src/cli/parser.ts +5 -0
  12. package/src/cli/status.ts +13 -0
  13. package/src/cli/surface-command.ts +5 -5
  14. package/src/cli/tui-startup.ts +60 -97
  15. package/src/cli/types.ts +2 -0
  16. package/src/config/index.ts +1 -1
  17. package/src/config/tui-extension-settings.ts +33 -0
  18. package/src/core/composer-state.ts +1 -1
  19. package/src/core/conversation-line-cache.ts +3 -3
  20. package/src/core/conversation-rendering.ts +4 -4
  21. package/src/core/conversation.ts +2 -2
  22. package/src/core/long-task-notifier.ts +7 -7
  23. package/src/core/stream-event-wiring.ts +8 -6
  24. package/src/core/system-message-noise.ts +1 -1
  25. package/src/core/system-message-router.ts +6 -6
  26. package/src/core/turn-cancellation.ts +1 -1
  27. package/src/core/turn-event-wiring.ts +8 -8
  28. package/src/daemon/cli.ts +20 -5
  29. package/src/daemon/lifecycle.ts +41 -0
  30. package/src/daemon/service-commands.ts +5 -5
  31. package/src/export/cost-utils.ts +128 -6
  32. package/src/input/autocomplete.ts +2 -2
  33. package/src/input/command-registry.ts +34 -9
  34. package/src/input/commands/ci-runtime.ts +5 -1
  35. package/src/input/commands/codebase-runtime.ts +6 -6
  36. package/src/input/commands/cost-runtime.ts +1 -1
  37. package/src/input/commands/health-runtime.ts +2 -2
  38. package/src/input/commands/intelligence-runtime.ts +1 -1
  39. package/src/input/commands/local-auth-runtime.ts +1 -1
  40. package/src/input/commands/local-provider-runtime.ts +49 -10
  41. package/src/input/commands/local-runtime.ts +53 -5
  42. package/src/input/commands/memory.ts +2 -2
  43. package/src/input/commands/operator-panel-runtime.ts +5 -5
  44. package/src/input/commands/permissions-provenance.ts +1 -1
  45. package/src/input/commands/permissions-runtime.ts +99 -8
  46. package/src/input/commands/planning-runtime.ts +3 -3
  47. package/src/input/commands/platform-sandbox-runtime.ts +1 -1
  48. package/src/input/commands/provider-accounts-runtime.ts +71 -6
  49. package/src/input/commands/provider.ts +7 -5
  50. package/src/input/commands/recall-files-git.ts +13 -51
  51. package/src/input/commands/recall-files.ts +8 -11
  52. package/src/input/commands/recall-review.ts +2 -2
  53. package/src/input/commands/relay-runtime.ts +1 -1
  54. package/src/input/commands/remote-runtime.ts +1 -1
  55. package/src/input/commands/runtime-services.ts +4 -4
  56. package/src/input/commands/schedule-runtime.ts +14 -7
  57. package/src/input/commands/services-runtime.ts +1 -1
  58. package/src/input/commands/session-workflow.ts +2 -2
  59. package/src/input/commands/settings-sync-runtime.ts +2 -2
  60. package/src/input/commands/share-runtime.ts +1 -1
  61. package/src/input/commands/shell-core.ts +46 -68
  62. package/src/input/commands/subscription-runtime.ts +1 -1
  63. package/src/input/commands/update-runtime.ts +153 -96
  64. package/src/input/commands/work-plan-runtime.ts +1 -1
  65. package/src/input/commands/workstream-runtime.ts +2 -2
  66. package/src/input/commands.ts +0 -2
  67. package/src/input/config-modal-types.ts +3 -3
  68. package/src/input/config-modal.ts +10 -11
  69. package/src/input/feature-unit-layout.ts +93 -82
  70. package/src/input/feed-context-factory.ts +2 -2
  71. package/src/input/handler-command-route.ts +2 -2
  72. package/src/input/handler-content-actions.ts +1 -1
  73. package/src/input/handler-feed-routes.ts +7 -7
  74. package/src/input/handler-feed.ts +4 -4
  75. package/src/input/handler-interactions.ts +3 -2
  76. package/src/input/handler-modal-routes.ts +12 -4
  77. package/src/input/handler-modal-token-routes.ts +1 -1
  78. package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
  79. package/src/input/handler-onboarding.ts +3 -2
  80. package/src/input/handler-picker-routes.ts +26 -13
  81. package/src/input/handler-shortcuts.ts +6 -6
  82. package/src/input/handler.ts +2 -2
  83. package/src/input/keybindings.ts +2 -2
  84. package/src/input/onboarding/onboarding-feature-units.ts +34 -27
  85. package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
  86. package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
  87. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
  88. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
  89. package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
  90. package/src/input/panel-integration-actions.ts +2 -2
  91. package/src/input/panel-mouse-geometry.ts +1 -1
  92. package/src/input/panel-paste-flood-guard.ts +1 -1
  93. package/src/input/provider-key-intake.ts +167 -0
  94. package/src/input/sandbox-exec-config.ts +2 -2
  95. package/src/input/selection-modal.ts +1 -1
  96. package/src/input/settings-modal-activation.ts +5 -0
  97. package/src/input/settings-modal-adjustment.ts +4 -31
  98. package/src/input/settings-modal-data.ts +22 -21
  99. package/src/input/settings-modal-mutations.ts +12 -68
  100. package/src/input/settings-modal-types.ts +23 -16
  101. package/src/input/settings-modal.ts +74 -55
  102. package/src/main.ts +48 -48
  103. package/src/panels/agent-inspector-shared.ts +4 -4
  104. package/src/panels/base-panel.ts +2 -2
  105. package/src/panels/builtin/agent.ts +4 -4
  106. package/src/panels/builtin/development.ts +4 -4
  107. package/src/panels/builtin/knowledge.ts +1 -1
  108. package/src/panels/builtin/operations.ts +29 -10
  109. package/src/panels/builtin/session.ts +5 -5
  110. package/src/panels/builtin/shared.ts +4 -4
  111. package/src/panels/builtin-modals.ts +10 -10
  112. package/src/panels/builtin-panels.ts +1 -1
  113. package/src/panels/cost-tracker-panel.ts +124 -7
  114. package/src/panels/diff-panel.ts +5 -5
  115. package/src/panels/eval-registry.ts +2 -2
  116. package/src/panels/fleet-deep-link.ts +1 -1
  117. package/src/panels/fleet-panel-format.ts +44 -8
  118. package/src/panels/fleet-panel.ts +16 -16
  119. package/src/panels/fleet-read-model.ts +52 -13
  120. package/src/panels/fleet-steer.ts +2 -2
  121. package/src/panels/fleet-stop.ts +3 -3
  122. package/src/panels/fleet-tabs.ts +13 -14
  123. package/src/panels/fleet-transcript.ts +5 -5
  124. package/src/panels/git-panel.ts +2 -2
  125. package/src/panels/index.ts +4 -1
  126. package/src/panels/local-auth-panel.ts +1 -1
  127. package/src/panels/modals/hooks-modal.ts +1 -1
  128. package/src/panels/modals/keybindings-modal.ts +2 -2
  129. package/src/panels/modals/knowledge-modal.ts +1 -1
  130. package/src/panels/modals/marketplace-modal.ts +4 -4
  131. package/src/panels/modals/memory-modal.ts +4 -4
  132. package/src/panels/modals/modal-surface-helpers.ts +1 -1
  133. package/src/panels/modals/modal-theme.ts +1 -1
  134. package/src/panels/modals/pairing-modal.ts +2 -2
  135. package/src/panels/modals/planning-modal.ts +2 -2
  136. package/src/panels/modals/plugins-modal.ts +1 -1
  137. package/src/panels/modals/policy-modal.ts +1 -1
  138. package/src/panels/modals/security-modal.ts +1 -1
  139. package/src/panels/modals/skills-modal.ts +1 -1
  140. package/src/panels/modals/work-plan-modal.ts +3 -3
  141. package/src/panels/notifications-feed.ts +127 -0
  142. package/src/panels/notifications-panel.ts +160 -0
  143. package/src/panels/panel-manager.ts +9 -9
  144. package/src/panels/plugins-panel.ts +4 -4
  145. package/src/panels/project-planning-answer-actions.ts +1 -1
  146. package/src/panels/provider-health-routes.ts +2 -2
  147. package/src/panels/skills-panel.ts +4 -5
  148. package/src/panels/token-budget-panel.ts +11 -3
  149. package/src/panels/types.ts +5 -5
  150. package/src/permissions/broker-approval-card.ts +201 -0
  151. package/src/permissions/hunk-selection.ts +28 -4
  152. package/src/permissions/prompt.ts +244 -29
  153. package/src/renderer/autocomplete-overlay.ts +107 -54
  154. package/src/renderer/code-block.ts +2 -2
  155. package/src/renderer/compaction-history-modal.ts +1 -1
  156. package/src/renderer/compaction-preview.ts +1 -1
  157. package/src/renderer/compaction-quality.ts +1 -1
  158. package/src/renderer/compositor.ts +2 -2
  159. package/src/renderer/config-modal.ts +1 -1
  160. package/src/renderer/conversation-overlays.ts +1 -1
  161. package/src/renderer/footer-tips.ts +3 -3
  162. package/src/renderer/hint-grammar.ts +1 -1
  163. package/src/renderer/modal-factory.ts +1 -1
  164. package/src/renderer/model-picker-overlay.ts +1 -1
  165. package/src/renderer/model-workspace.ts +7 -2
  166. package/src/renderer/panel-composite.ts +2 -2
  167. package/src/renderer/panel-workspace-bar.ts +1 -1
  168. package/src/renderer/process-indicator.ts +1 -1
  169. package/src/renderer/selection-modal-overlay.ts +1 -1
  170. package/src/renderer/session-picker-modal.ts +2 -2
  171. package/src/renderer/settings-modal-helpers.ts +14 -5
  172. package/src/renderer/settings-modal.ts +127 -39
  173. package/src/renderer/shell-surface.ts +3 -0
  174. package/src/renderer/terminal-bg-probe.ts +1 -1
  175. package/src/renderer/theme.ts +3 -3
  176. package/src/renderer/tool-call.ts +1 -1
  177. package/src/renderer/tool-result-summary.ts +1 -1
  178. package/src/renderer/turn-injection.ts +7 -6
  179. package/src/renderer/ui-factory.ts +6 -1
  180. package/src/runtime/bootstrap-command-context.ts +4 -1
  181. package/src/runtime/bootstrap-command-parts.ts +6 -4
  182. package/src/runtime/bootstrap-core.ts +35 -35
  183. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  184. package/src/runtime/bootstrap-shell.ts +6 -5
  185. package/src/runtime/bootstrap.ts +34 -32
  186. package/src/runtime/channel-composition.ts +46 -0
  187. package/src/runtime/code-index-services.ts +3 -4
  188. package/src/runtime/daemon-attach-notices.ts +120 -0
  189. package/src/runtime/durability-services.ts +55 -0
  190. package/src/runtime/embedded-service-factories.ts +40 -0
  191. package/src/runtime/feature-settings.ts +147 -0
  192. package/src/runtime/index.ts +4 -21
  193. package/src/runtime/legacy-daemon-migration.ts +29 -19
  194. package/src/runtime/memory-fold.ts +1 -1
  195. package/src/runtime/notification-dispatch.ts +110 -0
  196. package/src/runtime/onboarding/apply.ts +3 -29
  197. package/src/runtime/onboarding/fast-path.ts +39 -6
  198. package/src/runtime/onboarding/provider-key-capture.ts +126 -0
  199. package/src/runtime/onboarding/snapshot.ts +0 -1
  200. package/src/runtime/onboarding/types.ts +9 -3
  201. package/src/runtime/provider-fallback.ts +55 -0
  202. package/src/runtime/provider-optimizer-wiring.ts +55 -0
  203. package/src/runtime/release-artifacts.ts +19 -103
  204. package/src/runtime/resume-notice.ts +1 -1
  205. package/src/runtime/services.ts +81 -82
  206. package/src/runtime/session-continuity-hints.ts +38 -0
  207. package/src/runtime/surface-feature-flags.ts +40 -9
  208. package/src/runtime/terminal-output-guard.ts +2 -2
  209. package/src/runtime/trust/workspace-registration.ts +10 -2
  210. package/src/runtime/trust/workspace-trust.ts +58 -31
  211. package/src/runtime/ui-services.ts +3 -1
  212. package/src/runtime/update-check.ts +15 -69
  213. package/src/runtime/workstream-services.ts +2 -2
  214. package/src/shell/blocking-input.ts +102 -6
  215. package/src/shell/ui-openers.ts +38 -6
  216. package/src/utils/format-duration.ts +1 -1
  217. package/src/verification/verification-ledger.ts +21 -4
  218. package/src/version.ts +1 -1
  219. package/src/input/commands/flags-runtime.ts +0 -328
@@ -10,6 +10,8 @@ import {
10
10
  } from './onboarding-wizard-external-surfaces.ts';
11
11
  import { buildFeatureUnitSteps } from './onboarding-feature-units.ts';
12
12
  import { countSelected, modelSelectionLabel, normalizeText } from './onboarding-wizard-helpers.ts';
13
+ import { deriveProviderKeyCaptureTargets } from '../../runtime/onboarding/provider-key-capture.ts';
14
+ import { buildProviderKeyInventoryField, buildProviderKeyMaskedFields, buildSubscriptionPosture } from './onboarding-provider-key-fields.ts';
13
15
  import { getDaemonSource, pushDaemonAdoptionFields, pushLegacyDaemonMigrationFields } from './onboarding-wizard-network-adopt.ts';
14
16
  import type { OnboardingWizardControllerLike } from './onboarding-wizard-types.ts';
15
17
  import type { OnboardingWizardAcknowledgementFieldDefinition, OnboardingWizardActionFieldDefinition, OnboardingWizardChecklistFieldDefinition, OnboardingWizardExternalSurfaceStepId, OnboardingWizardFieldDefinition, OnboardingWizardModelPickerFieldDefinition, OnboardingWizardRadioFieldDefinition, OnboardingWizardRadioOption, OnboardingWizardStepDefinition } from './onboarding-wizard-types.ts';
@@ -150,10 +152,20 @@ export function buildProvidersStep(controller: OnboardingWizardControllerLike):
150
152
  const providerAck = controller.runtimeDerived.reopenEditAcknowledgements.providers;
151
153
  const activeSubscriptions = controller.runtimeSnapshot?.subscriptions.active ?? [];
152
154
  const pendingSubscriptions = controller.runtimeSnapshot?.subscriptions.pending ?? [];
153
- const openAiActive = activeSubscriptions.some((subscription) => subscription.provider === 'openai');
154
- const openAiPending = pendingSubscriptions.some((subscription) => subscription.provider === 'openai');
155
- const providerSecretCount = controller.runtimeSnapshot?.secrets.records.filter((record) => record.key.endsWith('_API_KEY') || record.key.endsWith('_TOKEN')).length ?? 0;
156
- const openAiApiKeyConfigured = controller.runtimeSnapshot?.secrets.records.some((record) => record.key === 'OPENAI_API_KEY') ?? false;
155
+ // Provider-agnostic subscription posture: one status row per
156
+ // subscription-capable provider (registration truth), plus any provider with
157
+ // a live/pending session, and which provider's in-wizard sign-in applies now.
158
+ const subscription = buildSubscriptionPosture(
159
+ activeSubscriptions.map((entry) => entry.provider),
160
+ pendingSubscriptions.map((entry) => entry.provider),
161
+ controller.runtimeSnapshot?.providerAccounts?.providers,
162
+ );
163
+ const signInPending = subscription.signInPending;
164
+ // Provider-agnostic key capture: one field per provider the registry knows
165
+ // accepts an API key (derived from registration truth — never a hardcoded
166
+ // vendor list). No provider is named as the only option.
167
+ const keyTargets = deriveProviderKeyCaptureTargets(controller.runtimeSnapshot?.providerAccounts?.providers);
168
+ const configuredKeyCount = keyTargets.filter((target) => target.configured).length;
157
169
  const providerReviewField: OnboardingWizardAcknowledgementFieldDefinition = {
158
170
  kind: 'acknowledgement',
159
171
  id: 'providers.reviewed',
@@ -166,46 +178,21 @@ export function buildProvidersStep(controller: OnboardingWizardControllerLike):
166
178
  };
167
179
 
168
180
  const fields: OnboardingWizardFieldDefinition[] = [
169
- {
170
- kind: 'status',
171
- id: 'providers.openai-subscription',
172
- label: 'OpenAI subscription status',
173
- hint: openAiActive
174
- ? 'An OpenAI subscription session is already available.'
175
- : openAiPending
176
- ? 'An OpenAI subscription login is pending.'
177
- : 'No OpenAI subscription session was found in the current runtime state.',
178
- defaultValue: openAiActive ? 'Active' : openAiPending ? 'Pending' : 'Not detected',
179
- },
180
- {
181
- kind: 'status',
182
- id: 'providers.api-key-inventory',
183
- label: 'Provider API key inventory',
184
- hint: providerSecretCount > 0
185
- ? `${providerSecretCount} provider credential reference(s) were found. Values stay masked.`
186
- : 'No provider API key references were detected in the current runtime state.',
187
- defaultValue: providerSecretCount > 0 ? `${providerSecretCount} configured` : 'None detected',
188
- },
189
- {
190
- kind: 'masked',
191
- id: 'providers.openai-api-key',
192
- label: 'OpenAI API key',
193
- hint: openAiApiKeyConfigured
194
- ? 'An OpenAI API key is already stored. Leave blank to keep it; enter a new key to replace it through the secret manager.'
195
- : 'Optional: enter an OpenAI API key now. The value is stored through the secret manager, not in config.',
196
- placeholder: openAiApiKeyConfigured ? 'already configured' : 'sk-...',
197
- defaultValue: '',
198
- },
199
- ...(openAiActive ? [] : [
181
+ ...subscription.statusFields,
182
+ buildProviderKeyInventoryField(keyTargets),
183
+ // One masked field per key-accepting provider. Entered values are stored
184
+ // through the secret manager at apply and the provider re-registers live.
185
+ ...buildProviderKeyMaskedFields(keyTargets),
186
+ ...(!subscription.signInProviderId ? [] : [
200
187
  {
201
188
  kind: 'action' as const,
202
189
  id: 'providers.openai-subscription-start',
203
190
  action: 'start-openai-subscription' as const,
204
- label: openAiPending ? 'Restart OpenAI subscription sign-in' : 'Start OpenAI subscription sign-in',
191
+ label: signInPending ? 'Restart OpenAI subscription sign-in' : 'Start OpenAI subscription sign-in',
205
192
  hint: 'Opens the OpenAI sign-in flow from the wizard and records pending login state here.',
206
- defaultValue: openAiPending ? 'Restart' : 'Start',
193
+ defaultValue: signInPending ? 'Restart' : 'Start',
207
194
  },
208
- ...(openAiPending ? [
195
+ ...(signInPending ? [
209
196
  {
210
197
  kind: 'text' as const,
211
198
  id: 'providers.openai-authorization-url',
@@ -239,12 +226,13 @@ export function buildProvidersStep(controller: OnboardingWizardControllerLike):
239
226
  id: 'provider-access',
240
227
  title: 'AI provider access',
241
228
  shortLabel: 'Providers',
242
- description: 'Review subscription posture and optionally add an OpenAI API key directly through the wizard.',
229
+ description: 'Review subscription posture and optionally add an API key for any provider that accepts one, directly through the wizard.',
243
230
  summaryTitle: 'Provider access summary',
244
231
  summaryLines: [
245
- `OpenAI subscription: ${openAiActive ? 'active' : openAiPending ? 'pending' : 'not detected'}`,
246
- `OpenAI API key: ${openAiApiKeyConfigured ? 'configured' : 'not detected'}`,
247
- `Provider credential references: ${providerSecretCount}`,
232
+ subscription.summaryLine,
233
+ keyTargets.length === 0
234
+ ? 'Provider API keys: no key-accepting providers registered'
235
+ : `Provider API keys: ${configuredKeyCount}/${keyTargets.length} configured`,
248
236
  `Review: ${controller.getFieldValueLabel(providerReviewField)}`,
249
237
  ],
250
238
  fields,
@@ -2,14 +2,14 @@ import type { CommandContext } from './command-registry.ts';
2
2
  import type { Panel } from '../panels/types.ts';
3
3
  import type { PanelManager } from '../panels/panel-manager.ts';
4
4
 
5
- // W6.1 (the purge): every instanceof-routed branch this file used to carry
5
+ // (the purge): every instanceof-routed branch this file used to carry
6
6
  // (FileExplorerPanel/FilePreviewPanel/SymbolOutlinePanel — DELETE;
7
7
  // ApprovalPanel/TasksPanel/OrchestrationPanel/AgentInspectorPanel —
8
8
  // RETIRE-INTO-FLEET) targeted a panel class that no longer exists. Panels
9
9
  // migrated onto the formal `Panel.handlePanelIntegrationAction` hook (the
10
10
  // preferred seam — see the comment below) are unaffected; this function now
11
11
  // only provides that passthrough. See
12
- // .goodvibes/audit/2026-07-04-wave6-briefs.json (W6.1) for the disposition
12
+ // .goodvibes/audit/2026-07-04-wave6-briefs.json for the disposition
13
13
  // map.
14
14
  export function handlePanelIntegrationAction(
15
15
  panelManager: PanelManager,
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Pure mouse-geometry helpers for the panel workspace, extracted from
5
5
  // handler-feed-routes.ts so that file stays under the 800-line architecture
6
- // cap once the UX-C focus verbs and the DEBT-5 paste-flood guard both live in
6
+ // cap once the focus verbs and the paste-flood guard both live in
7
7
  // it. These map a terminal (row, col) onto the panel/pane/workspace-tab under
8
8
  // the cursor. No dependency on the feed-route state types, so no import cycle.
9
9
  // ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  // ---------------------------------------------------------------------------
2
- // panel-paste-flood-guard.ts — DEBT-5 item 5.
2
+ // panel-paste-flood-guard.ts — item 5.
3
3
  //
4
4
  // A terminal WITHOUT bracketed paste delivers a pasted block as a burst of
5
5
  // discrete 1-char 'text' tokens (isPasteToken stays false for every one of
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Provider key intake — capture a provider's API key at the moment the user
3
+ * selects an unconfigured provider or model, store it through the secrets
4
+ * manager (never env-only, never cleartext into config or provider JSON), let
5
+ * the provider re-register live, then complete the user's original selection.
6
+ *
7
+ * The decision of whether a key is needed is read from the provider's own
8
+ * registration-time auth state (describeAuthState / isConfigured) — never a
9
+ * hardcoded vendor list — so this works for any provider the registry knows
10
+ * accepts a key. The secrets-store key name is the provider's declared auth env
11
+ * var; it is used only as the storage key and is never shown to the user.
12
+ */
13
+
14
+ import type { LLMProvider } from '@pellux/goodvibes-sdk/platform/providers';
15
+ import type { ConcealedInputRequest } from './concealed-input.ts';
16
+ import type { CommandContext } from './command-registry.ts';
17
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
+
19
+ /** The intake decision derived purely from a provider's registration truth. */
20
+ export interface ProviderKeyIntakeDecision {
21
+ /** True when the provider accepts a key and does not yet have one. */
22
+ readonly needsKey: boolean;
23
+ /** Secrets-store key to write the value under (the provider's auth env var). */
24
+ readonly secretKey?: string;
25
+ /** True when the provider already has a credential (no prompt needed). */
26
+ readonly alreadyConfigured: boolean;
27
+ }
28
+
29
+ /**
30
+ * Decide whether to prompt for a key, reading only the provider's own auth
31
+ * state. A provider that is already configured, works anonymously, or declares
32
+ * no accepted env var is left alone (proceed straight through). A provider that
33
+ * does not declare an auth state at all is treated per isConfigured(), and an
34
+ * unknown state is assumed configured so a working selection is never nagged.
35
+ */
36
+ export function decideProviderKeyIntake(provider: LLMProvider | undefined): ProviderKeyIntakeDecision {
37
+ if (!provider) return { needsKey: false, alreadyConfigured: false };
38
+ const state = provider.describeAuthState?.();
39
+ if (state) {
40
+ if (state.configured) return { needsKey: false, alreadyConfigured: true };
41
+ if (state.anonymousReady) return { needsKey: false, alreadyConfigured: false };
42
+ const secretKey = state.authEnvVars[0];
43
+ if (secretKey) return { needsKey: true, secretKey, alreadyConfigured: false };
44
+ return { needsKey: false, alreadyConfigured: false };
45
+ }
46
+ const configured = provider.isConfigured?.() ?? true;
47
+ return { needsKey: false, alreadyConfigured: configured };
48
+ }
49
+
50
+ /** Human-facing name for a provider — plain, vendor-neutral, never an env var. */
51
+ export function providerIntakeLabel(providerId: string): string {
52
+ return `the ${providerId} provider`;
53
+ }
54
+
55
+ /** Dependencies the intake orchestrator needs, decoupled from the command context for testing. */
56
+ export interface ProviderKeyIntakeDeps {
57
+ /** The provider instance whose registration-time auth state drives the decision. */
58
+ readonly provider: LLMProvider | undefined;
59
+ /** Canonical secret store — the same path /secrets set writes through. */
60
+ readonly secretsManager?: {
61
+ set(key: string, value: string, options?: { scope?: 'project' | 'user'; medium?: 'secure' | 'plaintext' }): Promise<void>;
62
+ } | undefined;
63
+ /** Re-resolves credentials and re-registers providers live (no restart). */
64
+ readonly refreshProviderCredentials?: (() => Promise<void>) | undefined;
65
+ /** Begins one line of concealed composer input for the key. */
66
+ readonly beginConcealedInput?: ((request: ConcealedInputRequest) => void) | undefined;
67
+ /** Surfaces short status lines to the transcript. */
68
+ readonly print: (text: string) => void;
69
+ }
70
+
71
+ /**
72
+ * If the selected provider needs a key, open the concealed prompt, store the
73
+ * pasted value through the secrets manager, re-register the provider live, then
74
+ * run `proceed` (the user's original selection). If no key is needed — or the
75
+ * concealed surface is unavailable — `proceed` runs immediately. `proceed`
76
+ * always runs exactly once, including when the user skips (empty/Escape), so a
77
+ * selection is never stranded.
78
+ */
79
+ export function runProviderKeyIntake(
80
+ providerId: string,
81
+ deps: ProviderKeyIntakeDeps,
82
+ proceed: () => void,
83
+ ): void {
84
+ const decision = decideProviderKeyIntake(deps.provider);
85
+ if (!decision.needsKey || !decision.secretKey) {
86
+ proceed();
87
+ return;
88
+ }
89
+ const { secretsManager, beginConcealedInput } = deps;
90
+ if (!secretsManager || !beginConcealedInput) {
91
+ // No canonical store or no concealed surface here — keep the selection
92
+ // rather than blocking it; the user can add the key via /secrets set later.
93
+ proceed();
94
+ return;
95
+ }
96
+ const secretKey = decision.secretKey;
97
+ const label = providerIntakeLabel(providerId);
98
+ let completed = false;
99
+ const finishOnce = (): void => {
100
+ if (completed) return;
101
+ completed = true;
102
+ proceed();
103
+ };
104
+ deps.print(`Enter the API key for ${label} (input is masked; press Enter to store, Esc to skip).`);
105
+ beginConcealedInput({
106
+ label: providerId,
107
+ onSubmit: (value) => {
108
+ if (value.length === 0) {
109
+ deps.print(`No key entered for ${label}; selection kept, no key stored.`);
110
+ finishOnce();
111
+ return;
112
+ }
113
+ void (async () => {
114
+ try {
115
+ await secretsManager.set(secretKey, value, { scope: 'user', medium: 'secure' });
116
+ await deps.refreshProviderCredentials?.();
117
+ deps.print(`Stored the API key for ${label} and registered it live (value hidden). No restart needed.`);
118
+ } catch (error) {
119
+ deps.print(`Could not store the key for ${label}: ${summarizeError(error)}`);
120
+ }
121
+ finishOnce();
122
+ })();
123
+ },
124
+ onCancel: () => {
125
+ deps.print(`Key entry skipped for ${label}; selection kept, no key stored.`);
126
+ finishOnce();
127
+ },
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Command-context adapter for {@link runProviderKeyIntake}: resolves the live
133
+ * provider, secrets manager, live re-registration hook, and concealed-input
134
+ * surface from the shell command context, then runs the intake before
135
+ * completing the user's original selection (`proceed`).
136
+ */
137
+ export function ensureProviderKeyThenSelect(
138
+ ctx: CommandContext,
139
+ providerId: string,
140
+ proceed: () => void,
141
+ ): void {
142
+ // Resolve the live provider defensively: a command context that routes only
143
+ // through the provider API (no raw registry access) leaves the intake a no-op
144
+ // and the selection completes unchanged.
145
+ let registry: CommandContext['provider']['providerRegistry'] | undefined;
146
+ let provider: ReturnType<CommandContext['provider']['providerRegistry']['tryGet']> | undefined;
147
+ try {
148
+ registry = ctx.provider?.providerRegistry;
149
+ provider = registry?.tryGet?.(providerId);
150
+ } catch {
151
+ registry = undefined;
152
+ provider = undefined;
153
+ }
154
+ runProviderKeyIntake(
155
+ providerId,
156
+ {
157
+ provider,
158
+ secretsManager: ctx.platform?.secretsManager,
159
+ refreshProviderCredentials: registry
160
+ ? () => registry!.refreshProviderCredentials()
161
+ : undefined,
162
+ beginConcealedInput: ctx.beginConcealedInput,
163
+ print: (text) => ctx.print(text),
164
+ },
165
+ proceed,
166
+ );
167
+ }
@@ -59,14 +59,14 @@ export const SANDBOX_EGRESS_ALLOWLIST_SYNTHETIC_SETTING: ConfigSetting = {
59
59
  key: SANDBOX_EGRESS_ALLOWLIST_CONFIG_KEY,
60
60
  type: 'string',
61
61
  default: [],
62
- description: 'Comma-separated command base names (or "*" for all) whose network access is re-enabled inside the per-command exec sandbox boundary, as a named escalation the approval flow surfaces ("wants network"). Empty = the sandbox disables network for every command. Only takes effect when sandbox.enabled is on, the graduation-tracked exec-sandbox feature flag is on, and bubblewrap is available (Linux only — reports unavailable elsewhere).',
62
+ description: 'Comma-separated command base names (or "*" for all) whose network access is re-enabled inside the per-command exec sandbox boundary, as a named escalation the approval flow surfaces ("wants network"). Empty = the sandbox disables network for every command. Only takes effect when sandbox.enabled is on, the exec-sandbox setting is on, and bubblewrap is available (Linux only — reports unavailable elsewhere).',
63
63
  };
64
64
 
65
65
  export const SANDBOX_WORKSPACE_WRITABLE_SYNTHETIC_SETTING: ConfigSetting = {
66
66
  key: SANDBOX_WORKSPACE_WRITABLE_CONFIG_KEY,
67
67
  type: 'string',
68
68
  default: [],
69
- description: 'Comma-separated absolute paths outside the workspace bound writable into the per-command exec sandbox boundary, as a named escalation the approval flow surfaces ("wants path outside workspace"). Empty = only the workspace (and an isolated /tmp) is writable. Only takes effect when sandbox.enabled is on, the graduation-tracked exec-sandbox feature flag is on, and bubblewrap is available (Linux only — reports unavailable elsewhere).',
69
+ description: 'Comma-separated absolute paths outside the workspace bound writable into the per-command exec sandbox boundary, as a named escalation the approval flow surfaces ("wants path outside workspace"). Empty = only the workspace (and an isolated /tmp) is writable. Only takes effect when sandbox.enabled is on, the exec-sandbox setting is on, and bubblewrap is available (Linux only — reports unavailable elsewhere).',
70
70
  };
71
71
 
72
72
  function buildSandboxExecListEntry(configManager: Pick<ConfigManager, 'get'>, setting: ConfigSetting): SettingEntry {
@@ -37,7 +37,7 @@ export class SelectionModal {
37
37
  public allowSearch = true;
38
38
  public customActions: Map<string, SelectionAction> = new Map();
39
39
  /**
40
- * UX-C vocab unification: overrides the default per-primaryAction Enter
40
+ * vocab unification: overrides the default per-primaryAction Enter
41
41
  * verb ("Select"/"Toggle"/"Edit"/"Delete") shown in the footer hint. Used by
42
42
  * callers whose items are really commands (e.g. /help) so the hint reads
43
43
  * "[Enter] Run", matching the slash-command palette's own verb, instead of
@@ -92,6 +92,11 @@ export function activateSelected(ctx: ActivateSelectedContext): void {
92
92
  const idx = setting.enumValues.indexOf(entry.currentValue as string);
93
93
  const nextIdx = (idx + 1) % setting.enumValues.length;
94
94
  ctx.setValue(setting.key as ConfigKey, setting.enumValues[nextIdx]);
95
+ } else if (setting.type === 'object') {
96
+ // Object-typed keys (e.g. pricing.modelPrices) edit as JSON: seed the
97
+ // buffer with the current value's JSON so the edit starts from truth.
98
+ ctx.setEditingMode(true);
99
+ ctx.setEditBuffer(JSON.stringify(entry.currentValue ?? {}));
95
100
  } else if (setting.type === 'string' || setting.type === 'number') {
96
101
  // Enter inline edit mode
97
102
  ctx.setEditingMode(true);
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * settings-modal-adjustment — pure adjustment helpers for SettingsModal.
3
3
  *
4
- * These functions encapsulate the two directional-adjustment operations:
4
+ * These functions encapsulate the directional-adjustment operation:
5
5
  * - adjustSelected: cycle enum/boolean/number values via left/right arrow keys
6
- * - toggleSelectedFlag: toggle a feature flag between enabled and disabled
7
6
  *
8
7
  * Each function takes its dependencies as explicit arguments rather than
9
8
  * accessing class-level state directly, following the same pattern as
@@ -13,11 +12,9 @@
13
12
  import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
14
13
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
15
14
  import type { FeatureFlagManager } from '@/runtime/index.ts';
16
- import type { FlagState } from '@/runtime/index.ts';
17
15
  import type { FlagEntry, McpEntry, SettingEntry } from './settings-modal-types.ts';
18
16
  import { buildMcpEntries } from './settings-modal-data.ts';
19
17
  import { getNumericAdjustmentMeta, roundToPrecision } from './settings-modal-behavior.ts';
20
- import { applyFlagState } from './settings-modal-mutations.ts';
21
18
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
22
19
 
23
20
  // ---------------------------------------------------------------------------
@@ -45,10 +42,9 @@ export function adjustSelected(
45
42
  ): void {
46
43
  if (ctx.editingMode) return;
47
44
 
48
- // Feature-unit toggle headers (setting.type === 'boolean', key featureFlags.<id>)
49
- // now live across topical categories, not a single flags tab. They fall through
50
- // to the boolean branch below, which routes through setValue → the modal's
51
- // flag-toggle chokepoint. No category-special-case is needed here anymore.
45
+ // Feature-unit headers are the real config rows for their enablement keys
46
+ // (boolean or enum), so they fall through to the ordinary branches below —
47
+ // a plain config write the settings bridge forwards to the gate manager.
52
48
 
53
49
  if (ctx.currentCategory === 'mcp') {
54
50
  const entry = ctx.getSelectedMcp();
@@ -96,26 +92,3 @@ export function adjustSelected(
96
92
  ctx.setValue(setting.key as ConfigKey, nextValue);
97
93
  }
98
94
  }
99
-
100
- // ---------------------------------------------------------------------------
101
- // toggleSelectedFlag
102
- // ---------------------------------------------------------------------------
103
-
104
- export interface ToggleSelectedFlagContext {
105
- readonly featureFlagManager: FeatureFlagManager | null;
106
- readonly configManager: ConfigManager | null;
107
- getSelectedFlag(): FlagEntry | null;
108
- }
109
-
110
- export function toggleSelectedFlag(ctx: ToggleSelectedFlagContext): void {
111
- const flagEntry = ctx.getSelectedFlag();
112
- if (!flagEntry || !ctx.featureFlagManager || !ctx.configManager) return;
113
-
114
- const { state } = flagEntry;
115
-
116
- // Killed flags are blocked
117
- if (state === 'killed') return;
118
-
119
- const newState: FlagState = state === 'enabled' ? 'disabled' : 'enabled';
120
- applyFlagState(flagEntry, newState, ctx.featureFlagManager, ctx.configManager);
121
- }
@@ -52,16 +52,15 @@ import {
52
52
  } from './settings-modal-types.ts';
53
53
  import { enrichRelaySettingDescriptions } from './relay-settings-descriptions.ts';
54
54
  import { applyFeatureUnitLayout } from './feature-unit-layout.ts';
55
+ import { FEATURE_SETTINGS_BY_ID } from '../runtime/feature-settings.ts';
55
56
 
56
57
  // ---------------------------------------------------------------------------
57
58
  // deepEqual — structural equality for isDefault comparisons
58
59
  // ---------------------------------------------------------------------------
59
60
 
60
61
  /**
61
- * Structural equality check for setting default comparisons.
62
- * Handles scalars, arrays, and plain objects. Does NOT support
63
- * circular references or non-plain prototypes — config defaults
64
- * are always JSON-safe primitives, arrays, or plain objects.
62
+ * Structural equality for setting-default comparisons: scalars, arrays, and
63
+ * plain objects (config defaults are always JSON-safe; no circular refs).
65
64
  */
66
65
  export function deepEqual(a: unknown, b: unknown): boolean {
67
66
  if (a === b) return true;
@@ -98,9 +97,8 @@ export function buildSettingGroups(
98
97
  featureFlagManager?: FeatureFlagManager | null,
99
98
  ): Map<SettingsCategory, SettingEntry[]> {
100
99
  const groups = new Map<SettingsCategory, SettingEntry[]>();
101
- // Every category starts empty (including 'flags', now the Advanced Features
102
- // bucket, and the new fetch/agents/security/integrations/policy/notifications
103
- // namespaces) and is filled from CONFIG_SCHEMA below.
100
+ // Every category starts empty and is filled from CONFIG_SCHEMA below; the
101
+ // feature-unit layout pass then folds each capability's rows into one unit.
104
102
  for (const cat of SETTINGS_CATEGORIES) groups.set(cat, []);
105
103
 
106
104
  for (const setting of CONFIG_SCHEMA) {
@@ -124,7 +122,7 @@ export function buildSettingGroups(
124
122
  }
125
123
  }
126
124
 
127
- // DEBT-2: inject the synthetic display.themeMode enum (auto|dark|light). TUI-local
125
+ // inject the synthetic display.themeMode enum (auto|dark|light). TUI-local
128
126
  // key stored under the existing `display` section (see theme-mode-config.ts for why
129
127
  // not `appearance`), same rationale as the other synthetic settings below.
130
128
  const displayEntries = groups.get('display');
@@ -181,7 +179,7 @@ export function buildSettingGroups(
181
179
  behaviorEntries.push(buildBudgetAlertUsdSyntheticEntry(configManager));
182
180
  }
183
181
 
184
- // Inject the W2.3 alert-class toggles + master focus gate. TUI-local
182
+ // Inject the alert-class toggles + master focus gate. TUI-local
185
183
  // synthetic settings, same rationale as notifyAfterSeconds above.
186
184
  if (behaviorEntries) {
187
185
  for (const entry of buildNotifyAlertSyntheticEntries(configManager)) {
@@ -240,11 +238,10 @@ export function buildSettingGroups(
240
238
  // the SDK's own descriptions don't carry (see relay-settings-descriptions.ts).
241
239
  enrichRelaySettingDescriptions(groups);
242
240
 
243
- // Feature-unit presentation: turn each flag into ONE unit (toggle + the config
244
- // keys it tunes) hosted in its topical category, folding no-config flags into
245
- // the Advanced Features bucket. Sourced from FEATURE_FLAG_CONFIG. Runs AFTER
246
- // synthetic injection so non-flag-owned rows stay as orphans; skipped on the
247
- // flagless test path.
241
+ // Feature-unit presentation: each capability becomes ONE unit (its real
242
+ // enablement row + the settings keys it tunes) in its settings domain,
243
+ // sourced from FEATURE_SETTINGS. Runs AFTER synthetic injection so
244
+ // non-feature-owned rows stay orphans; skipped on the featureless path.
248
245
  if (featureFlagManager) applyFeatureUnitLayout(groups, buildFlagEntries(featureFlagManager));
249
246
 
250
247
  // learning.consolidation.* is a real SDK config domain now, so its keys arrive
@@ -301,7 +298,7 @@ export function buildTtsSpeedSyntheticEntry(configManager: Pick<ConfigManager, '
301
298
  }
302
299
 
303
300
  // ---------------------------------------------------------------------------
304
- // display.themeMode synthetic setting (DEBT-2 light theme)
301
+ // display.themeMode synthetic setting (light theme)
305
302
  // ---------------------------------------------------------------------------
306
303
 
307
304
  /**
@@ -415,11 +412,11 @@ export function buildBudgetAlertUsdSyntheticEntry(configManager: Pick<ConfigMana
415
412
  }
416
413
 
417
414
  // ---------------------------------------------------------------------------
418
- // W2.3 alert-class synthetic settings — behavior.notifyOn* + notifyOnlyWhenUnfocused
415
+ // alert-class synthetic settings — behavior.notifyOn* + notifyOnlyWhenUnfocused
419
416
  // ---------------------------------------------------------------------------
420
417
 
421
418
  /**
422
- * The five W2.3 alert-gating booleans, all TUI-local (not yet in the SDK
419
+ * The five alert-gating booleans, all TUI-local (not yet in the SDK
423
420
  * ConfigKey union), all defaulting to on. Read/written generically by
424
421
  * core/alert-gating.ts (readBooleanConfig) and the per-alert-class modules
425
422
  * (budget-breach-notifier.ts, approval-alert.ts, turn-event-wiring.ts,
@@ -475,7 +472,7 @@ export function buildNotifyAlertSyntheticEntries(configManager: Pick<ConfigManag
475
472
  /**
476
473
  * The OSC 9 in-terminal desktop-notification toggles + the audible bell.
477
474
  * TUI-local synthetic booleans (not in the SDK ConfigKey union), each carrying
478
- * its OWN default (unlike the always-on W2.3 alert toggles above): approval-wait
475
+ * its OWN default (unlike the always-on alert toggles above): approval-wait
479
476
  * on, turn-end/agent-blocked off, bell off. Read generically by
480
477
  * core/terminal-notifier.ts (readBooleanConfig); this is only the
481
478
  * settings-modal-visible surface. These are independent of the master
@@ -537,9 +534,13 @@ export function buildCodeIndexEnabledSyntheticEntry(configManager: Pick<ConfigMa
537
534
 
538
535
  export function buildFlagEntries(featureFlagManager: FeatureFlagManager | null): FlagEntry[] {
539
536
  if (!featureFlagManager) return [];
540
- return Array.from(featureFlagManager.getAll().values()).map(
541
- ({ flag, state, persistedState, pendingRestart }) => ({ flag, state, persistedState, pendingRestart }),
542
- );
537
+ const entries: FlagEntry[] = [];
538
+ for (const { flag, state, persistedState, pendingRestart } of featureFlagManager.getAll().values()) {
539
+ const feature = FEATURE_SETTINGS_BY_ID.get(flag.id);
540
+ if (!feature) continue; // gate with no settings surface (registry/binding drift is SDK test-guarded)
541
+ entries.push({ feature, flag, state, persistedState, pendingRestart });
542
+ }
543
+ return entries;
543
544
  }
544
545
 
545
546
  // ---------------------------------------------------------------------------
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * settings-modal-mutations — pure mutation helpers for SettingsModal.
3
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.
4
+ * These functions encapsulate the side-effectful write operations. Every
5
+ * write — feature-unit headers included — is a plain domain-settings config
6
+ * write; the SDK's settings bridge keeps the runtime gate manager in sync
7
+ * (flipping runtime-toggleable gates live and recording honest
8
+ * pending-restart markers for startup-gated ones). Each function takes its
9
+ * dependencies as explicit arguments rather than accessing class-level state.
8
10
  */
9
11
 
10
- import type { ConfigKey, PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config';
12
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
11
13
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
12
14
  import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
15
  import type { FeatureFlagManager } from '@/runtime/index.ts';
14
- import type { FeatureFlag, FlagState } from '@/runtime/index.ts';
15
16
  import type { FlagEntry, SettingEntry } from './settings-modal-types.ts';
16
17
  import { deepEqual } from './settings-modal-data.ts';
17
18
 
@@ -83,10 +84,12 @@ export function applySettingValue({
83
84
  };
84
85
  }
85
86
 
86
- // Update the entry in the groups map
87
+ // Update every entry carrying this key: cross-listed rows (the network
88
+ // combined view) and feature-unit headers that share one enablement key
89
+ // (e.g. both compaction features read behavior.compactionStrategy).
87
90
  for (const entries of groups.values()) {
88
- const entry = entries.find((candidate) => candidate.setting.key === key);
89
- if (entry) {
91
+ for (const entry of entries) {
92
+ if (entry.setting.key !== key) continue;
90
93
  entry.currentValue = configManager.get(key);
91
94
  entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
92
95
  }
@@ -112,65 +115,6 @@ export function applySettingValue({
112
115
  return { restartDomain, effectMessage, changed: previousValue !== value };
113
116
  }
114
117
 
115
- // ---------------------------------------------------------------------------
116
- // persistFlagState — write a flag override to config
117
- // ---------------------------------------------------------------------------
118
-
119
- export function persistFlagState(
120
- configManager: ConfigManager,
121
- flagId: string,
122
- newState: FlagState,
123
- defaultState: FlagState,
124
- ): void {
125
- if (newState === 'killed') return; // never persist killed state
126
-
127
- try {
128
- if (newState === defaultState) {
129
- // Back at the default: the override must be REMOVED, not merged.
130
- // getCategory clones and mergeCategory only sets keys, so the old
131
- // delete-then-merge approach left the stale override on disk and the
132
- // flag silently reloaded in the overridden state on the next start.
133
- configManager.removeCategoryKey('featureFlags', flagId);
134
- } else {
135
- configManager.mergeCategory('featureFlags', { [flagId]: newState } as Record<string, PersistedFlagState>);
136
- }
137
- } catch (e) {
138
- logger.error('SettingsModal: failed to persist flag state', { flagId, error: summarizeError(e) });
139
- }
140
- }
141
-
142
- // ---------------------------------------------------------------------------
143
- // applyFlagState — toggle a feature flag (runtime + persist)
144
- // ---------------------------------------------------------------------------
145
-
146
- export function applyFlagState(
147
- flagEntry: FlagEntry,
148
- newState: FlagState,
149
- featureFlagManager: FeatureFlagManager,
150
- configManager: ConfigManager,
151
- ): void {
152
- const flag: FeatureFlag = flagEntry.flag;
153
-
154
- try {
155
- // Persist first so the on-disk override is the source of truth, then apply
156
- // the same value to the live manager through applyConfigState. That single
157
- // call keeps both paths consistent with the SDK's config→flag bridge:
158
- // - runtime-toggleable flags apply immediately (firing flag subscribers
159
- // exactly as a direct enable()/disable() would), and
160
- // - startup-gated flags are NOT faked live — the effective state is left
161
- // untouched and a pending-restart marker is recorded instead.
162
- persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
163
- featureFlagManager.applyConfigState(flag.id, newState);
164
- } catch (e) {
165
- logger.error('SettingsModal: failed to toggle feature flag', { flag: flag.id, error: summarizeError(e) });
166
- }
167
-
168
- // Reflect the manager's real state, never the requested target: for a
169
- // startup-gated flag `state` stays at the unchanged effective value and
170
- // `pendingRestart` reports that a restart is needed for `persistedState`.
171
- syncFlagEntryFromManager(flagEntry, featureFlagManager);
172
- }
173
-
174
118
  /**
175
119
  * Refresh a FlagEntry's live/persisted/pending fields from the manager's
176
120
  * authoritative snapshot so the settings UI never displays a guessed state.