@pellux/goodvibes-tui 0.21.0 → 0.23.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 (70) hide show
  1. package/CHANGELOG.md +45 -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/management-commands.ts +1 -1
  7. package/src/cli/management-utils.ts +352 -0
  8. package/src/cli/management.ts +36 -334
  9. package/src/cli/parser.ts +17 -0
  10. package/src/cli/surface-command.ts +1 -1
  11. package/src/cli/types.ts +2 -0
  12. package/src/config/goodvibes-home-audit.ts +2 -0
  13. package/src/core/context-auto-compact.ts +110 -0
  14. package/src/core/conversation-rendering.ts +5 -2
  15. package/src/core/conversation-types.ts +24 -0
  16. package/src/core/conversation.ts +7 -12
  17. package/src/core/stream-event-wiring.ts +125 -7
  18. package/src/core/turn-event-wiring.ts +124 -0
  19. package/src/daemon/cli.ts +5 -0
  20. package/src/input/command-registry.ts +1 -0
  21. package/src/input/commands/channel-runtime.ts +139 -0
  22. package/src/input/commands/control-room-runtime.ts +5 -5
  23. package/src/input/commands/provider.ts +57 -3
  24. package/src/input/commands/runtime-services.ts +30 -1
  25. package/src/input/commands/session-workflow.ts +8 -16
  26. package/src/input/commands/session.ts +70 -20
  27. package/src/input/commands/share-runtime.ts +1 -1
  28. package/src/input/commands/shell-core.ts +54 -4
  29. package/src/input/commands.ts +2 -2
  30. package/src/input/handler-modal-routes.ts +37 -0
  31. package/src/input/handler-modal-token-routes.ts +19 -5
  32. package/src/input/handler-onboarding.ts +18 -0
  33. package/src/input/handler.ts +1 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
  35. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
  36. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
  37. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  38. package/src/input/settings-modal-behavior.ts +5 -0
  39. package/src/input/settings-modal-data.ts +77 -3
  40. package/src/input/settings-modal-mutations.ts +3 -0
  41. package/src/input/settings-modal-reset.ts +154 -0
  42. package/src/input/settings-modal.ts +55 -13
  43. package/src/main.ts +58 -50
  44. package/src/panels/agent-inspector-panel.ts +120 -18
  45. package/src/panels/agent-inspector-shared.ts +29 -0
  46. package/src/panels/builtin/development.ts +1 -0
  47. package/src/panels/builtin/knowledge.ts +14 -13
  48. package/src/panels/builtin/operations.ts +22 -1
  49. package/src/panels/builtin/shared.ts +7 -0
  50. package/src/panels/cockpit-panel.ts +123 -3
  51. package/src/panels/cockpit-read-model.ts +232 -0
  52. package/src/panels/index.ts +1 -1
  53. package/src/panels/knowledge-graph-panel.ts +84 -0
  54. package/src/panels/memory-panel.ts +370 -40
  55. package/src/panels/session-maintenance.ts +66 -15
  56. package/src/renderer/agent-detail-modal.ts +107 -3
  57. package/src/renderer/compaction-history-modal.ts +55 -0
  58. package/src/renderer/compaction-preview.ts +146 -0
  59. package/src/renderer/context-status-hint.ts +54 -0
  60. package/src/renderer/settings-modal-helpers.ts +2 -2
  61. package/src/renderer/settings-modal.ts +14 -3
  62. package/src/renderer/shell-surface.ts +10 -0
  63. package/src/runtime/bootstrap-command-parts.ts +4 -0
  64. package/src/runtime/bootstrap-core.ts +116 -0
  65. package/src/runtime/bootstrap-shell.ts +11 -0
  66. package/src/runtime/bootstrap.ts +7 -0
  67. package/src/runtime/services.ts +6 -1
  68. package/src/utils/browser.ts +29 -0
  69. package/src/version.ts +1 -1
  70. package/src/panels/knowledge-panel.ts +0 -343
@@ -1,6 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
 
3
- import type { CommandContext, CommandRegistry } from '../command-registry.ts';
3
+ import type { CommandContext } from '../command-registry.ts';
4
4
  import { type SessionMeta } from '@pellux/goodvibes-sdk/platform/sessions';
5
5
  import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
6
6
  import type { ConversationTitleSource } from '../../core/conversation';
@@ -441,21 +441,13 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
441
441
  return false;
442
442
  }
443
443
 
444
- export function registerSessionWorkflowCommands(registry: CommandRegistry): void {
445
- registry.register({
446
- name: 'session-mgmt',
447
- aliases: ['smgmt'],
448
- description: 'Manage sessions, resume posture, and transcript structure',
449
- usage: '[list | rename <name> | resume <id|name> | fork | save | info <id> | events [kind] | groups [kind] | hotspots | export <id> [format] | search <query> | delete <id>]',
450
- argsHint: '<list|rename|resume|fork|save|info|events|groups|hotspots|export|search|delete>',
451
- async handler(args, ctx) {
452
- const handled = await handleSessionWorkflowCommand(args, ctx);
453
- if (!handled) {
454
- ctx.print('Unknown subcommand: ' + (args[0] ?? '') + '\nUsage: /session-mgmt [list | rename <name> | resume <id> | fork [name] | save [name] | info [id] | events [kind] | groups [kind] | hotspots | export <id> [format] | search <query> | delete <id>]');
455
- }
456
- },
457
- });
458
- }
444
+ // session-mgmt / smgmt was removed in TASK-032.
445
+ // All session lifecycle operations are now first-class subcommands of /session.
446
+ // Use /session list, /session resume, /session save, etc.
447
+ //
448
+ // CommandRegistry.register() throws on duplicate names/aliases, so this
449
+ // registration was intentionally deleted rather than left as dead code.
450
+
459
451
  interface SessionExportData {
460
452
  readonly messages: object[];
461
453
  readonly timestamp?: number;
@@ -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
  }
