@pellux/goodvibes-tui 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/cli/completions/generate.ts +4 -8
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/parser.ts +17 -0
- package/src/cli/types.ts +2 -0
- package/src/config/goodvibes-home-audit.ts +2 -0
- package/src/core/context-auto-compact.ts +77 -0
- package/src/core/turn-event-wiring.ts +124 -0
- package/src/daemon/cli.ts +5 -0
- package/src/input/command-registry.ts +1 -0
- package/src/input/commands/control-room-runtime.ts +5 -5
- package/src/input/commands/provider.ts +57 -3
- package/src/input/commands/session-workflow.ts +8 -16
- package/src/input/commands/session.ts +70 -20
- package/src/input/commands.ts +0 -2
- package/src/input/handler-modal-routes.ts +37 -0
- package/src/input/handler-modal-token-routes.ts +19 -5
- package/src/input/handler-onboarding.ts +18 -0
- package/src/input/handler.ts +1 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
- package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
- package/src/input/settings-modal-behavior.ts +5 -0
- package/src/input/settings-modal-data.ts +77 -3
- package/src/input/settings-modal-mutations.ts +3 -0
- package/src/input/settings-modal-reset.ts +154 -0
- package/src/input/settings-modal.ts +55 -13
- package/src/main.ts +36 -28
- package/src/panels/agent-inspector-panel.ts +120 -18
- package/src/panels/agent-inspector-shared.ts +29 -0
- package/src/panels/builtin/development.ts +1 -0
- package/src/panels/builtin/knowledge.ts +14 -13
- package/src/panels/builtin/operations.ts +22 -1
- package/src/panels/builtin/shared.ts +7 -0
- package/src/panels/cockpit-panel.ts +123 -3
- package/src/panels/cockpit-read-model.ts +232 -0
- package/src/panels/index.ts +1 -1
- package/src/panels/knowledge-graph-panel.ts +84 -0
- package/src/panels/memory-panel.ts +370 -40
- package/src/panels/session-maintenance.ts +66 -15
- package/src/renderer/agent-detail-modal.ts +107 -3
- package/src/renderer/context-status-hint.ts +54 -0
- package/src/renderer/settings-modal.ts +14 -3
- package/src/renderer/shell-surface.ts +10 -0
- package/src/runtime/bootstrap-command-parts.ts +4 -0
- package/src/runtime/bootstrap-core.ts +24 -0
- package/src/runtime/bootstrap-shell.ts +11 -0
- package/src/runtime/bootstrap.ts +7 -0
- package/src/runtime/services.ts +6 -1
- package/src/version.ts +1 -1
- package/src/panels/knowledge-panel.ts +0 -343
|
@@ -319,18 +319,52 @@ function handleCancel(args: string[], context: CommandContext): void {
|
|
|
319
319
|
/**
|
|
320
320
|
* sessionCommand — The `/session` slash command.
|
|
321
321
|
*
|
|
322
|
-
*
|
|
322
|
+
* The ONE front-door for all session operations. Owns two domains:
|
|
323
|
+
*
|
|
324
|
+
* Lifecycle (continuity, export, resume, pruning):
|
|
325
|
+
* list | rename | resume | fork | save | info | events | groups | hotspots | export | search | delete
|
|
326
|
+
*
|
|
327
|
+
* Orchestration (cross-session task DAG — 40 tests, cycle detection):
|
|
328
|
+
* link-task | handoff | graph | cancel
|
|
329
|
+
*
|
|
330
|
+
* Orchestration-command decision (TASK-032):
|
|
331
|
+
* Both domains live under /session rather than splitting orchestration into
|
|
332
|
+
* a separate /session-orch command. Rationale: they share the same entity
|
|
333
|
+
* (a session) and the same operator mental model ("I am working with sessions").
|
|
334
|
+
* A second front-door would create ambiguity about which command to reach for.
|
|
335
|
+
* Explicit switch routing (not fallthrough) makes both domains first-class;
|
|
336
|
+
* the former /session-mgmt alias (session-mgmt/smgmt) is removed so there
|
|
337
|
+
* is exactly one registration and no silent shadowing.
|
|
323
338
|
*/
|
|
324
339
|
export const sessionCommand: SlashCommand = {
|
|
325
340
|
name: 'session',
|
|
326
341
|
aliases: ['sess'],
|
|
327
|
-
description: '
|
|
342
|
+
description: 'Session lifecycle and orchestration: list, resume, fork, save, export, link-task, handoff, graph, cancel.',
|
|
328
343
|
usage: '<subcommand> [args]',
|
|
329
|
-
argsHint: 'link-task|handoff|graph|cancel',
|
|
344
|
+
argsHint: 'list|rename|resume|fork|save|info|export|search|delete|events|groups|hotspots|link-task|handoff|graph|cancel',
|
|
330
345
|
handler: async (args: string[], context: CommandContext): Promise<void> => {
|
|
331
346
|
const [sub, ...rest] = args;
|
|
332
347
|
|
|
333
348
|
switch (sub) {
|
|
349
|
+
// ── Lifecycle subcommands ────────────────────────────────────────────────
|
|
350
|
+
// Each delegates explicitly to handleSessionWorkflowCommand so every
|
|
351
|
+
// subcommand has a deterministic, named path — no silent fallthrough.
|
|
352
|
+
case 'list':
|
|
353
|
+
case 'rename':
|
|
354
|
+
case 'resume':
|
|
355
|
+
case 'fork':
|
|
356
|
+
case 'save':
|
|
357
|
+
case 'info':
|
|
358
|
+
case 'export':
|
|
359
|
+
case 'search':
|
|
360
|
+
case 'delete':
|
|
361
|
+
case 'events':
|
|
362
|
+
case 'groups':
|
|
363
|
+
case 'hotspots':
|
|
364
|
+
await handleSessionWorkflowCommand(args, context);
|
|
365
|
+
break;
|
|
366
|
+
|
|
367
|
+
// ── Orchestration subcommands ─────────────────────────────────────────────
|
|
334
368
|
case 'link-task':
|
|
335
369
|
case 'link':
|
|
336
370
|
handleLinkTask(rest, context);
|
|
@@ -350,24 +384,40 @@ export const sessionCommand: SlashCommand = {
|
|
|
350
384
|
handleCancel(rest, context);
|
|
351
385
|
break;
|
|
352
386
|
|
|
387
|
+
// ── No-arg: show current session info ────────────────────────────────────
|
|
388
|
+
case undefined:
|
|
389
|
+
await handleSessionWorkflowCommand([], context);
|
|
390
|
+
break;
|
|
391
|
+
|
|
353
392
|
default: {
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
]
|
|
369
|
-
|
|
370
|
-
|
|
393
|
+
const usage = [
|
|
394
|
+
'Usage: /session <subcommand>',
|
|
395
|
+
'',
|
|
396
|
+
'Lifecycle:',
|
|
397
|
+
' list — List saved sessions',
|
|
398
|
+
' rename <name> — Rename the current session',
|
|
399
|
+
' resume <id|name> — Resume a saved session',
|
|
400
|
+
' fork [name] — Fork the current session',
|
|
401
|
+
' save [name] — Save the current session',
|
|
402
|
+
' info [id] — Show session info',
|
|
403
|
+
' export <id|.> [markdown|text] — Export session transcript',
|
|
404
|
+
' search <query> — Search session content',
|
|
405
|
+
' delete <id> — Delete a saved session',
|
|
406
|
+
' events [kind] — Show transcript events',
|
|
407
|
+
' groups [kind] — Show transcript groups',
|
|
408
|
+
' hotspots — Show transcript hotspots',
|
|
409
|
+
'',
|
|
410
|
+
'Orchestration:',
|
|
411
|
+
' link-task <taskId> [--session <sid>] [--depends-on <sid:taskId>] [--label <label>]',
|
|
412
|
+
' — Register a task in the cross-session graph',
|
|
413
|
+
' handoff <taskId> --to <sid> [--session <sid>] [--reason <reason>]',
|
|
414
|
+
' — Hand a task off to another session',
|
|
415
|
+
' graph [--session <sid>] [--format text|json]',
|
|
416
|
+
' — Display the cross-session task dependency graph',
|
|
417
|
+
' cancel <taskId> [--scope task|subtree|session] [--session <sid>] [--reason <reason>]',
|
|
418
|
+
' — Cancel tasks with scoped semantics',
|
|
419
|
+
].join('\n');
|
|
420
|
+
context.print(usage);
|
|
371
421
|
break;
|
|
372
422
|
}
|
|
373
423
|
}
|
package/src/input/commands.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { recallCommand } from './commands/memory.ts';
|
|
|
7
7
|
import { knowledgeCommand } from './commands/knowledge.ts';
|
|
8
8
|
import { registerShellCoreCommands } from './commands/shell-core.ts';
|
|
9
9
|
import { registerConfigCommand } from './commands/config.ts';
|
|
10
|
-
import { registerSessionWorkflowCommands } from './commands/session-workflow.ts';
|
|
11
10
|
import { registerDiscoveryRuntimeCommands } from './commands/discovery-runtime.ts';
|
|
12
11
|
import { registerPlanningRuntimeCommands } from './commands/planning-runtime.ts';
|
|
13
12
|
import { registerScheduleRuntimeCommands } from './commands/schedule-runtime.ts';
|
|
@@ -107,7 +106,6 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
|
|
|
107
106
|
registerCloudflareRuntimeCommands(registry);
|
|
108
107
|
registerWorkPlanRuntimeCommands(registry);
|
|
109
108
|
registerLocalRuntimeCommands(registry);
|
|
110
|
-
registerSessionWorkflowCommands(registry);
|
|
111
109
|
registerDiscoveryRuntimeCommands(registry);
|
|
112
110
|
registerPlanningRuntimeCommands(registry);
|
|
113
111
|
registerScheduleRuntimeCommands(registry);
|
|
@@ -250,6 +250,15 @@ type SettingsRouteState = {
|
|
|
250
250
|
pendingProviderModelPickerTarget?: import('./model-picker.ts').ModelPickerTarget | null;
|
|
251
251
|
pendingSettingsPickerAction?: 'tts-provider' | 'tts-voice' | null;
|
|
252
252
|
resetSelected?: () => { key: string; value: unknown } | null;
|
|
253
|
+
initiateResetCategory?: () => void;
|
|
254
|
+
initiateResetAll?: () => void;
|
|
255
|
+
handleResetConfirmKey?: (
|
|
256
|
+
key: string,
|
|
257
|
+
) =>
|
|
258
|
+
| { result: 'confirmed'; entries: ReadonlyArray<{ key: string; value: unknown }> }
|
|
259
|
+
| 'cancelled'
|
|
260
|
+
| 'absorbed'
|
|
261
|
+
| 'inactive';
|
|
253
262
|
};
|
|
254
263
|
commandContext?: CommandContext;
|
|
255
264
|
/** Called when the settings modal requests the model picker for a non-main target. */
|
|
@@ -313,6 +322,28 @@ export function handleSettingsModalToken(state: SettingsRouteState, token: Input
|
|
|
313
322
|
}
|
|
314
323
|
}
|
|
315
324
|
|
|
325
|
+
// Reset confirm gate: routes all keys through the confirm contract before
|
|
326
|
+
// normal dispatch when a category or all-settings reset is pending.
|
|
327
|
+
if (state.settingsModal.handleResetConfirmKey) {
|
|
328
|
+
const key = token.type === 'key'
|
|
329
|
+
? (token.logicalName ?? '')
|
|
330
|
+
: token.type === 'text'
|
|
331
|
+
? token.value
|
|
332
|
+
: '';
|
|
333
|
+
const resetResult = state.settingsModal.handleResetConfirmKey(key);
|
|
334
|
+
if (resetResult !== 'inactive') {
|
|
335
|
+
if (typeof resetResult === 'object' && resetResult.result === 'confirmed') {
|
|
336
|
+
// Sync runtime for every reset entry so provider.model / reasoningEffort
|
|
337
|
+
// stay consistent with the live session without requiring a restart.
|
|
338
|
+
for (const entry of resetResult.entries) {
|
|
339
|
+
syncRuntimeAfterSettingReset(state.commandContext, entry.key, entry.value);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
state.requestRender();
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
316
347
|
if (token.type === 'key') {
|
|
317
348
|
const focusPane = state.settingsModal.focusPane ?? 'settings';
|
|
318
349
|
if (token.logicalName === 'escape') {
|
|
@@ -360,6 +391,12 @@ export function handleSettingsModalToken(state: SettingsRouteState, token: Input
|
|
|
360
391
|
} else if (state.settingsModal.moveFocusedDown) state.settingsModal.moveFocusedDown();
|
|
361
392
|
else state.settingsModal.moveDown?.();
|
|
362
393
|
}
|
|
394
|
+
else if (token.logicalName === 'r' && token.shift && token.ctrl && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
|
|
395
|
+
state.settingsModal.initiateResetAll?.();
|
|
396
|
+
}
|
|
397
|
+
else if (token.logicalName === 'r' && token.shift && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
|
|
398
|
+
state.settingsModal.initiateResetCategory?.();
|
|
399
|
+
}
|
|
363
400
|
else if (token.logicalName === 'r' && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
|
|
364
401
|
const reset = state.settingsModal.resetSelected?.();
|
|
365
402
|
if (reset) syncRuntimeAfterSettingReset(state.commandContext, reset.key, reset.value);
|
|
@@ -232,11 +232,25 @@ export function handleModalTokenRoutes(state: ModalTokenRouteState, token: Input
|
|
|
232
232
|
return withState(state, true);
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
235
|
+
// Agent detail modal: route c + confirm keys before escape-close.
|
|
236
|
+
// handleKey() consumes confirm-flow keys (y, Enter, n, Esc) and the 'c'
|
|
237
|
+
// initiator; unhandled keys (including Esc when no confirm is pending)
|
|
238
|
+
// fall through to escape-close below.
|
|
239
|
+
if (state.agentDetailModal.active) {
|
|
240
|
+
const keyStr: string =
|
|
241
|
+
token.type === 'key' ? (token.logicalName ?? '') :
|
|
242
|
+
token.type === 'text' ? token.value : '';
|
|
243
|
+
if (keyStr && state.agentDetailModal.handleKey(keyStr)) {
|
|
244
|
+
state.requestRender();
|
|
245
|
+
return withState(state, true);
|
|
246
|
+
}
|
|
247
|
+
// 'c' was not consumed (non-cancellable), or any other key.
|
|
248
|
+
// Esc closes the modal; all other keys are absorbed by the active modal.
|
|
249
|
+
if (token.type === 'key' && token.logicalName === 'escape') {
|
|
250
|
+
state.handleEscape();
|
|
251
|
+
return withState(state, true);
|
|
252
|
+
}
|
|
253
|
+
state.requestRender();
|
|
240
254
|
return withState(state, true);
|
|
241
255
|
}
|
|
242
256
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
formatOnboardingApplyCompletionMessage,
|
|
14
14
|
isLoopbackHostValue,
|
|
15
15
|
} from './onboarding/onboarding-verification-helpers.ts';
|
|
16
|
+
import { focusFirstOffendingField, getStepValidationErrors } from './onboarding/onboarding-wizard-validation.ts';
|
|
16
17
|
import type { ModelPickerTarget } from './model-picker.ts';
|
|
17
18
|
import { captureOnboardingWizardSnapshot, restoreOnboardingWizardSnapshot } from './handler-ui-state.ts';
|
|
18
19
|
import type { InputHandlerLike as InputHandler, OnboardingRuntimePosture } from './handler-types.ts';
|
|
@@ -82,6 +83,23 @@ function showOnboardingApplyFeedbackForHandler(handler: InputHandler, feedback:
|
|
|
82
83
|
|
|
83
84
|
function continueOnboardingSection(handler: InputHandler): void {
|
|
84
85
|
handler.onboardingWizard.commitEdit();
|
|
86
|
+
|
|
87
|
+
const step = handler.onboardingWizard.currentStep;
|
|
88
|
+
const { errors, firstOffendingFieldId } = getStepValidationErrors(handler.onboardingWizard, step);
|
|
89
|
+
if (errors.length > 0) {
|
|
90
|
+
handler.onboardingWizard.setApplyFeedback({
|
|
91
|
+
severity: 'error',
|
|
92
|
+
title: 'Required fields missing',
|
|
93
|
+
summary: 'Fill in the required fields below before continuing.',
|
|
94
|
+
messages: errors,
|
|
95
|
+
});
|
|
96
|
+
if (firstOffendingFieldId !== null) {
|
|
97
|
+
focusFirstOffendingField(handler.onboardingWizard, firstOffendingFieldId);
|
|
98
|
+
}
|
|
99
|
+
handler.requestRender();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
handler.onboardingWizard.clearApplyFeedback();
|
|
86
104
|
handler.onboardingWizard.nextStep();
|
|
87
105
|
handler.requestRender();
|
package/src/input/handler.ts
CHANGED
|
@@ -248,6 +248,7 @@ export class InputHandler implements InputHandlerLike {
|
|
|
248
248
|
sessionLogPathResolver: (agentId) => uiServices.environment.shellPaths.resolveProjectPath('tui', 'sessions', `${agentId}.jsonl`),
|
|
249
249
|
// SDK 0.23.0: supply wrfcController so the modal can show constraint data
|
|
250
250
|
wrfcController: uiServices.agents.wrfcController,
|
|
251
|
+
cancelAgent: (agentId: string) => uiServices.agents.agentManager.cancel(agentId),
|
|
251
252
|
});
|
|
252
253
|
this.bookmarkModal = new BookmarkModal(uiServices.shell.bookmarkManager);
|
|
253
254
|
this.sessionPickerModal = new SessionPickerModal(uiServices.sessions.sessionManager);
|
|
@@ -248,6 +248,16 @@ function addCloudflareOperations(
|
|
|
248
248
|
setConfig('cloudflare.maxQueueOpsPerDay', controller.getNumberFieldValue('cloudflare.max-queue-ops-per-day', config?.maxQueueOpsPerDay ?? 10000, 1));
|
|
249
249
|
setConfig('batch.mode', batchMode);
|
|
250
250
|
setConfig('batch.queueBackend', batchMode !== 'off' && components.queues ? 'cloudflare' : 'local');
|
|
251
|
+
// Zero Trust Tunnel auto-enables trustProxy on both services so the
|
|
252
|
+
// login-rate-limiter keys on the real CF-Connecting-IP rather than the tunnel
|
|
253
|
+
// egress address. RESIDUAL RISK: until the SDK validates CF-Connecting-IP
|
|
254
|
+
// against Cloudflare's published IP ranges (SDK handoff Item 5), a client
|
|
255
|
+
// that reaches the listener directly can spoof the header to bypass the
|
|
256
|
+
// per-IP limiter. The wizard surfaces this in the cloudflare step notice.
|
|
257
|
+
if (components.zeroTrustTunnel) {
|
|
258
|
+
setConfig('controlPlane.trustProxy', true);
|
|
259
|
+
setConfig('httpListener.trustProxy', true);
|
|
260
|
+
}
|
|
251
261
|
}
|
|
252
262
|
|
|
253
263
|
export function addNetworkOperations(
|
|
@@ -257,6 +257,19 @@ export function buildCloudflareStep(controller: OnboardingWizardControllerLike):
|
|
|
257
257
|
);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// Trust-proxy notice — shown when Tunnel is selected so the
|
|
261
|
+
// operator sees the security implication before applying.
|
|
262
|
+
const tunnelSelected = enabled && components.zeroTrustTunnel;
|
|
263
|
+
if (tunnelSelected) {
|
|
264
|
+
fields.push({
|
|
265
|
+
kind: 'status',
|
|
266
|
+
id: 'cloudflare.trust-proxy-notice',
|
|
267
|
+
label: 'trustProxy will be enabled for control plane and HTTP listener',
|
|
268
|
+
defaultValue: 'Notice',
|
|
269
|
+
hint: 'Selecting Zero Trust Tunnel auto-writes controlPlane.trustProxy=true and httpListener.trustProxy=true so the login rate-limiter keys on the real client IP (CF-Connecting-IP) rather than the tunnel egress address. RESIDUAL RISK: until the SDK validates CF-Connecting-IP against Cloudflare published IP ranges (handoff Item 5), a client that reaches the listener directly can spoof this header to bypass the per-IP rate-limiter. See docs/deployment-and-services.md for the full risk posture.',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
260
273
|
if (components.zeroTrustAccess) {
|
|
261
274
|
fields.push(
|
|
262
275
|
{
|
|
@@ -463,6 +476,7 @@ export function buildCloudflareStep(controller: OnboardingWizardControllerLike):
|
|
|
463
476
|
`Components: ${enabled ? componentCount : 0} selected`,
|
|
464
477
|
`Token setup: ${enabled ? setupSource : 'not used'}`,
|
|
465
478
|
`Provision on final apply: ${enabled ? controller.getStringFieldValue('cloudflare.provision-on-apply', 'no') : 'no'}`,
|
|
479
|
+
...(enabled && components.zeroTrustTunnel ? ['trustProxy: enabled for control plane and HTTP listener (see security notice)'] : []),
|
|
466
480
|
],
|
|
467
481
|
fields,
|
|
468
482
|
};
|
|
@@ -621,6 +621,12 @@ export function buildNetworkStep(controller: OnboardingWizardControllerLike): On
|
|
|
621
621
|
}
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
if (controlPlaneRemote || listenerEnabled || browserEnabled) { // TLS warn + CORS notice.
|
|
625
|
+
const cpOff = controlPlaneRemote && String(controller.runtimeSnapshot?.config.controlPlane?.tls?.mode ?? 'off') === 'off';
|
|
626
|
+
const hlOff = listenerEnabled && String(controller.runtimeSnapshot?.config.httpListener?.tls?.mode ?? 'off') === 'off';
|
|
627
|
+
if (cpOff || hlOff) { const a = [cpOff ? 'control plane' : '', hlOff ? 'HTTP listener' : ''].filter(Boolean).join(' and '); fields.push({ kind: 'status', id: 'network.tls-warn', label: `TLS off — ${a} transmits plaintext`, defaultValue: 'Warning', hint: `The ${a} is network-reachable but TLS is off. Traffic travels in plaintext. Enable TLS or use a terminating reverse proxy.` }); }
|
|
628
|
+
if (listenerEnabled) { fields.push({ kind: 'status', id: 'network.cors-note', label: 'CORS must be configured manually', defaultValue: 'Info', hint: 'httpListener.enforceCors and httpListener.allowedOrigins are not in ConfigKey union (SDK handoff Item 5). Edit ~/.goodvibes/tui/settings.json to set them, then restart the daemon.' }); }
|
|
629
|
+
}
|
|
624
630
|
return {
|
|
625
631
|
id: 'network',
|
|
626
632
|
title: 'Network setup',
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { normalizeText } from './onboarding-wizard-helpers.ts';
|
|
2
|
+
import type { OnboardingWizardControllerLike } from './onboarding-wizard-types.ts';
|
|
3
|
+
import type { OnboardingWizardFieldDefinition, OnboardingWizardStepDefinition } from './onboarding-wizard-types.ts';
|
|
4
|
+
|
|
5
|
+
export interface WizardStepValidationResult {
|
|
6
|
+
/** Human-readable error strings for each violating field. */
|
|
7
|
+
readonly errors: readonly string[];
|
|
8
|
+
/** ID of the first field that has an error, or null when all pass. */
|
|
9
|
+
readonly firstOffendingFieldId: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate all fields on a single wizard step, checking:
|
|
14
|
+
* - required text / masked fields that are empty
|
|
15
|
+
* - required acknowledgement fields that are unchecked
|
|
16
|
+
* - any general field-level validation errors (via getFieldValidationError)
|
|
17
|
+
*
|
|
18
|
+
* Returns per-field error messages and the first offending field id so the
|
|
19
|
+
* caller can block navigation and jump focus to the first problem.
|
|
20
|
+
*/
|
|
21
|
+
export function getStepValidationErrors(
|
|
22
|
+
controller: OnboardingWizardControllerLike,
|
|
23
|
+
step: OnboardingWizardStepDefinition,
|
|
24
|
+
): WizardStepValidationResult {
|
|
25
|
+
const errors: string[] = [];
|
|
26
|
+
let firstOffendingFieldId: string | null = null;
|
|
27
|
+
|
|
28
|
+
for (const field of step.fields) {
|
|
29
|
+
const error = getFieldError(controller, step, field);
|
|
30
|
+
if (error !== null) {
|
|
31
|
+
errors.push(error);
|
|
32
|
+
if (firstOffendingFieldId === null) firstOffendingFieldId = field.id;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { errors, firstOffendingFieldId };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getFieldError(
|
|
40
|
+
controller: OnboardingWizardControllerLike,
|
|
41
|
+
step: OnboardingWizardStepDefinition,
|
|
42
|
+
field: OnboardingWizardFieldDefinition,
|
|
43
|
+
): string | null {
|
|
44
|
+
// Required acknowledgement not checked
|
|
45
|
+
if (field.kind === 'acknowledgement' && field.required) {
|
|
46
|
+
if (!controller.isFieldSatisfied(field)) {
|
|
47
|
+
return `${step.shortLabel}: ${field.label} must be acknowledged before continuing.`;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Required text / masked field that is empty
|
|
53
|
+
if ((field.kind === 'text' || field.kind === 'masked') && field.required === true) {
|
|
54
|
+
const value = normalizeText(controller.getFieldValue(field) as string);
|
|
55
|
+
if (value.length === 0) {
|
|
56
|
+
return `${step.shortLabel}: ${field.label} is required.`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Delegate all other field-level validation (format errors, port range, etc.)
|
|
61
|
+
return controller.getFieldValidationError(step, field);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Focus the first offending field on the current step by mutating the
|
|
66
|
+
* controller's selectedFieldIndices. The renderer will pick up the change on
|
|
67
|
+
* the next paint cycle.
|
|
68
|
+
*/
|
|
69
|
+
export function focusFirstOffendingField(
|
|
70
|
+
controller: OnboardingWizardControllerLike,
|
|
71
|
+
fieldId: string,
|
|
72
|
+
): void {
|
|
73
|
+
const fields = controller.currentStep.fields;
|
|
74
|
+
const index = fields.findIndex((f) => f.id === fieldId);
|
|
75
|
+
if (index < 0) return;
|
|
76
|
+
controller.selectedFieldIndices[controller.stepIndex] = index;
|
|
77
|
+
}
|
|
@@ -33,5 +33,10 @@ export function getNumericAdjustmentMeta(setting: ConfigSetting): {
|
|
|
33
33
|
if (setting.key === 'wrfc.scoreThreshold') {
|
|
34
34
|
return { step: 0.1, min: 0, max: 10, precision: 1 };
|
|
35
35
|
}
|
|
36
|
+
if ((setting.key as string) === 'tts.speed') {
|
|
37
|
+
// Speed multiplier: 0.1 increments, min 0.1, no hard max (provider-defined).
|
|
38
|
+
// tts.speed is not yet a ConfigKey in the SDK schema; cast required.
|
|
39
|
+
return { step: 0.1, min: 0.1, precision: 1 };
|
|
40
|
+
}
|
|
36
41
|
return { step: 1, precision: 0 };
|
|
37
42
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { CONFIG_SCHEMA, type ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
10
|
-
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
10
|
+
import type { ConfigManager, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
|
|
11
11
|
import { getResolvedSettingLookup } from '@/runtime/index.ts';
|
|
12
12
|
import type { FeatureFlagManager } from '@/runtime/index.ts';
|
|
13
13
|
import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
|
|
@@ -114,9 +114,63 @@ export function buildSettingGroups(
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// Inject the synthetic tts.speed entry into the tts category.
|
|
118
|
+
// tts.speed is not yet a ConfigKey in the SDK schema (pending SDK addition).
|
|
119
|
+
// The entry is surfaced here with an honest description caveat so users can
|
|
120
|
+
// see and understand the setting before the SDK schema catches up.
|
|
121
|
+
if (ttsEntries && !ttsEntries.some((e) => e.setting.key === ('tts.speed' as ConfigKey))) {
|
|
122
|
+
ttsEntries.push(buildTtsSpeedSyntheticEntry(configManager));
|
|
123
|
+
}
|
|
124
|
+
|
|
117
125
|
return groups;
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// TTS_SPEED_DEFAULT — the pending-SDK default for tts.speed
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Pending default for tts.speed. Matches the value the SDK will use once
|
|
134
|
+
* the schema field is added: 1 (normal speed, provider default).
|
|
135
|
+
* Used for the synthetic settings-modal entry and isDefault comparisons.
|
|
136
|
+
*/
|
|
137
|
+
export const TTS_SPEED_DEFAULT = 1;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The synthetic ConfigSetting descriptor for tts.speed.
|
|
141
|
+
* `tts.speed` is not yet a ConfigKey in the SDK schema. This descriptor is
|
|
142
|
+
* TUI-local and is injected into the tts settings group so users can see
|
|
143
|
+
* and interact with the setting before the SDK schema catches up.
|
|
144
|
+
*
|
|
145
|
+
* The key is cast to ConfigKey because ConfigSetting requires it and the SDK
|
|
146
|
+
* will add this key in a future release. The cast is safe: configManager.get
|
|
147
|
+
* returns undefined for unknown keys rather than throwing.
|
|
148
|
+
*/
|
|
149
|
+
export const TTS_SPEED_SYNTHETIC_SETTING: ConfigSetting = {
|
|
150
|
+
key: 'tts.speed' as ConfigKey,
|
|
151
|
+
type: 'number',
|
|
152
|
+
default: TTS_SPEED_DEFAULT,
|
|
153
|
+
description: 'Playback speed multiplier passed to the TTS provider (1.0 = normal). Takes effect immediately via the TUI bridge; SDK schema registration is pending (native typing only).',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the synthetic SettingEntry for tts.speed.
|
|
158
|
+
*
|
|
159
|
+
* Reads the raw value from configManager using a cast key (tts.speed is not
|
|
160
|
+
* yet a valid ConfigKey). If the value is absent or not a positive finite
|
|
161
|
+
* number, falls back to TTS_SPEED_DEFAULT and marks isDefault true.
|
|
162
|
+
*/
|
|
163
|
+
export function buildTtsSpeedSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
|
|
164
|
+
const raw = configManager.get('tts.speed' as ConfigKey);
|
|
165
|
+
const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
|
|
166
|
+
const currentValue: number = isFinite(parsed) && parsed > 0 ? parsed : TTS_SPEED_DEFAULT;
|
|
167
|
+
return {
|
|
168
|
+
setting: TTS_SPEED_SYNTHETIC_SETTING,
|
|
169
|
+
currentValue,
|
|
170
|
+
isDefault: deepEqual(currentValue, TTS_SPEED_DEFAULT),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
120
174
|
// ---------------------------------------------------------------------------
|
|
121
175
|
// buildFlagEntries — snapshot of current feature flag states
|
|
122
176
|
// ---------------------------------------------------------------------------
|
|
@@ -177,13 +231,31 @@ export function buildNetworkFilteredItems(
|
|
|
177
231
|
// refreshEntryValues — re-reads currentValue/isDefault for all loaded entries
|
|
178
232
|
// ---------------------------------------------------------------------------
|
|
179
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Normalize a raw config value for the tts.speed synthetic entry.
|
|
236
|
+
* Returns the raw value if it is a positive finite number, otherwise falls
|
|
237
|
+
* back to TTS_SPEED_DEFAULT. Mirrors the logic in buildTtsSpeedSyntheticEntry.
|
|
238
|
+
*/
|
|
239
|
+
function normalizeTtsSpeedValue(raw: unknown): number {
|
|
240
|
+
const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
|
|
241
|
+
return isFinite(parsed) && parsed > 0 ? parsed : TTS_SPEED_DEFAULT;
|
|
242
|
+
}
|
|
243
|
+
|
|
180
244
|
export function refreshEntryValues(
|
|
181
245
|
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
182
246
|
configManager: ConfigManager,
|
|
183
247
|
): void {
|
|
184
248
|
for (const entries of groups.values()) {
|
|
185
249
|
for (const entry of entries) {
|
|
186
|
-
|
|
250
|
+
const raw = configManager.get(entry.setting.key as ConfigKey);
|
|
251
|
+
// Synthetic entries (e.g. tts.speed) that have no SDK schema key return
|
|
252
|
+
// undefined from configManager. Normalize using the same logic used at
|
|
253
|
+
// construction time so isDefault stays accurate.
|
|
254
|
+
if (entry.setting.key === ('tts.speed' as ConfigKey)) {
|
|
255
|
+
entry.currentValue = normalizeTtsSpeedValue(raw);
|
|
256
|
+
} else {
|
|
257
|
+
entry.currentValue = raw;
|
|
258
|
+
}
|
|
187
259
|
entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
|
|
188
260
|
}
|
|
189
261
|
}
|
|
@@ -201,7 +273,9 @@ export function updateEntryForKey(
|
|
|
201
273
|
for (const entries of groups.values()) {
|
|
202
274
|
const entry = entries.find((candidate) => candidate.setting.key === key);
|
|
203
275
|
if (entry) {
|
|
204
|
-
|
|
276
|
+
const raw = configManager.get(key);
|
|
277
|
+
// Synthetic tts.speed entry: normalize using the same fallback logic.
|
|
278
|
+
entry.currentValue = key === ('tts.speed' as ConfigKey) ? normalizeTtsSpeedValue(raw) : raw;
|
|
205
279
|
entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
|
|
206
280
|
}
|
|
207
281
|
}
|
|
@@ -55,6 +55,9 @@ export function applySettingValue({
|
|
|
55
55
|
refreshGroups: () => void;
|
|
56
56
|
}): ApplyValueResult {
|
|
57
57
|
const previousValue = configManager.get(key);
|
|
58
|
+
// REQUIRES_RESTART: SDK's ConfigSetting has no requiresRestart field yet (see
|
|
59
|
+
// goodvibes-sdk HANDOFF-FROM-TUI-SESSION-20260611.md §Item 8). Until it does,
|
|
60
|
+
// we detect restart-triggering keys by sub-key name heuristic below.
|
|
58
61
|
const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
|
|
59
62
|
|
|
60
63
|
try {
|