@pellux/goodvibes-tui 0.19.33 → 0.19.35

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 (41) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +6 -3
  3. package/docs/foundation-artifacts/operator-contract.json +284 -112
  4. package/package.json +2 -2
  5. package/src/cli/management.ts +2 -2
  6. package/src/input/command-registry.ts +1 -0
  7. package/src/input/commands/cloudflare-runtime.ts +370 -0
  8. package/src/input/commands/local-auth-runtime.ts +4 -4
  9. package/src/input/commands/tts-runtime.ts +93 -10
  10. package/src/input/commands.ts +2 -0
  11. package/src/input/feed-context-factory.ts +1 -0
  12. package/src/input/handler-feed.ts +6 -0
  13. package/src/input/handler-modal-routes.ts +23 -10
  14. package/src/input/handler-modal-token-routes.ts +9 -0
  15. package/src/input/handler-onboarding-cloudflare.ts +391 -0
  16. package/src/input/handler-onboarding.ts +33 -0
  17. package/src/input/handler-picker-routes.ts +1 -1
  18. package/src/input/handler.ts +4 -1
  19. package/src/input/model-picker-types.ts +125 -0
  20. package/src/input/model-picker.ts +144 -135
  21. package/src/input/onboarding/onboarding-wizard-apply.ts +85 -0
  22. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +494 -0
  23. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +204 -0
  24. package/src/input/onboarding/onboarding-wizard-constants.ts +12 -1
  25. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +117 -0
  26. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +3 -41
  27. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -6
  28. package/src/input/onboarding/onboarding-wizard-types.ts +8 -0
  29. package/src/input/settings-modal-types.ts +2 -1
  30. package/src/input/settings-modal.ts +30 -8
  31. package/src/renderer/buffer.ts +40 -2
  32. package/src/renderer/compositor.ts +25 -17
  33. package/src/renderer/model-picker-overlay.ts +70 -0
  34. package/src/renderer/settings-modal-helpers.ts +9 -0
  35. package/src/runtime/cloudflare-control-plane.ts +349 -0
  36. package/src/runtime/onboarding/apply.ts +9 -8
  37. package/src/runtime/onboarding/derivation.ts +26 -1
  38. package/src/runtime/onboarding/snapshot.ts +2 -0
  39. package/src/runtime/onboarding/types.ts +5 -1
  40. package/src/shell/ui-openers.ts +10 -1
  41. package/src/version.ts +1 -1
