@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/src/cli/completions/generate.ts +4 -8
  5. package/src/cli/entrypoint.ts +6 -0
  6. package/src/cli/parser.ts +17 -0
  7. package/src/cli/types.ts +2 -0
  8. package/src/config/goodvibes-home-audit.ts +2 -0
  9. package/src/core/context-auto-compact.ts +77 -0
  10. package/src/core/turn-event-wiring.ts +124 -0
  11. package/src/daemon/cli.ts +5 -0
  12. package/src/input/command-registry.ts +1 -0
  13. package/src/input/commands/control-room-runtime.ts +5 -5
  14. package/src/input/commands/provider.ts +57 -3
  15. package/src/input/commands/session-workflow.ts +8 -16
  16. package/src/input/commands/session.ts +70 -20
  17. package/src/input/commands.ts +0 -2
  18. package/src/input/handler-modal-routes.ts +37 -0
  19. package/src/input/handler-modal-token-routes.ts +19 -5
  20. package/src/input/handler-onboarding.ts +18 -0
  21. package/src/input/handler.ts +1 -0
  22. package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
  23. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
  24. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
  25. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  26. package/src/input/settings-modal-behavior.ts +5 -0
  27. package/src/input/settings-modal-data.ts +77 -3
  28. package/src/input/settings-modal-mutations.ts +3 -0
  29. package/src/input/settings-modal-reset.ts +154 -0
  30. package/src/input/settings-modal.ts +55 -13
  31. package/src/main.ts +36 -28
  32. package/src/panels/agent-inspector-panel.ts +120 -18
  33. package/src/panels/agent-inspector-shared.ts +29 -0
  34. package/src/panels/builtin/development.ts +1 -0
  35. package/src/panels/builtin/knowledge.ts +14 -13
  36. package/src/panels/builtin/operations.ts +22 -1
  37. package/src/panels/builtin/shared.ts +7 -0
  38. package/src/panels/cockpit-panel.ts +123 -3
  39. package/src/panels/cockpit-read-model.ts +232 -0
  40. package/src/panels/index.ts +1 -1
  41. package/src/panels/knowledge-graph-panel.ts +84 -0
  42. package/src/panels/memory-panel.ts +370 -40
  43. package/src/panels/session-maintenance.ts +66 -15
  44. package/src/renderer/agent-detail-modal.ts +107 -3
  45. package/src/renderer/context-status-hint.ts +54 -0
  46. package/src/renderer/settings-modal.ts +14 -3
  47. package/src/renderer/shell-surface.ts +10 -0
  48. package/src/runtime/bootstrap-command-parts.ts +4 -0
  49. package/src/runtime/bootstrap-core.ts +24 -0
  50. package/src/runtime/bootstrap-shell.ts +11 -0
  51. package/src/runtime/bootstrap.ts +7 -0
  52. package/src/runtime/services.ts +6 -1
  53. package/src/version.ts +1 -1
  54. 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
- * Routes to multi-session orchestration subcommand handlers based on args[0].
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: 'Multi-session orchestration: link tasks, handoff, view graph, and cancel across sessions.',
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 handled = await handleSessionWorkflowCommand(args, context);
355
- if (!handled) {
356
- const usage = [
357
- 'Usage: /session <subcommand>',
358
- ' list | rename <name> | resume <id|name> | fork [name] | save [name] | info [id] | export <id> [format] | search <query> | delete <id>',
359
- ' Session continuity, export, resume, and pruning',
360
- ' link-task <taskId> [--session <sid>] [--depends-on <sid:taskId>] [--label <label>]',
361
- ' Register a task in the cross-session graph',
362
- ' handoff <taskId> --to <sid> [--session <sid>] [--reason <reason>]',
363
- ' Hand a task off to another session',
364
- ' graph [--session <sid>] [--format text|json]',
365
- ' Display the cross-session task dependency graph',
366
- ' cancel <taskId> [--scope task|subtree|session] [--session <sid>] [--reason <reason>]',
367
- ' Cancel tasks with scoped semantics',
368
- ].join('\n');
369
- context.print(usage);
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
  }
@@ -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
- if (handleEscapeOnlyModalToken({
236
- active: state.agentDetailModal.active,
237
- requestRender: state.requestRender,
238
- handleEscape: state.handleEscape,
239
- }, token)) {
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();
@@ -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
- entry.currentValue = configManager.get(entry.setting.key as ConfigKey);
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
- entry.currentValue = configManager.get(key);
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 {