@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.
- package/CHANGELOG.md +27 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +3 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +662 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +14 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +12 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +31 -1
- package/src/input/commands/control-room-runtime.ts +5 -5
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +3 -3
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -2
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +91 -12
- package/src/input/handler-modal-token-routes.ts +3 -0
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +55 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +5 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-data.ts +304 -0
- package/src/input/settings-modal-mutations.ts +154 -0
- package/src/input/settings-modal.ts +182 -220
- package/src/main.ts +57 -57
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +4 -1
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/knowledge-panel.ts +3 -5
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +11 -10
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +77 -8
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +14 -0
- package/src/runtime/bootstrap-core.ts +121 -13
- package/src/runtime/bootstrap.ts +2 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/services.ts +21 -0
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { isIP } from 'node:net';
|
|
2
1
|
import type { ModelPickerTarget } from '../model-picker.ts';
|
|
3
2
|
import {
|
|
4
|
-
deriveOnboardingStepState,
|
|
5
3
|
type OnboardingAcknowledgementReason,
|
|
6
4
|
type OnboardingAcknowledgementTarget,
|
|
7
|
-
type OnboardingApplyOperation,
|
|
8
5
|
type OnboardingApplyRequest,
|
|
9
6
|
type OnboardingMode,
|
|
10
7
|
type OnboardingSnapshotState,
|
|
@@ -193,3 +190,148 @@ export interface OnboardingWizardRuntimeHydration {
|
|
|
193
190
|
}
|
|
194
191
|
|
|
195
192
|
export type MutableModelSelectionMap = Map<ModelPickerTarget, OnboardingWizardModelSelection>;
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// External surface types (moved here to break the surfaces ↔ extra-specs cycle)
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
export interface ExternalSurfaceSetupFieldSpec {
|
|
199
|
+
readonly id: string;
|
|
200
|
+
readonly configKey: ConfigKey;
|
|
201
|
+
readonly kind: 'text' | 'masked' | 'radio';
|
|
202
|
+
readonly valueType?: 'string' | 'number';
|
|
203
|
+
readonly label: string;
|
|
204
|
+
readonly hint: string;
|
|
205
|
+
readonly placeholder: string;
|
|
206
|
+
readonly options?: readonly OnboardingWizardRadioOption[];
|
|
207
|
+
readonly defaultNumber?: number;
|
|
208
|
+
readonly min?: number;
|
|
209
|
+
readonly max?: number;
|
|
210
|
+
readonly defaultValue: (snapshot: OnboardingSnapshotState | null) => string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ExternalSurfaceSpec {
|
|
214
|
+
readonly id: string;
|
|
215
|
+
readonly enabledFieldId: string;
|
|
216
|
+
readonly enabledConfigKey: ConfigKey;
|
|
217
|
+
readonly label: string;
|
|
218
|
+
readonly hint: string;
|
|
219
|
+
/**
|
|
220
|
+
* Existing SDK config key. In onboarding this maps to the per-surface
|
|
221
|
+
* auto-start choice, not to whether setup fields are shown.
|
|
222
|
+
*/
|
|
223
|
+
readonly defaultEnabled: (snapshot: OnboardingSnapshotState | null) => boolean;
|
|
224
|
+
readonly fields: readonly ExternalSurfaceSetupFieldSpec[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Controller interface (moved here to break the wizard ↔ satellites cycle)
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Public interface of OnboardingWizardController. Satellite modules
|
|
233
|
+
* (apply, steps, rules, state, cloudflare, cloudflare-step) receive the
|
|
234
|
+
* controller through this interface so they do not need to import from
|
|
235
|
+
* onboarding-wizard.ts and the circular dependency is broken.
|
|
236
|
+
*/
|
|
237
|
+
export interface OnboardingWizardControllerLike {
|
|
238
|
+
// State fields
|
|
239
|
+
active: boolean;
|
|
240
|
+
mode: OnboardingWizardMode;
|
|
241
|
+
stepIndex: number;
|
|
242
|
+
hydrationPending: boolean;
|
|
243
|
+
hydrationError: string | null;
|
|
244
|
+
pendingModelPickerTarget: ModelPickerTarget | null;
|
|
245
|
+
pendingAction: OnboardingWizardAction | null;
|
|
246
|
+
editingFieldId: string | null;
|
|
247
|
+
editBuffer: string;
|
|
248
|
+
applyFeedback: OnboardingWizardApplyFeedback | null;
|
|
249
|
+
|
|
250
|
+
readonly scrollOffsets: number[];
|
|
251
|
+
readonly selectedFieldIndices: number[];
|
|
252
|
+
readonly dirtyStepIds: Set<OnboardingWizardStepId>;
|
|
253
|
+
readonly toggleState: Map<string, boolean>;
|
|
254
|
+
readonly touchedActionFields: Set<string>;
|
|
255
|
+
readonly radioState: Map<string, string>;
|
|
256
|
+
readonly textState: Map<string, string>;
|
|
257
|
+
readonly modelSelectionState: MutableModelSelectionMap;
|
|
258
|
+
|
|
259
|
+
readonly baselineToggleState: Map<string, boolean>;
|
|
260
|
+
readonly baselineRadioState: Map<string, string>;
|
|
261
|
+
readonly baselineTextState: Map<string, string>;
|
|
262
|
+
readonly baselineModelSelectionState: MutableModelSelectionMap;
|
|
263
|
+
|
|
264
|
+
runtimeSnapshot: OnboardingSnapshotState | null;
|
|
265
|
+
runtimeDerived: OnboardingStepDerivationState;
|
|
266
|
+
|
|
267
|
+
// Computed
|
|
268
|
+
readonly steps: readonly OnboardingWizardStepDefinition[];
|
|
269
|
+
readonly currentStep: OnboardingWizardStepDefinition;
|
|
270
|
+
readonly dirty: boolean;
|
|
271
|
+
readonly dirtyStepCount: number;
|
|
272
|
+
|
|
273
|
+
// Field value accessors
|
|
274
|
+
getFieldValue(field: OnboardingWizardFieldDefinition): boolean | string | OnboardingWizardModelSelection;
|
|
275
|
+
getFieldById(fieldId: string): OnboardingWizardFieldDefinition | null;
|
|
276
|
+
getStringFieldValue(fieldId: string, fallback: string): string;
|
|
277
|
+
getBooleanFieldValue(fieldId: string, fallback: boolean): boolean;
|
|
278
|
+
parseIntegerFieldValue(fieldId: string, fallback: number): number | null;
|
|
279
|
+
getPortFieldValue(fieldId: string, fallback: number): number;
|
|
280
|
+
getNumberFieldValue(fieldId: string, fallback: number, min?: number, max?: number): number;
|
|
281
|
+
|
|
282
|
+
// Capability / rules accessors
|
|
283
|
+
isCapabilitySelected(capabilityId: OnboardingStep1CapabilityId): boolean;
|
|
284
|
+
hasServerCapabilitiesSelected(): boolean;
|
|
285
|
+
shouldEnableBrowserSurface(): boolean;
|
|
286
|
+
hasSelectedInboundExternalSurface(): boolean;
|
|
287
|
+
isRequiredExternalSetupField(fieldId: string): boolean;
|
|
288
|
+
getSelectedSecretMedium(): 'secure' | 'plaintext';
|
|
289
|
+
shouldEnableHttpListener(): boolean;
|
|
290
|
+
shouldExposeHttpListenerNetworkFields(): boolean;
|
|
291
|
+
shouldExposeControlPlaneNetwork(): boolean;
|
|
292
|
+
requiresAuthBootstrap(): boolean;
|
|
293
|
+
hasAdminAuthUser(): boolean;
|
|
294
|
+
hasLocalAuthUser(): boolean;
|
|
295
|
+
hasBootstrapCredentialPresent(): boolean;
|
|
296
|
+
getDefaultAdminUsername(): string;
|
|
297
|
+
getSharedIpDefault(enabled: { readonly controlPlane: boolean; readonly httpListener: boolean; readonly web: boolean }): boolean;
|
|
298
|
+
getSharedIpHostDefault(enabled: { readonly controlPlane: boolean; readonly httpListener: boolean; readonly web: boolean }): string;
|
|
299
|
+
|
|
300
|
+
// Capability mutations
|
|
301
|
+
toggleCapability(capabilityId: OnboardingStep1CapabilityId): void;
|
|
302
|
+
selectAllServerCapabilities(): void;
|
|
303
|
+
selectLocalTuiOnly(): void;
|
|
304
|
+
selectAllExternalSurfaces(): void;
|
|
305
|
+
clearExternalSurfaces(): void;
|
|
306
|
+
setCapabilityValue(capabilityId: OnboardingStep1CapabilityId, selected: boolean): void;
|
|
307
|
+
getCurrentCapabilities(): readonly OnboardingStep1CapabilityItem[];
|
|
308
|
+
getCapabilitySelectionState(): readonly OnboardingStep1CapabilityItem[];
|
|
309
|
+
|
|
310
|
+
// State operations
|
|
311
|
+
recalculateDirtyState(): void;
|
|
312
|
+
reconcileStateWithCurrentDefinitions(): void;
|
|
313
|
+
reconcileStepCursor(stepIndex: number): void;
|
|
314
|
+
resetValuesFromCurrentDefinitions(): void;
|
|
315
|
+
ensureSelectionVisible(visibleFields: number): void;
|
|
316
|
+
isFieldDirty(fieldId: string): boolean;
|
|
317
|
+
isFieldDirtyByDefinition(field: OnboardingWizardFieldDefinition): boolean;
|
|
318
|
+
isFieldSatisfied(field: OnboardingWizardFieldDefinition): boolean;
|
|
319
|
+
isStepDirty(stepIndex: number): boolean;
|
|
320
|
+
hasExistingAccessState(): boolean;
|
|
321
|
+
getBlockingFieldLabels(): readonly string[];
|
|
322
|
+
getFieldValidationError(step: OnboardingWizardStepDefinition, field: OnboardingWizardFieldDefinition): string | null;
|
|
323
|
+
getToggleFieldCount(stepIndex: number): number;
|
|
324
|
+
getCompletedToggleCount(stepIndex: number): number;
|
|
325
|
+
getStepFieldCount(stepIndex: number): number;
|
|
326
|
+
getCompletedFieldCount(stepIndex: number): number;
|
|
327
|
+
|
|
328
|
+
// Additional operations used by steps/apply
|
|
329
|
+
buildApplyRequest(): OnboardingApplyRequest;
|
|
330
|
+
isEditingTextField(): boolean;
|
|
331
|
+
getTextFieldValue(fieldId: string, fallback?: string): string;
|
|
332
|
+
getFieldValueLabel(field: OnboardingWizardFieldDefinition): string;
|
|
333
|
+
getSelectedFieldIndex(): number;
|
|
334
|
+
getSelectedField(): OnboardingWizardFieldDefinition | null;
|
|
335
|
+
getFieldWindow(visibleFields: number): OnboardingWizardFieldWindow;
|
|
336
|
+
}
|
|
337
|
+
|
|
@@ -34,12 +34,12 @@ import {
|
|
|
34
34
|
toggleCapability as toggleCapabilityForController,
|
|
35
35
|
} from './onboarding-wizard-rules.ts';
|
|
36
36
|
import { ensureSelectionVisible as ensureSelectionVisibleForController, getBlockingFieldLabels as getBlockingFieldLabelsForController, getCapabilitySelectionState as getCapabilitySelectionStateForController, getCompletedFieldCount as getCompletedFieldCountForController, getCompletedToggleCount as getCompletedToggleCountForController, getCurrentCapabilities as getCurrentCapabilitiesForController, getFieldById as getFieldByIdForController, getFieldValidationError as getFieldValidationErrorForController, getStepFieldCount as getStepFieldCountForController, getToggleFieldCount as getToggleFieldCountForController, hasExistingAccessState as hasExistingAccessStateForController, isFieldDirty as isFieldDirtyForController, isFieldDirtyByDefinition as isFieldDirtyByDefinitionForController, isFieldSatisfied as isFieldSatisfiedForController, isStepDirty as isStepDirtyForController, recalculateDirtyState as recalculateDirtyStateForController, reconcileStateWithCurrentDefinitions as reconcileStateWithCurrentDefinitionsForController, reconcileStepCursor as reconcileStepCursorForController, resetValuesFromCurrentDefinitions as resetValuesFromCurrentDefinitionsForController } from './onboarding-wizard-state.ts';
|
|
37
|
-
import type { MutableModelSelectionMap, OnboardingWizardAction, OnboardingWizardApplyFeedback, OnboardingWizardFieldDefinition, OnboardingWizardFieldWindow, OnboardingWizardMode, OnboardingWizardModelSelection, OnboardingWizardRuntimeHydration, OnboardingWizardSnapshot, OnboardingWizardStepDefinition, OnboardingWizardStepId } from './onboarding-wizard-types.ts';
|
|
37
|
+
import type { OnboardingWizardControllerLike, MutableModelSelectionMap, OnboardingWizardAction, OnboardingWizardApplyFeedback, OnboardingWizardFieldDefinition, OnboardingWizardFieldWindow, OnboardingWizardMode, OnboardingWizardModelSelection, OnboardingWizardRuntimeHydration, OnboardingWizardSnapshot, OnboardingWizardStepDefinition, OnboardingWizardStepId } from './onboarding-wizard-types.ts';
|
|
38
38
|
|
|
39
|
-
export type { OnboardingWizardAcknowledgementFieldDefinition, OnboardingWizardAction, OnboardingWizardActionFieldDefinition, OnboardingWizardApplyFeedback, OnboardingWizardApplyFeedbackSeverity, OnboardingWizardChecklistFieldDefinition, OnboardingWizardExternalSurfaceStepId, OnboardingWizardFieldDefinition, OnboardingWizardFieldKind, OnboardingWizardFieldWindow, OnboardingWizardMaskedFieldDefinition, OnboardingWizardMode, OnboardingWizardModelPickerFieldDefinition, OnboardingWizardModelSelection, OnboardingWizardRadioFieldDefinition, OnboardingWizardRadioOption, OnboardingWizardRuntimeHydration, OnboardingWizardSnapshot, OnboardingWizardStatusFieldDefinition, OnboardingWizardStepDefinition, OnboardingWizardStepId, OnboardingWizardTextFieldDefinition } from './onboarding-wizard-types.ts';
|
|
39
|
+
export type { OnboardingWizardControllerLike, OnboardingWizardAcknowledgementFieldDefinition, OnboardingWizardAction, OnboardingWizardActionFieldDefinition, OnboardingWizardApplyFeedback, OnboardingWizardApplyFeedbackSeverity, OnboardingWizardChecklistFieldDefinition, OnboardingWizardExternalSurfaceStepId, OnboardingWizardFieldDefinition, OnboardingWizardFieldKind, OnboardingWizardFieldWindow, OnboardingWizardMaskedFieldDefinition, OnboardingWizardMode, OnboardingWizardModelPickerFieldDefinition, OnboardingWizardModelSelection, OnboardingWizardRadioFieldDefinition, OnboardingWizardRadioOption, OnboardingWizardRuntimeHydration, OnboardingWizardSnapshot, OnboardingWizardStatusFieldDefinition, OnboardingWizardStepDefinition, OnboardingWizardStepId, OnboardingWizardTextFieldDefinition } from './onboarding-wizard-types.ts';
|
|
40
40
|
export { getOnboardingWizardBodyRows, getOnboardingWizardVisibleFieldCount } from './onboarding-wizard-helpers.ts';
|
|
41
41
|
|
|
42
|
-
export class OnboardingWizardController {
|
|
42
|
+
export class OnboardingWizardController implements OnboardingWizardControllerLike {
|
|
43
43
|
public active = false;
|
|
44
44
|
public mode: OnboardingWizardMode = 'new';
|
|
45
45
|
public stepIndex = 0;
|
|
@@ -0,0 +1,304 @@
|
|
|
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 } 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
|
+
return groups;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// buildFlagEntries — snapshot of current feature flag states
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
export function buildFlagEntries(featureFlagManager: FeatureFlagManager | null): FlagEntry[] {
|
|
125
|
+
if (!featureFlagManager) return [];
|
|
126
|
+
return Array.from(featureFlagManager.getAll().values()).map(({ flag, state }) => ({
|
|
127
|
+
flag,
|
|
128
|
+
state,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// buildMcpEntries — snapshot of current MCP server security entries
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
export function buildMcpEntries(mcpRegistry: McpRegistry | null): McpEntry[] {
|
|
137
|
+
if (!mcpRegistry) return [];
|
|
138
|
+
return mcpRegistry.listServerSecurity().map((entry) => ({
|
|
139
|
+
name: entry.name,
|
|
140
|
+
connected: entry.connected,
|
|
141
|
+
role: entry.role,
|
|
142
|
+
trustMode: entry.trustMode,
|
|
143
|
+
allowedPaths: [...entry.allowedPaths],
|
|
144
|
+
allowedHosts: [...entry.allowedHosts],
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// buildSubscriptionEntries — re-export for use by SettingsModal
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export { buildSubscriptionEntries };
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// buildNetworkFilteredItems — applies host-mode visibility rules for 'network' tab
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
export function buildNetworkFilteredItems(
|
|
159
|
+
items: SettingEntry[],
|
|
160
|
+
configManager: ConfigManager | null,
|
|
161
|
+
): SettingEntry[] {
|
|
162
|
+
return items.filter(entry => {
|
|
163
|
+
if (entry.setting.key === 'controlPlane.host') {
|
|
164
|
+
return configManager?.get('controlPlane.hostMode') === 'custom';
|
|
165
|
+
}
|
|
166
|
+
if (entry.setting.key === 'httpListener.host') {
|
|
167
|
+
return configManager?.get('httpListener.hostMode') === 'custom';
|
|
168
|
+
}
|
|
169
|
+
if (entry.setting.key === 'web.host') {
|
|
170
|
+
return configManager?.get('web.hostMode') === 'custom';
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// refreshEntryValues — re-reads currentValue/isDefault for all loaded entries
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
export function refreshEntryValues(
|
|
181
|
+
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
182
|
+
configManager: ConfigManager,
|
|
183
|
+
): void {
|
|
184
|
+
for (const entries of groups.values()) {
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
entry.currentValue = configManager.get(entry.setting.key as ConfigKey);
|
|
187
|
+
entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// updateEntryForKey — updates a single setting entry after a value change
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
export function updateEntryForKey(
|
|
197
|
+
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
198
|
+
key: ConfigKey,
|
|
199
|
+
configManager: ConfigManager,
|
|
200
|
+
): void {
|
|
201
|
+
for (const entries of groups.values()) {
|
|
202
|
+
const entry = entries.find((candidate) => candidate.setting.key === key);
|
|
203
|
+
if (entry) {
|
|
204
|
+
entry.currentValue = configManager.get(key);
|
|
205
|
+
entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// fuzzyScoreSettingEntry — score an entry against a query for ranked search
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Score a single SettingEntry against a search query.
|
|
216
|
+
*
|
|
217
|
+
* Score tiers (higher = better match):
|
|
218
|
+
* - 3000–3999: exact key substring match (3000 + position bonus 0–999)
|
|
219
|
+
* - 2000–2999: exact label substring match (2000 + position bonus 0–999)
|
|
220
|
+
* - 1000–1999: exact description substring match (1000 + position bonus 0–999)
|
|
221
|
+
* - 1–99: subsequence match across key+label+description
|
|
222
|
+
*
|
|
223
|
+
* Returns null when the query does not match at all.
|
|
224
|
+
*
|
|
225
|
+
* @param query - The search string (already lowercased).
|
|
226
|
+
* @param entry - The setting entry to test.
|
|
227
|
+
* @param getLabel - Pure function mapping an entry to its display label.
|
|
228
|
+
*/
|
|
229
|
+
export function fuzzyScoreSettingEntry(
|
|
230
|
+
query: string,
|
|
231
|
+
entry: SettingEntry,
|
|
232
|
+
getLabel: (e: SettingEntry) => string,
|
|
233
|
+
): number | null {
|
|
234
|
+
if (query.length === 0) return 0;
|
|
235
|
+
const lq = query.toLowerCase();
|
|
236
|
+
const key = entry.setting.key.toLowerCase();
|
|
237
|
+
const label = getLabel(entry).toLowerCase();
|
|
238
|
+
const description = (entry.setting.description ?? '').toLowerCase();
|
|
239
|
+
|
|
240
|
+
// Tier 1: key substring — base 3000, position bonus up to 999
|
|
241
|
+
// A key match at position 0 scores 3999; at position 999 scores 3000.
|
|
242
|
+
const keyIdx = key.indexOf(lq);
|
|
243
|
+
if (keyIdx !== -1) return 3000 + Math.max(0, 999 - keyIdx);
|
|
244
|
+
|
|
245
|
+
// Tier 2: label substring — base 2000, position bonus up to 999
|
|
246
|
+
const labelIdx = label.indexOf(lq);
|
|
247
|
+
if (labelIdx !== -1) return 2000 + Math.max(0, 999 - labelIdx);
|
|
248
|
+
|
|
249
|
+
// Tier 3: description substring — base 1000, position bonus up to 999
|
|
250
|
+
const descIdx = description.indexOf(lq);
|
|
251
|
+
if (descIdx !== -1) return 1000 + Math.max(0, 999 - descIdx);
|
|
252
|
+
|
|
253
|
+
// Tier 4: subsequence across concatenated key + label + description — 1..99
|
|
254
|
+
const haystack = `${key} ${label} ${description}`;
|
|
255
|
+
let qi = 0;
|
|
256
|
+
let score = 0;
|
|
257
|
+
for (let ci = 0; ci < haystack.length && qi < lq.length; ci++) {
|
|
258
|
+
if (haystack[ci] === lq[qi]) {
|
|
259
|
+
qi++;
|
|
260
|
+
score++;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (qi === lq.length) return Math.min(99, score);
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Search all setting entries across all groups, returning results ranked by
|
|
269
|
+
* relevance score (highest first). Excludes the flags, mcp, and subscriptions
|
|
270
|
+
* special categories (which have their own entry types).
|
|
271
|
+
*
|
|
272
|
+
* @param query - User input string. Empty string returns [].
|
|
273
|
+
* @param groups - The settings group map from buildSettingGroups.
|
|
274
|
+
* @param getLabel - Pure function mapping an entry to its display label.
|
|
275
|
+
*/
|
|
276
|
+
export function searchSettingEntries(
|
|
277
|
+
query: string,
|
|
278
|
+
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
279
|
+
getLabel: (e: SettingEntry) => string,
|
|
280
|
+
): SettingEntry[] {
|
|
281
|
+
if (query.trim().length === 0) return [];
|
|
282
|
+
const lq = query.trim().toLowerCase();
|
|
283
|
+
const seen = new Set<string>();
|
|
284
|
+
const scored: Array<{ entry: SettingEntry; score: number }> = [];
|
|
285
|
+
for (const entries of groups.values()) {
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
// Deduplicate: network tab cross-lists keys already in controlPlane/httpListener/web
|
|
288
|
+
if (seen.has(entry.setting.key)) continue;
|
|
289
|
+
seen.add(entry.setting.key);
|
|
290
|
+
const score = fuzzyScoreSettingEntry(lq, entry, getLabel);
|
|
291
|
+
if (score !== null) scored.push({ entry, score });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
scored.sort((a, b) => b.score - a.score);
|
|
295
|
+
return scored.map(r => r.entry);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Re-export SubscriptionEntry for convenience
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
export type { SubscriptionEntry } from './settings-modal-types.ts';
|
|
303
|
+
export type { SubscriptionManager };
|
|
304
|
+
export type { ServiceInspectionQuery };
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
configManager.setDynamic(key, value);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
logger.error('SettingsModal: failed to set config value', { key, error: summarizeError(e) });
|
|
64
|
+
return {
|
|
65
|
+
restartDomain: null,
|
|
66
|
+
effectMessage: `Save failed: ${summarizeError(e)}`,
|
|
67
|
+
changed: false,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Update the entry in the groups map
|
|
72
|
+
for (const entries of groups.values()) {
|
|
73
|
+
const entry = entries.find((candidate) => candidate.setting.key === key);
|
|
74
|
+
if (entry) {
|
|
75
|
+
entry.currentValue = configManager.get(key);
|
|
76
|
+
entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Determine restart domain
|
|
81
|
+
let restartDomain: 'control-plane' | 'http-listener' | 'web' | null = null;
|
|
82
|
+
if (previousValue !== value && isRestartKey) {
|
|
83
|
+
const rawCat = key.split('.')[0] as string;
|
|
84
|
+
if (rawCat === 'controlPlane') restartDomain = 'control-plane';
|
|
85
|
+
else if (rawCat === 'httpListener') restartDomain = 'http-listener';
|
|
86
|
+
else if (rawCat === 'web') restartDomain = 'web';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fire change callback
|
|
90
|
+
let effectMessage: string | null = null;
|
|
91
|
+
if (previousValue !== value && onSettingApplied) {
|
|
92
|
+
const result = onSettingApplied({ key, previousValue, value });
|
|
93
|
+
effectMessage = result?.message ?? null;
|
|
94
|
+
refreshGroups();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { restartDomain, effectMessage, changed: previousValue !== value };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// persistFlagState — write a flag override to config
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export function persistFlagState(
|
|
105
|
+
configManager: ConfigManager,
|
|
106
|
+
flagId: string,
|
|
107
|
+
newState: FlagState,
|
|
108
|
+
defaultState: FlagState,
|
|
109
|
+
): void {
|
|
110
|
+
if (newState === 'killed') return; // never persist killed state
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const current = (configManager.getCategory('featureFlags') as Record<string, PersistedFlagState>) ?? {};
|
|
114
|
+
if (newState === defaultState) {
|
|
115
|
+
delete current[flagId];
|
|
116
|
+
} else {
|
|
117
|
+
current[flagId] = newState;
|
|
118
|
+
}
|
|
119
|
+
configManager.mergeCategory('featureFlags', current);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
logger.error('SettingsModal: failed to persist flag state', { flagId, error: summarizeError(e) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// applyFlagState — toggle a feature flag (runtime + persist)
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export function applyFlagState(
|
|
130
|
+
flagEntry: FlagEntry,
|
|
131
|
+
newState: FlagState,
|
|
132
|
+
featureFlagManager: FeatureFlagManager,
|
|
133
|
+
configManager: ConfigManager,
|
|
134
|
+
): void {
|
|
135
|
+
const flag: FeatureFlag = flagEntry.flag;
|
|
136
|
+
|
|
137
|
+
if (!flag.runtimeToggleable) {
|
|
138
|
+
persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
|
|
139
|
+
flagEntry.state = newState;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
if (newState === 'enabled') {
|
|
145
|
+
featureFlagManager.enable(flag.id);
|
|
146
|
+
} else {
|
|
147
|
+
featureFlagManager.disable(flag.id);
|
|
148
|
+
}
|
|
149
|
+
persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
|
|
150
|
+
flagEntry.state = newState;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
logger.error('SettingsModal: failed to toggle feature flag', { flag: flag.id, error: summarizeError(e) });
|
|
153
|
+
}
|
|
154
|
+
}
|