@@ -0,0 +1,204 @@
1
+ import {
2
+ CLOUDFLARE_COMPONENT_IDS,
3
+ CLOUDFLARE_COMPONENT_LABELS,
4
+ DEFAULT_CLOUDFLARE_COMPONENT_SELECTION,
5
+ type CloudflareBatchMode,
6
+ type CloudflareComponent,
7
+ type CloudflareComponentSelection,
8
+ type CloudflareProvisionRequest,
9
+ } from '../../runtime/cloudflare-control-plane.ts';
10
+ import { buildGoodVibesSecretRef, normalizeText } from './onboarding-wizard-helpers.ts';
11
+ import type { OnboardingWizardController } from './onboarding-wizard.ts';
12
+ import type { OnboardingWizardRadioOption } from './onboarding-wizard-types.ts';
13
+
14
+ export const CLOUDFLARE_SETUP_SOURCE_OPTIONS: readonly OnboardingWizardRadioOption[] = [
15
+ {
16
+ id: 'save-only',
17
+ label: 'Save settings only',
18
+ hint: 'Persist Cloudflare fields without passing a token to the daemon. Provision later from the Cloudflare command or settings.',
19
+ },
20
+ {
21
+ id: 'bootstrap-token',
22
+ label: 'Paste temporary bootstrap token',
23
+ hint: 'Use a short-lived Cloudflare token once. The SDK creates and stores a narrower GoodVibes operational token.',
24
+ },
25
+ {
26
+ id: 'bootstrap-env',
27
+ label: 'Read bootstrap token from environment',
28
+ hint: 'Read a temporary token from an environment variable and pass it once to the SDK. The value is not stored.',
29
+ },
30
+ {
31
+ id: 'operational-token',
32
+ label: 'Paste final operational token',
33
+ hint: 'Use a token you already created. The SDK can store it as a GoodVibes secret during provisioning.',
34
+ },
35
+ {
36
+ id: 'operational-env',
37
+ label: 'Use final token from environment',
38
+ hint: 'Use an environment-backed token reference such as CLOUDFLARE_API_TOKEN.',
39
+ },
40
+ ];
41
+
42
+ export const CLOUDFLARE_BATCH_MODE_OPTIONS: readonly OnboardingWizardRadioOption[] = [
43
+ {
44
+ id: 'off',
45
+ label: 'Off',
46
+ hint: 'Keep daemon requests on the immediate local path. Cloudflare resource settings can still be saved.',
47
+ },
48
+ {
49
+ id: 'explicit',
50
+ label: 'Explicit batch only',
51
+ hint: 'Only requests explicitly marked for batch execution use the configured batch path.',
52
+ },
53
+ {
54
+ id: 'eligible-by-default',
55
+ label: 'Eligible requests batch by default',
56
+ hint: 'Batch-capable daemon work can use the configured batch path unless the caller opts out.',
57
+ },
58
+ ];
59
+
60
+ export const CLOUDFLARE_YES_NO_OPTIONS: readonly OnboardingWizardRadioOption[] = [
61
+ { id: 'yes', label: 'Yes', hint: 'Enable this behavior.' },
62
+ { id: 'no', label: 'No', hint: 'Leave this behavior off.' },
63
+ ];
64
+
65
+ export const CLOUDFLARE_PROVISION_OPTIONS: readonly OnboardingWizardRadioOption[] = [
66
+ {
67
+ id: 'no',
68
+ label: 'No, save configuration only',
69
+ hint: 'Final Apply saves the settings. Use the Cloudflare command or this wizard later to provision resources.',
70
+ },
71
+ {
72
+ id: 'yes',
73
+ label: 'Yes, create or update Cloudflare resources',
74
+ hint: 'Final Apply asks the daemon SDK route to create/update selected Cloudflare resources and verify the Worker when possible.',
75
+ },
76
+ ];
77
+
78
+ export type CloudflareSetupSource =
79
+ | 'save-only'
80
+ | 'bootstrap-token'
81
+ | 'bootstrap-env'
82
+ | 'operational-token'
83
+ | 'operational-env';
84
+
85
+ export function cloudflareComponentFieldId(component: CloudflareComponent): string {
86
+ return `cloudflare.component.${component}`;
87
+ }
88
+
89
+ export function cloudflareComponentLabel(component: CloudflareComponent): string {
90
+ return CLOUDFLARE_COMPONENT_LABELS[component];
91
+ }
92
+
93
+ export function isCloudflareConfigured(controller: OnboardingWizardController): boolean {
94
+ const config = controller.runtimeSnapshot?.config.cloudflare;
95
+ if (!config) return false;
96
+ return config.enabled
97
+ || normalizeText(config.accountId).length > 0
98
+ || normalizeText(config.apiTokenRef).length > 0
99
+ || normalizeText(config.workerBaseUrl).length > 0
100
+ || normalizeText(config.workerName).length > 0;
101
+ }
102
+
103
+ export function shouldShowCloudflareStep(controller: OnboardingWizardController): boolean {
104
+ return controller.isCapabilitySelected('cloudflare-batch') || isCloudflareConfigured(controller);
105
+ }
106
+
107
+ export function getCloudflareSetupSource(controller: OnboardingWizardController): CloudflareSetupSource {
108
+ const configuredTokenRef = controller.runtimeSnapshot?.config.cloudflare.apiTokenRef ?? '';
109
+ const defaultValue = configuredTokenRef.startsWith('goodvibes://secrets/env/') ? 'operational-env' : 'save-only';
110
+ const value = controller.getStringFieldValue('cloudflare.setup-source', defaultValue);
111
+ if (
112
+ value === 'bootstrap-token'
113
+ || value === 'bootstrap-env'
114
+ || value === 'operational-token'
115
+ || value === 'operational-env'
116
+ || value === 'save-only'
117
+ ) {
118
+ return value;
119
+ }
120
+ return 'save-only';
121
+ }
122
+
123
+ export function getCloudflareComponentSelection(controller: OnboardingWizardController): Record<CloudflareComponent, boolean> {
124
+ const selected: Record<CloudflareComponent, boolean> = { ...DEFAULT_CLOUDFLARE_COMPONENT_SELECTION };
125
+ const configured = controller.runtimeSnapshot?.config.cloudflare;
126
+ for (const component of CLOUDFLARE_COMPONENT_IDS) {
127
+ const fallback = configured?.enabled === true
128
+ ? component === 'workers' || component === 'queues'
129
+ : DEFAULT_CLOUDFLARE_COMPONENT_SELECTION[component];
130
+ selected[component] = controller.getBooleanFieldValue(cloudflareComponentFieldId(component), fallback);
131
+ }
132
+ return selected;
133
+ }
134
+
135
+ export function getSelectedCloudflareComponents(controller: OnboardingWizardController): CloudflareComponentSelection {
136
+ return getCloudflareComponentSelection(controller);
137
+ }
138
+
139
+ export function getCloudflareBatchMode(controller: OnboardingWizardController): CloudflareBatchMode {
140
+ const value = controller.getStringFieldValue('cloudflare.batch-mode', controller.runtimeSnapshot?.config.batch.mode ?? 'off');
141
+ return value === 'explicit' || value === 'eligible-by-default' ? value : 'off';
142
+ }
143
+
144
+ export function buildCloudflareApiTokenRef(envName: string): string {
145
+ const normalized = normalizeText(envName) || 'CLOUDFLARE_API_TOKEN';
146
+ return `goodvibes://secrets/env/${encodeURIComponent(normalized)}`;
147
+ }
148
+
149
+ export function buildCloudflareProvisionRequest(controller: OnboardingWizardController, options: {
150
+ readonly includeTransientSecrets?: boolean;
151
+ } = {}): CloudflareProvisionRequest {
152
+ const components = getCloudflareComponentSelection(controller);
153
+ const setupSource = getCloudflareSetupSource(controller);
154
+ const accountId = controller.getStringFieldValue('cloudflare.account-id', controller.runtimeSnapshot?.config.cloudflare.accountId ?? '');
155
+ const zoneId = controller.getStringFieldValue('cloudflare.zone-id', controller.runtimeSnapshot?.config.cloudflare.zoneId ?? '');
156
+ const zoneName = controller.getStringFieldValue('cloudflare.zone-name', controller.runtimeSnapshot?.config.cloudflare.zoneName ?? '');
157
+ const apiToken = setupSource === 'operational-token' && options.includeTransientSecrets
158
+ ? controller.getStringFieldValue('cloudflare.operational-token', '')
159
+ : '';
160
+ const apiTokenRef = setupSource === 'operational-env'
161
+ ? buildCloudflareApiTokenRef(controller.getStringFieldValue('cloudflare.operational-env-name', 'CLOUDFLARE_API_TOKEN'))
162
+ : controller.runtimeSnapshot?.config.cloudflare.apiTokenRef ?? '';
163
+
164
+ return {
165
+ components,
166
+ ...(accountId ? { accountId } : {}),
167
+ ...(zoneId ? { zoneId } : {}),
168
+ ...(zoneName ? { zoneName } : {}),
169
+ ...(apiToken ? { apiToken, storeApiToken: true } : {}),
170
+ ...(!apiToken && apiTokenRef ? { apiTokenRef } : {}),
171
+ workerName: controller.getStringFieldValue('cloudflare.worker-name', controller.runtimeSnapshot?.config.cloudflare.workerName ?? 'goodvibes-batch-worker'),
172
+ workerSubdomain: controller.getStringFieldValue('cloudflare.worker-subdomain', controller.runtimeSnapshot?.config.cloudflare.workerSubdomain ?? ''),
173
+ workerHostname: controller.getStringFieldValue('cloudflare.worker-hostname', controller.runtimeSnapshot?.config.cloudflare.workerHostname ?? ''),
174
+ workerBaseUrl: controller.getStringFieldValue('cloudflare.worker-base-url', controller.runtimeSnapshot?.config.cloudflare.workerBaseUrl ?? ''),
175
+ daemonBaseUrl: controller.getStringFieldValue('cloudflare.daemon-base-url', controller.runtimeSnapshot?.config.cloudflare.daemonBaseUrl ?? ''),
176
+ daemonHostname: controller.getStringFieldValue('cloudflare.daemon-hostname', controller.runtimeSnapshot?.config.cloudflare.daemonHostname ?? ''),
177
+ queueName: controller.getStringFieldValue('cloudflare.queue-name', controller.runtimeSnapshot?.config.cloudflare.queueName ?? 'goodvibes-batch'),
178
+ deadLetterQueueName: controller.getStringFieldValue('cloudflare.dead-letter-queue-name', controller.runtimeSnapshot?.config.cloudflare.deadLetterQueueName ?? 'goodvibes-batch-dlq'),
179
+ tunnelName: controller.getStringFieldValue('cloudflare.tunnel-name', controller.runtimeSnapshot?.config.cloudflare.tunnelName ?? 'goodvibes-daemon'),
180
+ tunnelId: controller.getStringFieldValue('cloudflare.tunnel-id', controller.runtimeSnapshot?.config.cloudflare.tunnelId ?? ''),
181
+ tunnelServiceUrl: controller.getStringFieldValue('cloudflare.tunnel-service-url', ''),
182
+ tunnelTokenRef: controller.getStringFieldValue('cloudflare.tunnel-token-ref', controller.runtimeSnapshot?.config.cloudflare.tunnelTokenRef ?? ''),
183
+ accessAppId: controller.getStringFieldValue('cloudflare.access-app-id', controller.runtimeSnapshot?.config.cloudflare.accessAppId ?? ''),
184
+ accessServiceTokenId: controller.getStringFieldValue('cloudflare.access-service-token-id', controller.runtimeSnapshot?.config.cloudflare.accessServiceTokenId ?? ''),
185
+ accessServiceTokenRef: controller.getStringFieldValue('cloudflare.access-service-token-ref', controller.runtimeSnapshot?.config.cloudflare.accessServiceTokenRef ?? ''),
186
+ kvNamespaceName: controller.getStringFieldValue('cloudflare.kv-namespace-name', controller.runtimeSnapshot?.config.cloudflare.kvNamespaceName ?? 'goodvibes-runtime'),
187
+ kvNamespaceId: controller.getStringFieldValue('cloudflare.kv-namespace-id', controller.runtimeSnapshot?.config.cloudflare.kvNamespaceId ?? ''),
188
+ durableObjectNamespaceName: controller.getStringFieldValue('cloudflare.do-namespace-name', controller.runtimeSnapshot?.config.cloudflare.durableObjectNamespaceName ?? 'GoodVibesCoordinator'),
189
+ durableObjectNamespaceId: controller.getStringFieldValue('cloudflare.do-namespace-id', controller.runtimeSnapshot?.config.cloudflare.durableObjectNamespaceId ?? ''),
190
+ r2BucketName: controller.getStringFieldValue('cloudflare.r2-bucket-name', controller.runtimeSnapshot?.config.cloudflare.r2BucketName ?? 'goodvibes-artifacts'),
191
+ secretsStoreName: controller.getStringFieldValue('cloudflare.secrets-store-name', controller.runtimeSnapshot?.config.cloudflare.secretsStoreName ?? 'goodvibes'),
192
+ secretsStoreId: controller.getStringFieldValue('cloudflare.secrets-store-id', controller.runtimeSnapshot?.config.cloudflare.secretsStoreId ?? ''),
193
+ workerCron: controller.getStringFieldValue('cloudflare.worker-cron', controller.runtimeSnapshot?.config.cloudflare.workerCron ?? '*/5 * * * *'),
194
+ enableWorkersDev: true,
195
+ queueJobPayloads: false,
196
+ persistConfig: true,
197
+ verify: true,
198
+ batchMode: getCloudflareBatchMode(controller),
199
+ };
200
+ }
201
+
202
+ export function buildCloudflareOperationalTokenRef(): string {
203
+ return buildGoodVibesSecretRef('CLOUDFLARE_API_TOKEN');
204
+ }
@@ -6,6 +6,7 @@ export const STEP_ORDER: readonly OnboardingWizardStepId[] = [
6
6
  'network',
7
7
  'access',
8
8
  'external-services',
9
+ 'cloudflare',
9
10
  'provider-access',
10
11
  'default-model',
11
12
  'experience',
@@ -41,7 +42,13 @@ export const DEFAULT_CAPABILITIES: readonly OnboardingStep1CapabilityItem[] = [
41
42
  id: 'external-integrations',
42
43
  label: 'Connect GoodVibes to external apps and services',
43
44
  selected: false,
44
- detail: 'Enable setup screens for Slack, Discord, Telegram, Teams, Matrix, and other app surfaces you choose.',
45
+ detail: 'Enable setup screens for Slack, Discord, Telegram, Home Assistant, Teams, Matrix, and other app surfaces you choose.',
46
+ },
47
+ {
48
+ id: 'cloudflare-batch',
49
+ label: 'Use Cloudflare for batch or remote daemon work',
50
+ selected: false,
51
+ detail: 'Optionally configure Cloudflare Workers and Queues for explicit or eligible background batch jobs. Immediate local daemon behavior stays the default unless enabled.',
45
52
  },
46
53
  ];