@@ -17,7 +17,7 @@ import {
17
17
  resolveGithubToken,
18
18
  } from '../../export/gist-uploader.ts';
19
19
  import { copyToClipboard } from '../../utils/clipboard.ts';
20
- import { openBrowser } from '../../cli/management.ts';
20
+ import { openBrowser } from '../../utils/browser.ts';
21
21
 
22
22
  export function registerShareRuntimeCommands(registry: CommandRegistry): void {
23
23
  registry.register({
@@ -3,7 +3,9 @@ import type { SelectionItem } from '../selection-modal.ts';
3
3
  import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
4
4
  import { REASONING_BUDGET_MAP } from '@pellux/goodvibes-sdk/platform/providers';
5
5
  import { executeWriteQuit } from './quit-shared.ts';
6
- import { compactConversation, requireKeybindingsManager, requireProviderApi } from './runtime-services.ts';
6
+ import { compactConversation, requireKeybindingsManager, requireProviderApi, requireSessionMemoryStore } from './runtime-services.ts';
7
+ import { buildCompactionPreview, buildCompactionAfterNotice, buildPinUsageText, buildPinSuccessText } from '../../renderer/compaction-preview.ts';
8
+ import { buildCompactionHistoryText } from '../../renderer/compaction-history-modal.ts';
7
9
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
8
10
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
11
 
@@ -204,9 +206,57 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
204
206
  aliases: [],
205
207
  description: 'Summarize conversation to free context window',
206
208
  async handler(_args, ctx) {
207
- ctx.print('Compacting conversation...');
208
- await compactConversation(ctx);
209
- ctx.print('Conversation compacted.');
209
+ const messages = ctx.session.conversationManager.getMessagesForLLM();
210
+ // contextWindow is not on CommandContext; preview shows message/token counts
211
+ // without the capacity-% clause (still honest; no fabricated value).
212
+ const contextWindow = 0;
213
+ const memStore = ctx.session.sessionMemoryStore;
214
+ const pinnedMemoryCount = memStore ? memStore.list().length : 0;
215
+ // Pre-compact preview: honest estimate, clearly labelled.
216
+ const preview = buildCompactionPreview({ messages, contextWindow, pinnedMemoryCount, trigger: 'manual' });
217
+ ctx.print(preview);
218
+ const event = await compactConversation(ctx);
219
+ if (event) {
220
+ // Post-compact notice: uses real CompactionEvent figures.
221
+ ctx.print(buildCompactionAfterNotice({ event, pinnedMemoryCount }));
222
+ } else {
223
+ ctx.print('[Context] Compact complete.');
224
+ }
225
+ ctx.renderRequest();
226
+ },
227
+ });
228
+
229
+ registry.register({
230
+ name: 'compact-history',
231
+ aliases: ['compaction-history'],
232
+ description: 'Show compaction history for this session',
233
+ handler(_args, ctx) {
234
+ ctx.print(buildCompactionHistoryText());
235
+ ctx.renderRequest();
236
+ },
237
+ });
238
+
239
+ registry.register({
240
+ name: 'keep',
241
+ aliases: [],
242
+ description: 'Pin text to session memory (survives compaction)',
243
+ usage: '<text>',
244
+ argsHint: '<text to preserve>',
245
+ handler(args, ctx) {
246
+ const text = args.join(' ').trim();
247
+ if (!text) {
248
+ ctx.print(buildPinUsageText());
249
+ ctx.renderRequest();
250
+ return;
251
+ }
252
+ const memStore = requireSessionMemoryStore(ctx);
253
+ const id = memStore.add(text);
254
+ if (!id) {
255
+ ctx.print('[Pin] Nothing pinned — text was blank.');
256
+ } else {
257
+ const count = memStore.list().length;
258
+ ctx.print(buildPinSuccessText(id, text, count));
259
+ }
210
260
  ctx.renderRequest();
211
261
  },
212
262
  });
@@ -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';
@@ -19,6 +18,7 @@ import { registerGitRuntimeCommands } from './commands/git-runtime.ts';
19
18
  import { registerNotifyRuntimeCommands } from './commands/notify-runtime.ts';
20
19
  import { registerReplayRuntimeCommands } from './commands/replay-runtime.ts';
21
20
  import { registerShareRuntimeCommands } from './commands/share-runtime.ts';
21
+ import { registerChannelRuntimeCommands } from './commands/channel-runtime.ts';
22
22
  import { registerLocalSetupCommands } from './commands/local-setup.ts';
23
23
  import { registerProductRuntimeCommands } from './commands/product-runtime.ts';
24
24
  import { registerPlatformRuntimeCommands } from './commands/platform-runtime.ts';
@@ -71,6 +71,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
71
71
  registerNotifyRuntimeCommands(registry);
72
72
  registerReplayRuntimeCommands(registry);
73
73
  registerShareRuntimeCommands(registry);
74
+ registerChannelRuntimeCommands(registry);
74
75
  registerLocalSetupCommands(registry);
75
76
  registerProductRuntimeCommands(registry);
76
77
  registerPlatformRuntimeCommands(registry);
@@ -107,7 +108,6 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
107
108
  registerCloudflareRuntimeCommands(registry);
108
109
  registerWorkPlanRuntimeCommands(registry);
109
110
  registerLocalRuntimeCommands(registry);
110
- registerSessionWorkflowCommands(registry);
111
111
  registerDiscoveryRuntimeCommands(registry);
112
112
  registerPlanningRuntimeCommands(registry);
113
113
  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
  }