@pellux/goodvibes-tui 0.19.28 → 0.19.29

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 (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +3 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/surface-command.ts +46 -11
  6. package/src/daemon/cli.ts +7 -0
  7. package/src/input/handler-onboarding.ts +151 -44
  8. package/src/input/onboarding/handler-onboarding-routes.ts +4 -0
  9. package/src/input/onboarding/onboarding-wizard-apply.ts +35 -8
  10. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -5
  11. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +93 -5
  12. package/src/input/onboarding/onboarding-wizard-helpers.ts +2 -2
  13. package/src/input/onboarding/onboarding-wizard-rules.ts +22 -3
  14. package/src/input/onboarding/onboarding-wizard-state.ts +12 -7
  15. package/src/input/onboarding/onboarding-wizard-steps.ts +133 -59
  16. package/src/input/onboarding/onboarding-wizard-types.ts +10 -0
  17. package/src/input/onboarding/onboarding-wizard.ts +56 -4
  18. package/src/input/settings-modal-types.ts +2 -1
  19. package/src/input/settings-modal.ts +4 -0
  20. package/src/main.ts +33 -26
  21. package/src/renderer/compositor.ts +3 -3
  22. package/src/renderer/onboarding/onboarding-wizard.ts +38 -21
  23. package/src/renderer/settings-modal-helpers.ts +9 -0
  24. package/src/renderer/settings-modal.ts +3 -0
  25. package/src/runtime/bootstrap.ts +15 -0
  26. package/src/runtime/onboarding/apply.ts +36 -8
  27. package/src/runtime/onboarding/derivation.ts +7 -7
  28. package/src/runtime/onboarding/snapshot.ts +1 -0
  29. package/src/runtime/onboarding/types.ts +4 -1
  30. package/src/runtime/onboarding/verify.ts +1 -1
  31. package/src/runtime/surface-feature-flags.ts +67 -0
  32. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.19.29] — 2026-04-24
8
+
9
+ ### Changes
10
+ - 617b8860 feat: improve onboarding and surface setup
11
+ - 5e39b0f8 docs: add onboarding wizard WIP notice to README top
12
+
7
13
  ## [0.19.28] — 2026-04-24
8
14
 
9
15
  ### Changes
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
+ > **ATTENTION:** Currently updating the Onboarding Wizard - Expect problems with starting the TUI until this work is complete!
2
+
1
3
  # goodvibes-tui
2
4
 