47
54
 
@@ -95,6 +102,7 @@ export const INBOUND_EXTERNAL_SURFACE_IDS = new Set<string>([
95
102
  'bluebubbles',
96
103
  'discord',
97
104
  'googleChat',
105
+ 'homeassistant',
98
106
  'imessage',
99
107
  'mattermost',
100
108
  'matrix',
@@ -116,6 +124,9 @@ export const REQUIRED_EXTERNAL_SETUP_FIELD_IDS = new Set<string>([
116
124
  'external-services.telegram.bot-token',
117
125
  'external-services.telegram.webhook-secret',
118
126
  'external-services.webhook.default-target',
127
+ 'external-services.homeassistant.instance-url',
128
+ 'external-services.homeassistant.access-token',
129
+ 'external-services.homeassistant.webhook-secret',
119
130
  'external-services.google-chat.webhook-url',
120
131
  'external-services.google-chat.verification-token',
121
132
  'external-services.signal.bridge-url',
@@ -0,0 +1,117 @@
1
+ import type { ExternalSurfaceSpec } from './onboarding-wizard-external-surfaces.ts';
2
+
3
+ export const WEBHOOK_SURFACE_SPEC: ExternalSurfaceSpec = {
4
+ id: 'webhook',
5
+ enabledFieldId: 'external-services.webhook',
6
+ enabledConfigKey: 'surfaces.webhook.enabled',
7
+ label: 'Outbound webhook surface',
8
+ hint: 'Enable outbound webhook delivery targets.',
9
+ defaultEnabled: (snapshot) => snapshot?.config.surfaces.webhook.enabled ?? false,
10
+ fields: [
11
+ {
12
+ id: 'external-services.webhook.default-target',
13
+ configKey: 'surfaces.webhook.defaultTarget',
14
+ kind: 'text',
15
+ label: 'Default webhook target',
16
+ hint: 'Fallback URL used for outbound webhook deliveries.',
17
+ placeholder: 'https://example.com/goodvibes',
18
+ defaultValue: (snapshot) => snapshot?.config.surfaces.webhook.defaultTarget ?? '',
19
+ },
20
+ {
21
+ id: 'external-services.webhook.secret',
22
+ configKey: 'surfaces.webhook.secret',
23
+ kind: 'masked',
24
+ label: 'Webhook signing secret',
25
+ hint: 'Secret used to sign outbound webhook payloads.',
26
+ placeholder: 'secret',
27
+ defaultValue: (snapshot) => snapshot?.config.surfaces.webhook.secret ?? '',
28
+ },
29
+ {
30
+ id: 'external-services.webhook.timeout-ms',
31
+ configKey: 'surfaces.webhook.timeoutMs',
32
+ kind: 'text',
33
+ valueType: 'number',
34
+ label: 'Webhook timeout ms',
35
+ hint: 'Request timeout for outbound webhook deliveries.',
36
+ placeholder: '10000',
37
+ defaultNumber: 10000,
38
+ min: 1000,
39
+ max: 60000,
40
+ defaultValue: (snapshot) => String(snapshot?.config.surfaces.webhook.timeoutMs ?? 10000),
41
+ },
42
+ ],
43
+ };
44
+
45
+ export const HOME_ASSISTANT_SURFACE_SPEC: ExternalSurfaceSpec = {
46
+ id: 'homeassistant',
47
+ enabledFieldId: 'external-services.homeassistant',
48
+ enabledConfigKey: 'surfaces.homeassistant.enabled',
49
+ label: 'Home Assistant surface',
50
+ hint: 'Enable the Home Assistant companion surface, daemon callbacks, and event delivery.',
51
+ defaultEnabled: (snapshot) => snapshot?.config.surfaces.homeassistant.enabled ?? false,
52
+ fields: [
53
+ {
54
+ id: 'external-services.homeassistant.instance-url',
55
+ configKey: 'surfaces.homeassistant.instanceUrl',
56
+ kind: 'text',
57
+ label: 'Home Assistant URL',
58
+ hint: 'Base URL of the Home Assistant instance.',
59
+ placeholder: 'http://homeassistant.local:8123',
60
+ defaultValue: (snapshot) => snapshot?.config.surfaces.homeassistant.instanceUrl ?? '',
61
+ },
62
+ {
63
+ id: 'external-services.homeassistant.access-token',
64
+ configKey: 'surfaces.homeassistant.accessToken',
65
+ kind: 'masked',
66
+ label: 'Home Assistant access token',
67
+ hint: 'Long-lived Home Assistant access token or goodvibes:// secret reference.',
68
+ placeholder: 'long-lived access token',
69
+ defaultValue: (snapshot) => snapshot?.config.surfaces.homeassistant.accessToken ?? '',
70
+ },
71
+ {
72
+ id: 'external-services.homeassistant.webhook-secret',
73
+ configKey: 'surfaces.homeassistant.webhookSecret',
74
+ kind: 'masked',
75
+ label: 'Home Assistant webhook secret',
76
+ hint: 'Shared secret used to verify inbound Home Assistant callbacks.',
77
+ placeholder: 'webhook secret',
78
+ defaultValue: (snapshot) => snapshot?.config.surfaces.homeassistant.webhookSecret ?? '',
79
+ },
80
+ {
81
+ id: 'external-services.homeassistant.default-conversation-id',
82
+ configKey: 'surfaces.homeassistant.defaultConversationId',
83
+ kind: 'text',
84
+ label: 'Default conversation ID',
85
+ hint: 'Default Home Assistant conversation id used for route binding.',
86
+ placeholder: 'goodvibes',
87
+ defaultValue: (snapshot) => snapshot?.config.surfaces.homeassistant.defaultConversationId ?? 'goodvibes',
88
+ },
89
+ {
90
+ id: 'external-services.homeassistant.device-id',
91
+ configKey: 'surfaces.homeassistant.deviceId',
92
+ kind: 'text',
93
+ label: 'Home Assistant device ID',
94
+ hint: 'Stable device identifier exposed by the GoodVibes daemon.',
95
+ placeholder: 'goodvibes-daemon',
96
+ defaultValue: (snapshot) => snapshot?.config.surfaces.homeassistant.deviceId ?? 'goodvibes-daemon',
97
+ },
98
+ {
99
+ id: 'external-services.homeassistant.device-name',
100
+ configKey: 'surfaces.homeassistant.deviceName',
101
+ kind: 'text',
102
+ label: 'Home Assistant device name',
103
+ hint: 'Display name for the GoodVibes daemon device in Home Assistant.',
104
+ placeholder: 'GoodVibes Daemon',
105
+ defaultValue: (snapshot) => snapshot?.config.surfaces.homeassistant.deviceName ?? 'GoodVibes Daemon',
106
+ },
107
+ {
108
+ id: 'external-services.homeassistant.event-type',
109
+ configKey: 'surfaces.homeassistant.eventType',
110
+ kind: 'text',
111
+ label: 'Home Assistant event type',
112
+ hint: 'Event type used for daemon-to-Home Assistant deliveries.',
113
+ placeholder: 'goodvibes_message',
114
+ defaultValue: (snapshot) => snapshot?.config.surfaces.homeassistant.eventType ?? 'goodvibes_message',
115
+ },
116
+ ],
117
+ };
@@ -7,6 +7,7 @@ import {
7
7
  import { DEFAULT_CONFIG, type ConfigKey } from '../../config/index.ts';
8
8
  import type { OnboardingSnapshotState } from '../../runtime/onboarding/index.ts';
9
9
  import { TELEGRAM_MODE_OPTIONS, WHATSAPP_PROVIDER_OPTIONS } from './onboarding-wizard-constants.ts';
10
+ import { HOME_ASSISTANT_SURFACE_SPEC, WEBHOOK_SURFACE_SPEC } from './onboarding-wizard-external-surface-extra-specs.ts';
10
11
  import type { OnboardingWizardRadioOption } from './onboarding-wizard-types.ts';
11
12
 
12
13
  export interface ExternalSurfaceSetupFieldSpec {
@@ -333,47 +334,8 @@ export const EXTERNAL_SURFACE_SPECS: readonly ExternalSurfaceSpec[] = [
333
334
  },
334
335
  ],
335
336
  },
336
- {
337
- id: 'webhook',
338
- enabledFieldId: 'external-services.webhook',
339
- enabledConfigKey: 'surfaces.webhook.enabled',
340
- label: 'Outbound webhook surface',
341
- hint: 'Enable outbound webhook delivery targets.',
342
- defaultEnabled: (snapshot) => snapshot?.config.surfaces.webhook.enabled ?? false,
343
- fields: [
344
- {
345
- id: 'external-services.webhook.default-target',
346
- configKey: 'surfaces.webhook.defaultTarget',
347
- kind: 'text',
348
- label: 'Default webhook target',
349
- hint: 'Fallback URL used for outbound webhook deliveries.',
350
- placeholder: 'https://example.com/goodvibes',
351
- defaultValue: (snapshot) => snapshot?.config.surfaces.webhook.defaultTarget ?? '',
352
- },
353
- {
354
- id: 'external-services.webhook.secret',
355
- configKey: 'surfaces.webhook.secret',
356
- kind: 'masked',
357
- label: 'Webhook signing secret',
358
- hint: 'Secret used to sign outbound webhook payloads.',
359
- placeholder: 'secret',
360
- defaultValue: (snapshot) => snapshot?.config.surfaces.webhook.secret ?? '',
361
- },
362
- {
363
- id: 'external-services.webhook.timeout-ms',
364
- configKey: 'surfaces.webhook.timeoutMs',
365
- kind: 'text',
366
- valueType: 'number',
367
- label: 'Webhook timeout ms',
368
- hint: 'Request timeout for outbound webhook deliveries.',
369
- placeholder: '10000',
370
- defaultNumber: 10000,
371
- min: 1000,
372
- max: 60000,
373
- defaultValue: (snapshot) => String(snapshot?.config.surfaces.webhook.timeoutMs ?? 10000),
374
- },
375
- ],
376
- },
337
+ WEBHOOK_SURFACE_SPEC,
338
+ HOME_ASSISTANT_SURFACE_SPEC,
377
339
  {
378
340
  id: 'googleChat',
379
341
  enabledFieldId: 'external-services.google-chat',
@@ -1,4 +1,6 @@
1
1
  import { NETWORK_MODE_OPTIONS, REASONING_OPTIONS, HITL_MODE_OPTIONS, GUIDANCE_MODE_OPTIONS, PERMISSION_MODE_OPTIONS, SECRET_POLICY_OPTIONS } from './onboarding-wizard-constants.ts';
2
+ import { shouldShowCloudflareStep } from './onboarding-wizard-cloudflare.ts';
3
+ import { buildCloudflareStep } from './onboarding-wizard-cloudflare-step.ts';
2
4
  import {
3
5
  EXTERNAL_SURFACE_SPECS,
4
6
  getExternalSurfaceAutoStartDefaultValue,
@@ -19,27 +21,25 @@ export function buildOnboardingWizardSteps(controller: OnboardingWizardControlle
19
21
  const steps: OnboardingWizardStepDefinition[] = [
20
22
  buildCapabilitiesStep(controller),
21
23
  ];
22
-
23
24
  if (hasServers) {
24
25
  steps.push(buildNetworkStep(controller));
25
26
  }
26
-
27
27
  if (hasServers || controller.hasExistingAccessState()) {
28
28
  steps.push(buildAccessStep(controller));
29
29
  }
30
-
31
30
  if (wantsExternalServices) {
32
31
  steps.push(buildExternalServicesStep(controller));
33
32
  for (const surface of getSelectedExternalSurfaceSpecs(controller)) {
34
33
  steps.push(buildExternalSurfaceStep(controller, surface));
35
34
  }
36
35
  }
37
-
36
+ if (shouldShowCloudflareStep(controller)) {
37
+ steps.push(buildCloudflareStep(controller));
38
+ }
38
39
  steps.push(buildProviderAccessStep(controller));
39
40
  steps.push(buildDefaultModelStep(controller));
40
41
  steps.push(buildExperienceStep(controller));
41
42
  steps.push(buildReviewStep(controller));
42
-
43
43
  return steps.map(addApplyAndContinueAction);
44
44
  }
45
45
 
@@ -49,7 +49,7 @@ function buildApplyAndContinueAction(step: OnboardingWizardStepDefinition): Onbo
49
49
  id: `${step.id}.apply-and-continue`,
50
50
  action: 'apply-and-continue',
51
51
  label: 'Apply & Continue To Next Section',
52
- hint: 'Persist the current wizard settings, verify them, and move to the next onboarding section.',
52
+ hint: 'Save the current wizard selections in this onboarding session and move to the next section. Settings are persisted on the final Review apply.',
53
53
  defaultValue: 'Apply & next',
54
54
  spacerBeforeRows: 2,
55
55
  };
@@ -25,6 +25,7 @@ export type OnboardingWizardStepId =
25
25
  | 'access'
26
26
  | 'external-services'
27
27
  | OnboardingWizardExternalSurfaceStepId
28
+ | 'cloudflare'
28
29
  | 'provider-access'
29
30
  | 'default-model'
30
31
  | 'experience'
@@ -47,6 +48,13 @@ export type OnboardingWizardAction =
47
48
  | 'clear-capabilities'
48
49
  | 'select-all-external-surfaces'
49
50
  | 'clear-external-surfaces'
51
+ | 'cloudflare-token-requirements'
52
+ | 'cloudflare-create-operational-token'
53
+ | 'cloudflare-discover'
54
+ | 'cloudflare-validate'
55
+ | 'cloudflare-provision'
56
+ | 'cloudflare-verify'
57
+ | 'cloudflare-disable'
50
58
  | 'start-openai-subscription'
51
59
  | 'finish-openai-subscription';
52
60
 
@@ -2,7 +2,7 @@ import type { ConfigSetting } from '@pellux/goodvibes-sdk/platform/config/schema
2
2
  import type { ProviderAuthFreshness, ProviderAuthRoute } from '@pellux/goodvibes-sdk/platform/runtime/provider-accounts/registry';
3
3
  import type { FeatureFlag, FlagState } from '@pellux/goodvibes-sdk/platform/runtime/feature-flags/types';
4
4
 
5
- export type SettingsCategory = 'display' | 'ui' | 'provider' | 'subscriptions' | 'behavior' | 'storage' | 'permissions' | 'mcp' | 'sandbox' | 'surfaces' | 'danger' | 'tools' | 'flags' | 'network';
5
+ export type SettingsCategory = 'display' | 'ui' | 'provider' | 'subscriptions' | 'behavior' | 'storage' | 'permissions' | 'mcp' | 'sandbox' | 'surfaces' | 'cloudflare' | 'danger' | 'tools' | 'flags' | 'network';
6
6
 
7
7
  export const SETTINGS_CATEGORIES: SettingsCategory[] = [
8
8
  'display',
@@ -15,6 +15,7 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [
15
15
  'mcp',
16
16
  'sandbox',
17
17
  'surfaces',
18
+ 'cloudflare',
18
19
  'danger',
19
20
  'tools',
20
21
  'flags',
@@ -41,13 +41,21 @@ export {
41
41
  type SubscriptionEntry,
42
42
  } from './settings-modal-types.ts';
43
43
 
44
+ type ModelPickerLaunch =
45
+ | { readonly flow: 'providerModel'; readonly target: ModelPickerTarget }
46
+ | { readonly flow: 'model'; readonly target: ModelPickerTarget };
47
+
44
48
  /**
45
- * Map a config key to the model picker target it should open, or null if the
46
- * setting should use the normal inline text-edit flow.
49
+ * Map config keys to the shared provider/model picker flows. Provider rows open
50
+ * provider first; model rows open directly to models for the same target.
47
51
  */
48
- function _modelPickerTargetForKey(key: string): ModelPickerTarget | null {
49
- if (key === 'helper.globalProvider' || key === 'helper.globalModel') return 'helper';
50
- if (key === 'tools.llmProvider' || key === 'tools.llmModel') return 'tool';
52
+ function _modelPickerLaunchForKey(key: string): ModelPickerLaunch | null {
53
+ if (key === 'helper.globalProvider') return { flow: 'providerModel', target: 'helper' };
54
+ if (key === 'helper.globalModel') return { flow: 'model', target: 'helper' };
55
+ if (key === 'tools.llmProvider') return { flow: 'providerModel', target: 'tool' };
56
+ if (key === 'tools.llmModel') return { flow: 'model', target: 'tool' };
57
+ if (key === 'tts.llmProvider') return { flow: 'providerModel', target: 'tts' };
58
+ if (key === 'tts.llmModel') return { flow: 'model', target: 'tts' };
51
59
  return null;
52
60
  }
53
61
 
@@ -94,6 +102,8 @@ export class SettingsModal {
94
102
  * Consumed and cleared by the route handler after each Enter/Space action.
95
103
  */
96
104
  public pendingModelPickerTarget: ModelPickerTarget | null = null;
105
+ /** Set when the highlighted setting should open provider selection before model selection. */
106
+ public pendingProviderModelPickerTarget: ModelPickerTarget | null = null;
97
107
  /** Provider awaiting explicit logout confirmation, if any. */
98
108
  public subscriptionLogoutConfirmationTarget: string | null = null;
99
109
 
@@ -146,6 +156,8 @@ export class SettingsModal {
146
156
  this.selectedIndex = 0;
147
157
  this.editingMode = false;
148
158
  this.editBuffer = '';
159
+ this.pendingModelPickerTarget = null;
160
+ this.pendingProviderModelPickerTarget = null;
149
161
  this.mcpAllowAllConfirmationTarget = null;
150
162
  this.subscriptionLogoutConfirmationTarget = null;
151
163
  this.lastSaveTriggeredRestart = null;
@@ -156,6 +168,8 @@ export class SettingsModal {
156
168
  this.active = false;
157
169
  this.editingMode = false;
158
170
  this.editBuffer = '';
171
+ this.pendingModelPickerTarget = null;
172
+ this.pendingProviderModelPickerTarget = null;
159
173
  this.mcpAllowAllConfirmationTarget = null;
160
174
  this.subscriptionLogoutConfirmationTarget = null;
161
175
  this.lastSaveTriggeredRestart = null;
@@ -290,9 +304,13 @@ export class SettingsModal {
290
304
  const { setting } = entry;
291
305
 
292
306
  // Delegate provider/model picker settings to the model picker UI
293
- const pickerTarget = _modelPickerTargetForKey(setting.key);
294
- if (pickerTarget !== null) {
295
- this.pendingModelPickerTarget = pickerTarget;
307
+ const pickerLaunch = _modelPickerLaunchForKey(setting.key);
308
+ if (pickerLaunch !== null) {
309
+ if (pickerLaunch.flow === 'providerModel') {
310
+ this.pendingProviderModelPickerTarget = pickerLaunch.target;
311
+ } else {
312
+ this.pendingModelPickerTarget = pickerLaunch.target;
313
+ }
296
314
  return;
297
315
  }
298
316
 
@@ -521,6 +539,8 @@ export class SettingsModal {
521
539
  cat = 'network';
522
540
  } else if (rawCat === 'surfaces') {
523
541
  cat = 'surfaces';
542
+ } else if (rawCat === 'cloudflare' || rawCat === 'batch') {
543
+ cat = 'cloudflare';
524
544
  } else {
525
545
  cat = rawCat as SettingsCategory;
526
546
  }
@@ -750,6 +770,8 @@ export class SettingsModal {
750
770
  }
751
771
  } else if (rawCat === 'surfaces') {
752
772
  cat = 'surfaces';
773
+ } else if (rawCat === 'cloudflare' || rawCat === 'batch') {
774
+ cat = 'cloudflare';
753
775
  } else {
754
776
  cat = rawCat as SettingsCategory;
755
777
  }
@@ -39,7 +39,13 @@ export class TerminalBuffer {
39
39
 
40
40
  public blitLine(row: number, line: Line): void {
41
41
  if (row >= 0 && row < this.height) {
42
- this.cells[row] = [...line];
42
+ const current = this.cells[row]!;
43
+ const next = createEmptyLine(this.width);
44
+ for (let x = 0; x < Math.min(line.length, this.width); x++) {
45
+ next[x] = { ...line[x]! };
46
+ }
47
+ if (linesEqual(current, next, this.width)) return;
48
+ this.cells[row] = next;
43
49
  this.dirtyRows[row] = true;
44
50
  }
45
51
  }
@@ -56,12 +62,17 @@ export class TerminalBuffer {
56
62
  * If dimensions changed, reallocates cells array.
57
63
  * Always clears the dirty bitmap.
58
64
  */
59
- public reset(width: number, height: number): void {
65
+ public reset(width: number, height: number, source?: TerminalBuffer | null): void {
60
66
  if (width !== this.width || height !== this.height) {
61
67
  this.width = width;
62
68
  this.height = height;
63
69
  this.cells = Array.from({ length: height }, () => createEmptyLine(width));
64
70
  this.dirtyRows = new Array(height).fill(false);
71
+ } else if (source && source.width === width && source.height === height) {
72
+ for (let y = 0; y < this.height; y++) {
73
+ this.cells[y] = source.cells[y]!.map(cell => ({ ...cell }));
74
+ this.dirtyRows[y] = false;
75
+ }
65
76
  } else {
66
77
  for (let y = 0; y < this.height; y++) {
67
78
  const row = this.cells[y]!;
@@ -72,4 +83,31 @@ export class TerminalBuffer {
72
83
  }
73
84
  }
74
85
  }
86
+
87
+ public clearDirty(): void {
88
+ this.dirtyRows.fill(false);
89
+ }
90
+ }
91
+
92
+ function linesEqual(left: Line, right: Line, width: number): boolean {
93
+ for (let x = 0; x < width; x++) {
94
+ const a = left[x];
95
+ const b = right[x];
96
+ if (!a && !b) continue;
97
+ if (!a || !b) return false;
98
+ if (
99
+ a.char !== b.char
100
+ || a.fg !== b.fg
101
+ || a.bg !== b.bg
102
+ || a.bold !== b.bold
103
+ || a.dim !== b.dim
104
+ || a.underline !== b.underline
105
+ || a.italic !== b.italic
106
+ || a.strikethrough !== b.strikethrough
107
+ || (a.link ?? '') !== (b.link ?? '')
108
+ ) {
109
+ return false;
110
+ }
111
+ }
112
+ return true;
75
113
  }