3
5
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.19.28-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
7
+ [![Version](https://img.shields.io/badge/version-0.19.29-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
8
 
7
9
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
10
 
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "0.25.2"
6
+ "version": "0.25.4"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.28",
3
+ "version": "0.19.29",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -91,7 +91,7 @@
91
91
  "@anthropic-ai/vertex-sdk": "^0.16.0",
92
92
  "@ast-grep/napi": "^0.42.0",
93
93
  "@aws/bedrock-token-generator": "^1.1.0",
94
- "@pellux/goodvibes-sdk": "^0.25.2",
94
+ "@pellux/goodvibes-sdk": "^0.25.4",
95
95
  "bash-language-server": "^5.6.0",
96
96
  "fuse.js": "^7.1.0",
97
97
  "graphql": "^16.13.2",
@@ -1,4 +1,11 @@
1
1
  import type { ConfigKey } from '../config/index.ts';
2
+ import {
3
+ GOODVIBES_NTFY_AGENT_TOPIC,
4
+ GOODVIBES_NTFY_CHAT_TOPIC,
5
+ GOODVIBES_NTFY_REMOTE_TOPIC,
6
+ resolveGoodVibesNtfyTopics,
7
+ } from '@pellux/goodvibes-sdk/platform/integrations/ntfy';
8
+ import { enableFeatureFlags, getMissingSurfaceFeatureFlags, getServerSurfaceFeatureFlags } from '../runtime/surface-feature-flags.ts';
2
9
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
3
10
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
4
11
  import type { CliCommandRuntime } from './management.ts';
@@ -18,7 +25,7 @@ export const SURFACE_CONFIGS = [
18
25
  ['discord', 'Discord', ['surfaces.discord.publicKey', 'surfaces.discord.botToken', 'surfaces.discord.applicationId']],
19
26
  ['telegram', 'Telegram', ['surfaces.telegram.botToken']],
20
27
  ['webhook', 'Webhook', ['surfaces.webhook.secret']],
21
- ['ntfy', 'ntfy', ['surfaces.ntfy.baseUrl', 'surfaces.ntfy.topic']],
28
+ ['ntfy', 'ntfy', ['surfaces.ntfy.baseUrl']],
22
29
  ['googleChat', 'Google Chat', ['surfaces.googleChat.webhookUrl']],
23
30
  ['signal', 'Signal', ['surfaces.signal.bridgeUrl', 'surfaces.signal.account']],
24
31
  ['whatsapp', 'WhatsApp', ['surfaces.whatsapp.accessToken', 'surfaces.whatsapp.phoneNumberId']],
@@ -39,6 +46,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
39
46
  if (target === 'web') {
40
47
  runtime.configManager.setDynamic('web.enabled', enabled);
41
48
  if (enabled) {
49
+ enableFeatureFlags(runtime.configManager, getServerSurfaceFeatureFlags({ serverBacked: true, web: true }));
42
50
  runtime.configManager.setDynamic('danger.daemon', true);
43
51
  runtime.configManager.setDynamic('controlPlane.enabled', true);
44
52
  const webError = applyTargetEndpointFlagsOrDefault(runtime, 'web');
@@ -71,6 +79,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
71
79
  else if (SURFACE_CONFIGS.some(([id]) => id === target)) {
72
80
  runtime.configManager.setDynamic(`surfaces.${target}.enabled` as ConfigKey, enabled);
73
81
  if (enabled) {
82
+ enableFeatureFlags(runtime.configManager, getServerSurfaceFeatureFlags({ serverBacked: true, externalSurfaces: [target] }));
74
83
  runtime.configManager.setDynamic('danger.httpListener', true);
75
84
  enableEndpointLanDefault(runtime.configManager, 'httpListener');
76
85
  }
@@ -88,34 +97,45 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
88
97
  const web = resolveRuntimeEndpointBinding(config, 'web');
89
98
  const httpListener = resolveRuntimeEndpointBinding(config, 'httpListener');
90
99
  const includeProbe = sub === 'check';
100
+ const targetExternalSurface = target && SURFACE_CONFIGS.some(([id]) => id === target);
101
+ const shouldProbeControlPlane = includeProbe && !target;
102
+ const shouldProbeWeb = includeProbe && !target;
103
+ const shouldProbeListener = includeProbe && (!target || targetExternalSurface);
91
104
  const [controlPlaneReachable, webReachable, listenerReachable] = includeProbe
92
105
  ? await Promise.all([
93
- probeTcp(controlPlane.host, controlPlane.port),
94
- probeTcp(web.host, web.port),
95
- probeTcp(httpListener.host, httpListener.port),
106
+ shouldProbeControlPlane ? probeTcp(controlPlane.host, controlPlane.port) : Promise.resolve(undefined),
107
+ shouldProbeWeb ? probeTcp(web.host, web.port) : Promise.resolve(undefined),
108
+ shouldProbeListener ? probeTcp(httpListener.host, httpListener.port) : Promise.resolve(undefined),
96
109
  ])
97
110
  : [undefined, undefined, undefined];
98
111
  const externalSurfaces = SURFACE_CONFIGS.map(([id, label, requiredKeys]) => {
99
112
  const enabled = config.get(`surfaces.${id}.enabled` as ConfigKey);
100
113
  const missing = requiredKeys.filter((key) => !isPresentConfigValue(config.get(key as ConfigKey)));
114
+ const missingFeatureFlags = enabled === true ? getMissingSurfaceFeatureFlags(config, id) : [];
101
115
  return {
102
116
  id,
103
117
  label,
104
118
  enabled,
105
- ready: !enabled || missing.length === 0,
119
+ ready: !enabled || (missing.length === 0 && missingFeatureFlags.length === 0),
106
120
  missing,
121
+ missingFeatureFlags,
107
122
  };
108
123
  });
109
124
  const filteredSurfaces = target ? externalSurfaces.filter((surface) => surface.id === target) : externalSurfaces;
110
125
  if (target && filteredSurfaces.length === 0) return { output: `Unknown surface: ${target}`, exitCode: 1 };
126
+ const ntfyTopics = resolveGoodVibesNtfyTopics({
127
+ chatTopic: String(config.get('surfaces.ntfy.chatTopic' as ConfigKey) || GOODVIBES_NTFY_CHAT_TOPIC),
128
+ agentTopic: String(config.get('surfaces.ntfy.agentTopic' as ConfigKey) || GOODVIBES_NTFY_AGENT_TOPIC),
129
+ remoteTopic: String(config.get('surfaces.ntfy.remoteTopic' as ConfigKey) || GOODVIBES_NTFY_REMOTE_TOPIC),
130
+ });
111
131
  const readinessIssues: string[] = [];
112
- if (includeProbe && config.get('controlPlane.enabled') === true && !controlPlaneReachable) {
132
+ if (shouldProbeControlPlane && config.get('controlPlane.enabled') === true && !controlPlaneReachable) {
113
133
  readinessIssues.push(`Control plane is enabled but not reachable on ${controlPlane.host}:${controlPlane.port}.`);
114
134
  }
115
- if (includeProbe && config.get('web.enabled') === true && !webReachable) {
135
+ if (shouldProbeWeb && config.get('web.enabled') === true && !webReachable) {
116
136
  readinessIssues.push(`Web surface is enabled but not reachable on ${web.host}:${web.port}.`);
117
137
  }
118
- if (includeProbe && config.get('danger.httpListener') === true && !listenerReachable) {
138
+ if (shouldProbeListener && config.get('danger.httpListener') === true && !listenerReachable) {
119
139
  readinessIssues.push(`HTTP listener is enabled but not reachable on ${httpListener.host}:${httpListener.port}.`);
120
140
  }
121
141
  for (const surface of filteredSurfaces) {
@@ -126,6 +146,9 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
126
146
  if (surface.missing.length > 0) {
127
147
  readinessIssues.push(`${surface.label} is enabled but missing ${surface.missing.join(', ')}.`);
128
148
  }
149
+ if (surface.missingFeatureFlags.length > 0) {
150
+ readinessIssues.push(`${surface.label} is enabled but feature gates are disabled: ${surface.missingFeatureFlags.join(', ')}.`);
151
+ }
129
152
  }
130
153
  const value = {
131
154
  controlPlane: {
@@ -162,7 +185,15 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
162
185
  ` http-listener: ${yesNo(value.httpListener.enabled)} (${value.httpListener.hostMode} ${value.httpListener.host}:${value.httpListener.port})${includeProbe ? ` reachable=${yesNo(value.httpListener.reachable)}` : ''}`,
163
186
  '',
164
187
  'External surfaces:',
165
- ...value.surfaces.map((surface) => ` ${surface.label.padEnd(16)} enabled=${yesNo(surface.enabled)} ready=${yesNo(surface.ready)}${surface.enabled && surface.missing.length > 0 ? ` missing=${surface.missing.join(',')}` : ''}`),
188
+ ...value.surfaces.map((surface) => ` ${surface.label.padEnd(16)} enabled=${yesNo(surface.enabled)} ready=${yesNo(surface.ready)}${surface.enabled && surface.missing.length > 0 ? ` missing=${surface.missing.join(',')}` : ''}${surface.enabled && surface.missingFeatureFlags.length > 0 ? ` featureGates=${surface.missingFeatureFlags.join(',')}` : ''}`),
189
+ ...(filteredSurfaces.some((surface) => surface.id === 'ntfy') ? [
190
+ '',
191
+ 'ntfy inbound topics:',
192
+ ` chat: ${ntfyTopics.chatTopic}`,
193
+ ` agent: ${ntfyTopics.agentTopic}`,
194
+ ` daemon-only remote: ${ntfyTopics.remoteTopic}`,
195
+ ` default delivery topic: ${String(config.get('surfaces.ntfy.topic') || '(none)')}`,
196
+ ] : []),
166
197
  ...(includeProbe ? [
167
198
  readinessIssues.length === 0 ? 'Readiness: ready' : 'Readiness: needs attention',
168
199
  ...readinessIssues.map((issue) => ` - ${issue}`),
@@ -191,6 +222,7 @@ export interface ListenerTestResult {
191
222
  readonly enabled: unknown;
192
223
  readonly ready: boolean;
193
224
  readonly missing: readonly string[];
225
+ readonly missingFeatureFlags: readonly string[];
194
226
  }[];
195
227
  readonly issues: readonly string[];
196
228
  }
@@ -209,12 +241,14 @@ export async function buildListenerTestResult(runtime: CliCommandRuntime): Promi
209
241
  const surfaces = SURFACE_CONFIGS.map(([id, label, requiredKeys]) => {
210
242
  const surfaceEnabled = runtime.configManager.get(`surfaces.${id}.enabled` as ConfigKey);
211
243
  const missing = requiredKeys.filter((key) => !isPresentConfigValue(runtime.configManager.get(key as ConfigKey)));
244
+ const missingFeatureFlags = surfaceEnabled === true ? getMissingSurfaceFeatureFlags(runtime.configManager, id) : [];
212
245
  return {
213
246
  id,
214
247
  label,
215
248
  enabled: surfaceEnabled,
216
- ready: surfaceEnabled !== true || missing.length === 0,
249
+ ready: surfaceEnabled !== true || (missing.length === 0 && missingFeatureFlags.length === 0),
217
250
  missing,
251
+ missingFeatureFlags,
218
252
  };
219
253
  }).filter((surface) => surface.enabled === true);
220
254
  const issues: string[] = [];
@@ -226,6 +260,7 @@ export async function buildListenerTestResult(runtime: CliCommandRuntime): Promi
226
260
  if (isNetworkFacing(enabled, binding) && auth.bootstrapCredentialPresent) issues.push('Network-facing listener still has a bootstrap credential file.');
227
261
  for (const surface of surfaces) {
228
262
  if (surface.missing.length > 0) issues.push(`${surface.label} is enabled but missing ${surface.missing.join(', ')}.`);
263
+ if (surface.missingFeatureFlags.length > 0) issues.push(`${surface.label} is enabled but feature gates are disabled: ${surface.missingFeatureFlags.join(', ')}.`);
229
264
  }
230
265
  return { enabled, ...binding, posture, reachable, service, auth, surfaces, issues };
231
266
  }
@@ -241,7 +276,7 @@ export function formatListenerTestResult(runtime: CliCommandRuntime, value: List
241
276
  ` local auth users: ${value.auth.userStorePresent ? 'present' : 'missing'}`,
242
277
  ` bootstrap credential: ${value.auth.bootstrapCredentialPresent ? 'present' : 'missing'}`,
243
278
  value.surfaces.length === 0 ? ' enabled webhook surfaces: none' : ' enabled webhook surfaces:',
244
- ...value.surfaces.map((surface) => ` ${surface.label}: ready=${yesNo(surface.ready)}${surface.missing.length > 0 ? ` missing=${surface.missing.join(',')}` : ''}`),
279
+ ...value.surfaces.map((surface) => ` ${surface.label}: ready=${yesNo(surface.ready)}${surface.missing.length > 0 ? ` missing=${surface.missing.join(',')}` : ''}${surface.missingFeatureFlags.length > 0 ? ` featureGates=${surface.missingFeatureFlags.join(',')}` : ''}`),
245
280
  value.issues.length === 0 ? ' readiness: ready' : ' readiness: needs attention',
246
281
  ...value.issues.map((issue) => ` - ${issue}`),
247
282
  ].join('\n'));
package/src/daemon/cli.ts CHANGED
@@ -3,6 +3,8 @@ import { join } from 'node:path';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
5
5
  import { RuntimeEventBus } from '@pellux/goodvibes-sdk/platform/runtime/events/index';
6
+ import { createFeatureFlagManager } from '@pellux/goodvibes-sdk/platform/runtime/feature-flags/index';
7
+ import type { FlagState } from '@pellux/goodvibes-sdk/platform/runtime/feature-flags/types';
6
8
  import { createRuntimeStore } from '../runtime/store/index.ts';
7
9
  import { createRuntimeServices } from '../runtime/services.ts';
8
10
  import { DaemonServer } from '@pellux/goodvibes-sdk/platform/daemon/server';
@@ -155,8 +157,13 @@ async function main(): Promise<void> {
155
157
  }
156
158
  const runtimeBus = new RuntimeEventBus();
157
159
  const runtimeStore = createRuntimeStore();
160
+ const featureFlags = createFeatureFlagManager();
161
+ featureFlags.loadFromConfig({
162
+ flags: (config.getCategory('featureFlags') as Record<string, FlagState>) ?? {},
163
+ });
158
164
  const runtimeServices = createRuntimeServices({
159
165
  configManager: config,
166
+ featureFlags,
160
167
  runtimeBus,
161
168
  runtimeStore,
162
169
  getConversationTitle: () => 'goodvibes daemon',
@@ -2,7 +2,7 @@ import { createOAuthLocalListener } from '@pellux/goodvibes-sdk/platform/config/
2
2
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config/openai-codex-auth';
3
3
  import { openExternalUrl } from '@pellux/goodvibes-sdk/platform/utils/open-external';
4
4
  import { buildProviderAccountSnapshot } from '@pellux/goodvibes-sdk/platform/runtime/provider-accounts/registry';
5
- import { OnboardingWizardController, type OnboardingWizardAction } from './onboarding/onboarding-wizard.ts';
5
+ import { OnboardingWizardController, type OnboardingWizardAction, type OnboardingWizardApplyFeedback } from './onboarding/onboarding-wizard.ts';
6
6
  import { applyOnboardingRequest, collectOnboardingSnapshot, verifyOnboardingRequest } from '../runtime/onboarding/index.ts';
7
7
  import type { OnboardingApplyRequest, OnboardingVerificationItem } from '../runtime/onboarding/index.ts';
8
8
  import type { ModelPickerTarget } from './model-picker.ts';
@@ -19,6 +19,15 @@ export interface OnboardingRuntimePosture {
19
19
  readonly remoteExposure: boolean;
20
20
  }
21
21
 
22
+ interface OnboardingExternalServiceState {
23
+ readonly daemonRunning?: boolean;
24
+ readonly daemonPortInUse?: boolean;
25
+ readonly httpListenerRunning?: boolean;
26
+ readonly httpListenerPortInUse?: boolean;
27
+ }
28
+
29
+ type OnboardingRuntimeEndpoint = 'daemon' | 'httpListener';
30
+
22
31
  function extractAuthorizationCode(input: string): string | null {
23
32
  const trimmed = input.trim();
24
33
  if (!trimmed) return null;
@@ -41,6 +50,101 @@ function isLoopbackHostValue(value: string | null | undefined): boolean {
41
50
  || /^127(?:\.\d{1,3}){3}$/.test(normalized);
42
51
  }
43
52
 
53
+ function onboardingVerificationStatusRank(item: OnboardingVerificationItem): number {
54
+ if (item.status === 'fail') return 3;
55
+ if (item.status === 'warn') return 2;
56
+ return 1;
57
+ }
58
+
59
+ function dedupeOnboardingVerificationItems(
60
+ items: readonly OnboardingVerificationItem[],
61
+ ): OnboardingVerificationItem[] {
62
+ const order: string[] = [];
63
+ const byId = new Map<string, OnboardingVerificationItem>();
64
+ for (const item of items) {
65
+ const existing = byId.get(item.id);
66
+ if (!existing) {
67
+ order.push(item.id);
68
+ byId.set(item.id, item);
69
+ continue;
70
+ }
71
+ if (onboardingVerificationStatusRank(item) > onboardingVerificationStatusRank(existing)) {
72
+ byId.set(item.id, item);
73
+ }
74
+ }
75
+ return order.map((id) => byId.get(id)).filter((item): item is OnboardingVerificationItem => Boolean(item));
76
+ }
77
+
78
+ function formatOnboardingApplyCompletionMessage(items: readonly OnboardingVerificationItem[]): string {
79
+ const warnings = items.filter((item) => item.status === 'warn');
80
+ if (warnings.length === 0) return `Onboarding applied and verified ${items.length} item(s).`;
81
+ const passed = items.filter((item) => item.status === 'pass').length;
82
+ return [
83
+ `Onboarding settings applied. ${passed} verification item(s) passed; ${warnings.length} warning(s) need attention.`,
84
+ ...warnings.map((warning) => ` warning ${warning.id}: ${warning.message}`),
85
+ ].join('\n');
86
+ }
87
+
88
+ function getRuntimeEndpointBinding(
89
+ handler: InputHandler,
90
+ request: OnboardingApplyRequest,
91
+ endpoint: OnboardingRuntimeEndpoint,
92
+ ): { readonly label: string; readonly host: string; readonly port: number } {
93
+ const hostKey = endpoint === 'daemon' ? 'controlPlane.host' : 'httpListener.host';
94
+ const portKey = endpoint === 'daemon' ? 'controlPlane.port' : 'httpListener.port';
95
+ const fallbackHost = '127.0.0.1';
96
+ const fallbackPort = endpoint === 'daemon' ? 3421 : 3422;
97
+ const rawHost = handler.getOnboardingConfigValue(request, hostKey);
98
+ const rawPort = handler.getOnboardingConfigValue(request, portKey);
99
+ const parsedPort = typeof rawPort === 'number' ? rawPort : Number(rawPort);
100
+ return {
101
+ label: endpoint === 'daemon' ? 'GoodVibes daemon' : 'HTTP listener',
102
+ host: String(rawHost ?? fallbackHost),
103
+ port: Number.isFinite(parsedPort) ? parsedPort : fallbackPort,
104
+ };
105
+ }
106
+
107
+ function runtimePortDiagnostic(
108
+ binding: { readonly label: string; readonly host: string; readonly port: number },
109
+ portInUse: boolean | undefined,
110
+ ): string {
111
+ if (portInUse) {
112
+ return `The configured port ${binding.host}:${binding.port} is occupied after restart; another GoodVibes process, an overlapping restart, or another service may still own it.`;
113
+ }
114
+ return `No process is listening on ${binding.host}:${binding.port} after restart.`;
115
+ }
116
+
117
+ function formatRuntimeActiveFailureMessage(
118
+ handler: InputHandler,
119
+ request: OnboardingApplyRequest,
120
+ endpoint: OnboardingRuntimeEndpoint,
121
+ state: OnboardingExternalServiceState | undefined,
122
+ ): string {
123
+ const binding = getRuntimeEndpointBinding(handler, request, endpoint);
124
+ const portInUse = endpoint === 'daemon' ? state?.daemonPortInUse : state?.httpListenerPortInUse;
125
+ const impact = endpoint === 'daemon'
126
+ ? 'browser, LAN, and service-backed GoodVibes surfaces may be unavailable until the daemon is running there.'
127
+ : 'incoming webhooks and event surfaces will not receive traffic until the listener is running there.';
128
+ return `${binding.label} is enabled for ${binding.host}:${binding.port}, but onboarding could not confirm it is running in this TUI instance after restart. ${runtimePortDiagnostic(binding, portInUse)} Settings were saved; ${impact}`;
129
+ }
130
+
131
+ function formatRuntimeStoppedFailureMessage(
132
+ handler: InputHandler,
133
+ request: OnboardingApplyRequest,
134
+ endpoint: OnboardingRuntimeEndpoint,
135
+ ): string {
136
+ const binding = getRuntimeEndpointBinding(handler, request, endpoint);
137
+ const disabledSurface = endpoint === 'daemon' ? 'server-backed surfaces' : 'incoming event surfaces';
138
+ return `${binding.label} was disabled for ${disabledSurface}, but ${binding.host}:${binding.port} is still occupied. Settings were saved; another GoodVibes process or external service may still be running on that port.`;
139
+ }
140
+
141
+ function showOnboardingApplyFeedbackForHandler(handler: InputHandler, feedback: OnboardingWizardApplyFeedback): void {
142
+ handler.onboardingWizard.setApplyFeedback(feedback);
143
+ const reviewIndex = handler.onboardingWizard.steps.findIndex((step) => step.id === 'review');
144
+ if (reviewIndex >= 0) handler.onboardingWizard.setStep(reviewIndex);
145
+ handler.requestRender();
146
+ }
147
+
44
148
  export function clearOnboardingPendingModelPickerTargetForHandler(handler: InputHandler): void {
45
149
  handler.onboardingWizard.clearPendingModelPickerTarget();
46
150
  }
@@ -115,15 +219,17 @@ export async function handleOnboardingActionForHandler(handler: InputHandler, ac
115
219
  if (handler.onboardingApplyPending) return;
116
220
  const blockers = handler.onboardingWizard.getBlockingFieldLabels();
117
221
  if (blockers.length > 0) {
118
- handler.commandContext?.print?.([
119
- 'Onboarding needs these fields before applying.',
120
- ...blockers.map((label) => ` ${label}`),
121
- ].join('\n'));
122
- handler.requestRender();
222
+ showOnboardingApplyFeedbackForHandler(handler, {
223
+ severity: 'error',
224
+ title: 'Cannot apply yet',
225
+ summary: 'Fix these required or invalid fields, then apply again.',
226
+ messages: blockers,
227
+ });
123
228
  return;
124
229
  }
125
230
 
126
231
  const request = handler.onboardingWizard.buildApplyRequest();
232
+ handler.onboardingWizard.clearApplyFeedback();
127
233
  const deps = {
128
234
  config: handler.uiServices.platform.configManager,
129
235
  secrets: handler.uiServices.platform.secretsManager,
@@ -133,43 +239,48 @@ export async function handleOnboardingActionForHandler(handler: InputHandler, ac
133
239
  };
134
240
  let appliedErrors: string[] = [];
135
241
  let verificationItems: readonly OnboardingVerificationItem[] = [];
242
+ let runtimeWarnings: readonly OnboardingVerificationItem[] = [];
136
243
  handler.onboardingApplyPending = true;
137
244
  try {
138
245
  const applied = await applyOnboardingRequest(deps, request);
139
- const verification = await verifyOnboardingRequest(deps, request);
140
- verificationItems = verification.items;
141
- appliedErrors = [
142
- ...applied.errors.map((error) => `apply ${error.kind}: ${error.message}`),
143
- ...verification.items
144
- .filter((item) => item.status !== 'pass')
145
- .map((item) => `verify ${item.id}: ${item.message}`),
146
- ];
246
+ if (applied.errors.length > 0) {
247
+ appliedErrors = applied.errors.map((error) => `apply ${error.kind}: ${error.message}`);
248
+ } else {
249
+ const verification = await verifyOnboardingRequest(deps, request);
250
+ verificationItems = verification.items;
251
+ appliedErrors = verification.items
252
+ .filter((item) => item.status === 'fail')
253
+ .map((item) => `verify ${item.id}: ${item.message}`);
254
+ }
147
255
 
148
256
  if (appliedErrors.length === 0) {
149
257
  const activationVerification = await handler.restartOnboardingExternalServicesIfNeeded(request);
150
- const runtimeVerification = [...activationVerification, ...handler.verifyOnboardingRuntimePosture(request)];
151
- verificationItems = [...verification.items, ...runtimeVerification];
152
- appliedErrors = runtimeVerification
153
- .filter((item) => item.status === 'fail')
154
- .map((item) => `verify ${item.id}: ${item.message}`);
258
+ runtimeWarnings = dedupeOnboardingVerificationItems([...activationVerification, ...handler.verifyOnboardingRuntimePosture(request)]
259
+ .map((item): OnboardingVerificationItem => item.status === 'fail'
260
+ ? { ...item, status: 'warn' }
261
+ : item));
262
+ verificationItems = dedupeOnboardingVerificationItems([...verificationItems, ...runtimeWarnings]);
155
263
  }
156
264
  } catch (error) {
157
- handler.commandContext?.print?.([
158
- 'Onboarding apply did not complete.',
159
- ` ${error instanceof Error ? error.message : String(error)}`,
160
- ].join('\n'));
161
- handler.requestRender();
265
+ showOnboardingApplyFeedbackForHandler(handler, {
266
+ severity: 'error',
267
+ title: 'Apply failed',
268
+ summary: 'The wizard could not persist these settings. No service restart was attempted.',
269
+ messages: [error instanceof Error ? error.message : String(error)],
270
+ });
162
271
  return;
163
272
  } finally {
164
273
  handler.onboardingApplyPending = false;
274
+ handler.requestRender();
165
275
  }
166
276
 
167
277
  if (appliedErrors.length > 0) {
168
- handler.commandContext?.print?.([
169
- 'Onboarding apply did not complete.',
170
- ...appliedErrors.map((error) => ` ${error}`),
171
- ].join('\n'));
172
- handler.requestRender();
278
+ showOnboardingApplyFeedbackForHandler(handler, {
279
+ severity: 'error',
280
+ title: 'Apply did not complete',
281
+ summary: 'The settings were not fully applied. Review the messages below and try again.',
282
+ messages: appliedErrors,
283
+ });
173
284
  return;
174
285
  }
175
286
 
@@ -185,11 +296,7 @@ export async function handleOnboardingActionForHandler(handler: InputHandler, ac
185
296
  handler.indicatorFocused = returnFocus === 'indicator';
186
297
  handler.modalReturnFocus = 'prompt';
187
298
  }
188
- const warnings = verificationItems.filter((item) => item.status === 'warn');
189
- handler.commandContext?.print?.([
190
- `Onboarding applied and verified ${verificationItems.length} item(s).`,
191
- ...warnings.map((warning) => ` warning ${warning.id}: ${warning.message}`),
192
- ].join('\n'));
299
+ handler.commandContext?.print?.(formatOnboardingApplyCompletionMessage(verificationItems));
193
300
  handler.requestRender();
194
301
  }
195
302
 
@@ -472,7 +579,7 @@ export async function restartOnboardingExternalServicesIfNeededForHandler(handle
472
579
  failures.push({
473
580
  id: 'runtime:daemon-active',
474
581
  status: 'fail',
475
- message: 'The GoodVibes daemon did not start after applying onboarding settings.',
582
+ message: formatRuntimeActiveFailureMessage(handler, request, 'daemon', state),
476
583
  target: 'service',
477
584
  });
478
585
  }
@@ -480,7 +587,7 @@ export async function restartOnboardingExternalServicesIfNeededForHandler(handle
480
587
  failures.push({
481
588
  id: 'runtime:daemon-stopped',
482
589
  status: 'fail',
483
- message: 'The GoodVibes daemon port is still occupied after onboarding disabled server-backed surfaces.',
590
+ message: formatRuntimeStoppedFailureMessage(handler, request, 'daemon'),
484
591
  target: 'service',
485
592
  });
486
593
  }
@@ -488,7 +595,7 @@ export async function restartOnboardingExternalServicesIfNeededForHandler(handle
488
595
  failures.push({
489
596
  id: 'runtime:http-listener-active',
490
597
  status: 'fail',
491
- message: 'The HTTP listener did not start after applying onboarding settings.',
598
+ message: formatRuntimeActiveFailureMessage(handler, request, 'httpListener', state),
492
599
  target: 'service',
493
600
  });
494
601
  }
@@ -496,7 +603,7 @@ export async function restartOnboardingExternalServicesIfNeededForHandler(handle
496
603
  failures.push({
497
604
  id: 'runtime:http-listener-stopped',
498
605
  status: 'fail',
499
- message: 'The HTTP listener port is still occupied after onboarding disabled incoming event surfaces.',
606
+ message: formatRuntimeStoppedFailureMessage(handler, request, 'httpListener'),
500
607
  target: 'service',
501
608
  });
502
609
  }
@@ -537,7 +644,7 @@ export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler,
537
644
  stoppedItems.push({
538
645
  id: 'runtime:daemon-stopped',
539
646
  status: 'fail',
540
- message: 'The GoodVibes daemon port is still occupied after onboarding disabled server-backed surfaces.',
647
+ message: formatRuntimeStoppedFailureMessage(handler, request, 'daemon'),
541
648
  target: 'service',
542
649
  });
543
650
  }
@@ -545,7 +652,7 @@ export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler,
545
652
  stoppedItems.push({
546
653
  id: 'runtime:http-listener-stopped',
547
654
  status: 'fail',
548
- message: 'The HTTP listener port is still occupied after onboarding disabled incoming event surfaces.',
655
+ message: formatRuntimeStoppedFailureMessage(handler, request, 'httpListener'),
549
656
  target: 'service',
550
657
  });
551
658
  }
@@ -597,7 +704,7 @@ export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler,
597
704
  status: externalState?.daemonRunning ? 'pass' : 'fail',
598
705
  message: externalState?.daemonRunning
599
706
  ? 'The GoodVibes daemon is running with the applied onboarding settings.'
600
- : 'The GoodVibes daemon is not running after onboarding apply.',
707
+ : formatRuntimeActiveFailureMessage(handler, request, 'daemon', externalState),
601
708
  target: 'service',
602
709
  });
603
710
  }
@@ -605,7 +712,7 @@ export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler,
605
712
  items.push({
606
713
  id: 'runtime:daemon-stopped',
607
714
  status: 'fail',
608
- message: 'The GoodVibes daemon port is still occupied after onboarding disabled server-backed surfaces.',
715
+ message: formatRuntimeStoppedFailureMessage(handler, request, 'daemon'),
609
716
  target: 'service',
610
717
  });
611
718
  }
@@ -615,7 +722,7 @@ export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler,
615
722
  status: externalState?.httpListenerRunning ? 'pass' : 'fail',
616
723
  message: externalState?.httpListenerRunning
617
724
  ? 'The HTTP listener is running with the applied onboarding settings.'
618
- : 'The HTTP listener is not running after onboarding apply.',
725
+ : formatRuntimeActiveFailureMessage(handler, request, 'httpListener', externalState),
619
726
  target: 'service',
620
727
  });
621
728
  }
@@ -623,7 +730,7 @@ export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler,
623
730
  items.push({
624
731
  id: 'runtime:http-listener-stopped',
625
732
  status: 'fail',
626
- message: 'The HTTP listener port is still occupied after onboarding disabled incoming event surfaces.',
733
+ message: formatRuntimeStoppedFailureMessage(handler, request, 'httpListener'),
627
734
  target: 'service',
628
735
  });
629
736
  }
@@ -64,6 +64,8 @@ export function handleOnboardingWizardToken(state: OnboardingRouteState, token:
64
64
  if (editing) {
65
65
  if (isEnterKey(token)) {
66
66
  state.onboardingWizard.commitEdit();
67
+ } else if ((token.ctrl && token.logicalName === 'u') || token.logicalName === 'delete') {
68
+ state.onboardingWizard.clearEditingValue();
67
69
  } else if (token.logicalName === 'backspace') {
68
70
  state.onboardingWizard.editBackspace();
69
71
  } else {
@@ -97,6 +99,8 @@ export function handleOnboardingWizardToken(state: OnboardingRouteState, token:
97
99
  }
98
100
  if (isEnterKey(token) || token.logicalName === 'space') {
99
101
  activateSelection(state);
102
+ } else if ((token.ctrl && token.logicalName === 'u') || token.logicalName === 'delete') {
103
+ state.onboardingWizard.clearSelectedTextField();
100
104
  } else if (token.logicalName === 'backspace') {
101
105
  state.onboardingWizard.editBackspace();
102
106
  }