@pellux/goodvibes-tui 1.7.0 → 1.9.1

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 (140) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -9
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/config/credential-availability.ts +122 -0
  12. package/src/config/index.ts +15 -0
  13. package/src/core/composer-state.ts +11 -3
  14. package/src/core/conversation-line-cache.ts +27 -3
  15. package/src/core/conversation-rendering.ts +71 -14
  16. package/src/core/stream-event-wiring.ts +20 -1
  17. package/src/core/system-message-noise.ts +87 -0
  18. package/src/core/system-message-router.ts +68 -1
  19. package/src/core/turn-cancellation.ts +7 -2
  20. package/src/core/turn-event-wiring.ts +10 -2
  21. package/src/daemon/cli.ts +29 -2
  22. package/src/daemon/handlers/register.ts +8 -1
  23. package/src/daemon/service-commands.ts +329 -0
  24. package/src/input/autocomplete.ts +27 -1
  25. package/src/input/command-registry.ts +46 -4
  26. package/src/input/commands/codebase-runtime.ts +46 -6
  27. package/src/input/commands/config.ts +43 -3
  28. package/src/input/commands/health-runtime.ts +9 -1
  29. package/src/input/commands/memory.ts +68 -35
  30. package/src/input/commands/operator-panel-runtime.ts +31 -7
  31. package/src/input/commands/planning-runtime.ts +95 -6
  32. package/src/input/commands/qrcode-runtime.ts +25 -5
  33. package/src/input/commands/remote-runtime-setup.ts +5 -3
  34. package/src/input/commands/session-content.ts +20 -9
  35. package/src/input/commands/settings-sync-runtime.ts +15 -3
  36. package/src/input/commands/shell-core.ts +10 -1
  37. package/src/input/commands/workstream-runtime.ts +168 -18
  38. package/src/input/config-modal-types.ts +15 -1
  39. package/src/input/config-modal.ts +227 -12
  40. package/src/input/feed-context-factory.ts +3 -0
  41. package/src/input/handler-command-route.ts +10 -3
  42. package/src/input/handler-content-actions.ts +17 -2
  43. package/src/input/handler-feed-routes.ts +43 -121
  44. package/src/input/handler-feed.ts +32 -4
  45. package/src/input/handler-modal-routes.ts +78 -13
  46. package/src/input/handler-modal-stack.ts +11 -2
  47. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  48. package/src/input/handler-onboarding.ts +71 -59
  49. package/src/input/handler-picker-routes.ts +15 -8
  50. package/src/input/handler-shortcuts.ts +59 -9
  51. package/src/input/handler.ts +6 -1
  52. package/src/input/keybindings.ts +6 -5
  53. package/src/input/model-picker.ts +19 -2
  54. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  55. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  56. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  57. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  58. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  59. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  60. package/src/input/panel-mouse-geometry.ts +97 -0
  61. package/src/input/panel-paste-flood-guard.ts +86 -0
  62. package/src/input/selection-modal.ts +11 -0
  63. package/src/input/session-picker-modal.ts +44 -4
  64. package/src/input/settings-modal-data.ts +51 -2
  65. package/src/input/settings-modal-types.ts +4 -2
  66. package/src/main.ts +28 -27
  67. package/src/panels/builtin/shared.ts +9 -3
  68. package/src/panels/fleet-deep-link.ts +31 -0
  69. package/src/panels/fleet-panel-format.ts +62 -0
  70. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  71. package/src/panels/fleet-panel.ts +89 -131
  72. package/src/panels/fleet-read-model.ts +43 -0
  73. package/src/panels/fleet-steer.ts +35 -2
  74. package/src/panels/fleet-stop.ts +29 -1
  75. package/src/panels/modals/keybindings-modal.ts +16 -1
  76. package/src/panels/modals/modal-theme.ts +35 -29
  77. package/src/panels/modals/pairing-modal.ts +25 -6
  78. package/src/panels/modals/planning-modal.ts +43 -21
  79. package/src/panels/modals/work-plan-modal.ts +28 -0
  80. package/src/panels/panel-manager.ts +15 -4
  81. package/src/panels/polish-core.ts +38 -25
  82. package/src/panels/project-planning-answer-actions.ts +8 -13
  83. package/src/panels/types.ts +24 -0
  84. package/src/permissions/prompt.ts +160 -13
  85. package/src/renderer/autocomplete-overlay.ts +28 -2
  86. package/src/renderer/compositor.ts +2 -3
  87. package/src/renderer/config-modal.ts +19 -5
  88. package/src/renderer/footer-tips.ts +5 -1
  89. package/src/renderer/fullscreen-primitives.ts +32 -22
  90. package/src/renderer/git-status.ts +3 -1
  91. package/src/renderer/layout.ts +0 -4
  92. package/src/renderer/markdown.ts +7 -3
  93. package/src/renderer/modal-factory.ts +25 -20
  94. package/src/renderer/model-workspace.ts +18 -3
  95. package/src/renderer/overlay-box.ts +21 -17
  96. package/src/renderer/process-indicator.ts +14 -3
  97. package/src/renderer/selection-modal-overlay.ts +6 -1
  98. package/src/renderer/session-picker-modal.ts +196 -3
  99. package/src/renderer/settings-modal-helpers.ts +2 -0
  100. package/src/renderer/settings-modal.ts +7 -0
  101. package/src/renderer/shell-surface.ts +8 -1
  102. package/src/renderer/status-glyphs.ts +14 -15
  103. package/src/renderer/system-message.ts +15 -3
  104. package/src/renderer/terminal-bg-probe.ts +339 -0
  105. package/src/renderer/terminal-escapes.ts +20 -0
  106. package/src/renderer/theme-mode-config.ts +67 -0
  107. package/src/renderer/theme.ts +91 -1
  108. package/src/renderer/thinking.ts +11 -3
  109. package/src/renderer/tool-call.ts +15 -9
  110. package/src/renderer/tool-result-summary.ts +148 -0
  111. package/src/renderer/turn-injection.ts +22 -3
  112. package/src/renderer/ui-factory.ts +154 -85
  113. package/src/renderer/ui-primitives.ts +30 -129
  114. package/src/runtime/bootstrap-command-context.ts +6 -0
  115. package/src/runtime/bootstrap-command-parts.ts +8 -4
  116. package/src/runtime/bootstrap-core.ts +46 -24
  117. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  118. package/src/runtime/bootstrap-shell.ts +19 -1
  119. package/src/runtime/bootstrap.ts +118 -5
  120. package/src/runtime/code-index-services.ts +25 -2
  121. package/src/runtime/legacy-daemon-migration.ts +516 -0
  122. package/src/runtime/memory-fold.ts +55 -0
  123. package/src/runtime/onboarding/derivation.ts +7 -2
  124. package/src/runtime/onboarding/snapshot.ts +27 -1
  125. package/src/runtime/onboarding/types.ts +31 -1
  126. package/src/runtime/operator-token-cleanup.ts +82 -1
  127. package/src/runtime/orchestrator-core-services.ts +10 -0
  128. package/src/runtime/resume-notice.ts +209 -0
  129. package/src/runtime/services.ts +12 -8
  130. package/src/runtime/session-inbound-inputs.ts +252 -0
  131. package/src/runtime/session-spine-transport.ts +64 -0
  132. package/src/runtime/terminal-output-guard.ts +15 -8
  133. package/src/runtime/ui-services.ts +19 -3
  134. package/src/runtime/workstream-services.ts +160 -28
  135. package/src/runtime/wrfc-persistence.ts +124 -17
  136. package/src/shell/blocking-input.ts +46 -3
  137. package/src/shell/recovery-input-helpers.ts +170 -1
  138. package/src/shell/ui-openers.ts +42 -9
  139. package/src/utils/terminal-width.ts +52 -0
  140. package/src/version.ts +1 -1
@@ -32,6 +32,7 @@ import {
32
32
  handlePromptTextToken,
33
33
  type PanelMouseLayout,
34
34
  } from './handler-feed-routes.ts';
35
+ import type { PanelBurstGuardState } from './panel-paste-flood-guard.ts';
35
36
  import type { WrappedPromptInfo } from './handler-prompt-buffer.ts';
36
37
  import { handleModalTokenRoutes } from './handler-modal-token-routes.ts';
37
38
  import { handleCommandModeToken } from './handler-command-route.ts';
@@ -130,6 +131,8 @@ export interface InputFeedContext {
130
131
  readonly killRing: KillRing;
131
132
  /** Terminal focus tracker (W2.3) — updated here from 'focus' tokens, read by the unfocused-alert notifiers in core/. */
132
133
  readonly focusTracker: FocusTracker;
134
+ /** DEBT-5 item 5 — the paste-flood guard's persistent state, mutated in place across tokens by handlePanelFocusToken (see panel-paste-flood-guard.ts). Never reallocated. */
135
+ readonly panelBurstGuard: PanelBurstGuardState;
133
136
  readonly getHistory: () => InfiniteBuffer;
134
137
  readonly getViewportHeight: () => number;
135
138
  readonly getScrollTop: () => number;
@@ -199,6 +202,11 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
199
202
  // several batched into one feed() by render-tick latency — arrive as
200
203
  // separate 1-char 'text' tokens. The old per-feed sum misread two quick nav
201
204
  // keystrokes (e.g. j then k in one drain) as a "burst" and yanked focus.
205
+ // DEBT-5 item 5's flood guard reuses that same per-token model but adds
206
+ // real timing: one `now` per feed() call (not per token) is intentional —
207
+ // a genuine flood delivers many tokens in one drain, and they should all
208
+ // measure as arriving "at once", not spread across meaningless sub-ms noise.
209
+ const now = Date.now();
202
210
  for (const token of tokens) {
203
211
  // Focus-reporting tokens (CSI ?1004h, W2.3) never reach the composer or any
204
212
  // modal route — consumed here, first, unconditionally. No render needed.
@@ -279,6 +287,12 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
279
287
  // focusTarget and self-heals it, and context.panelFocused was seeded from
280
288
  // it at feed entry — so no manual "unfocus if panels vanished" patch is
281
289
  // needed here anymore.
290
+ // Snapshot these four BEFORE dispatch so the write-back below can tell
291
+ // a pipeline-driven change (see comment there) from a stale copy.
292
+ const promptBefore = context.prompt;
293
+ const cursorPosBefore = context.cursorPos;
294
+ const commandModeBefore = context.commandMode;
295
+ const panelFocusedBefore = context.panelFocused;
282
296
  const shortcutState = {
283
297
  panelFocused: context.panelFocused,
284
298
  prompt: context.prompt,
@@ -312,10 +326,22 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
312
326
  killRing: context.killRing,
313
327
  };
314
328
  if (handleGlobalShortcutToken(shortcutState, token, viewportHeight)) {
315
- context.prompt = shortcutState.prompt;
316
- context.cursorPos = shortcutState.cursorPos;
317
- context.commandMode = shortcutState.commandMode;
318
- context.panelFocused = shortcutState.panelFocused;
329
+ // Some branches (handleEscape, handleCtrlC, handleUndo, handleRedo,
330
+ // handlePaste) mutate handler state directly and immediately sync it
331
+ // into this same `context` via syncFeedContextMutableFields — e.g.
332
+ // Escape closing the slash-command palette clears context.prompt and
333
+ // context.commandMode on the live handler mid-call. `shortcutState`
334
+ // above is a snapshot taken BEFORE that call, so applying it
335
+ // unconditionally would stomp the live update right back to its
336
+ // stale pre-action value (undoing the very close it just performed,
337
+ // and even re-arming the palette below). This is the same class of
338
+ // bug as the overlay-flag write-back guard in handler.ts's feed().
339
+ // Only apply the snapshot for a field the dispatched action did NOT
340
+ // already update out from under us.
341
+ context.prompt = context.prompt === promptBefore ? shortcutState.prompt : context.prompt;
342
+ context.cursorPos = context.cursorPos === cursorPosBefore ? shortcutState.cursorPos : context.cursorPos;
343
+ context.commandMode = context.commandMode === commandModeBefore ? shortcutState.commandMode : context.commandMode;
344
+ context.panelFocused = context.panelFocused === panelFocusedBefore ? shortcutState.panelFocused : context.panelFocused;
319
345
  if (context.commandMode) {
320
346
  if (!context.prompt.startsWith('/')) {
321
347
  context.commandMode = false;
@@ -344,6 +370,8 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
344
370
  panelManager: context.panelManager,
345
371
  keybindingsManager: context.keybindingsManager,
346
372
  onPanelInputConsumed: context.onPanelInputConsumed,
373
+ now,
374
+ burstGuard: context.panelBurstGuard,
347
375
  // Per-token paste classification (Invariant B): a paste is a single
348
376
  // 'text' token whose value holds more than one character.
349
377
  isPasteToken: token.type === 'text' && token.value.length > 1,
@@ -94,16 +94,26 @@ export function handleSelectionModalToken(state: SelectionRouteState, token: Inp
94
94
  if (selected) {
95
95
  dispatchSelectionAction(action, selected);
96
96
  }
97
+ } else if (state.selectionModal.allowSearch) {
98
+ // UX-C instant filter: an unclaimed keystroke arms search AND starts
99
+ // the query immediately, instead of silently doing nothing until the
100
+ // user discovers '/' first (the "help search needs '/' arming while
101
+ // the palette filters instantly" evaluator finding — /help has no
102
+ // customActions, so every letter used to be swallowed here). '/'
103
+ // above still works too; this is additive, not a replacement, and
104
+ // never fires for a claimed hotkey letter (checked first, above).
105
+ state.selectionModal.focusSearch();
106
+ state.selectionModal.setQuery(token.value);
97
107
  }
98
108
  }
99
109
  } else if (token.type === 'key') {
100
110
  if (token.logicalName === 'escape') {
101
- if (state.selectionModal.allowSearch && state.selectionModal.searchFocused) {
102
- if (state.selectionModal.query.length > 0) state.selectionModal.setQuery('');
103
- else state.selectionModal.blurSearch();
104
- state.requestRender();
105
- return true;
106
- }
111
+ // UX-C: ONE Escape always closes the modal, regardless of search state.
112
+ // Clearing an in-progress query is Backspace's job, not Esc's — the old
113
+ // two-stage contract (1st Esc clears query, 2nd blurs search, 3rd
114
+ // finally closes) was the "help modal took 3 Escapes after searching"
115
+ // evaluator finding (searchFocused is not modal-specific — every
116
+ // SelectionModal-based picker had the same multi-Esc pattern).
107
117
  state.handleEscape();
108
118
  return true;
109
119
  }
@@ -545,19 +555,56 @@ type ConfigModalRouteState = {
545
555
  /**
546
556
  * Route a key to the generic config-modal host (W6.1 MIGRATE-TO-MODAL
547
557
  * surfaces). The host owns the reserved navigation keys (Esc, up/down/j/k tab
548
- * left/right); every other key is offered to the active surface's declarative
549
- * action table (fireAction handles the two-press confirm for destructive
550
- * actions). Any unrecognised key is absorbed so the modal stays modal — this is
551
- * the same "active modal captures all keys" shape as the other modal routers.
558
+ * left/right, and now '/' DEBT-5 item 1's type-to-filter, armed the same
559
+ * way as scrollable-list-panel.ts's opt-in filter and SettingsModal's own
560
+ * '/'-armed search); every other key is offered to the active surface's
561
+ * declarative action table (fireAction handles the two-press confirm for
562
+ * destructive actions). Any unrecognised key is absorbed so the modal stays
563
+ * modal — this is the same "active modal captures all keys" shape as the
564
+ * other modal routers.
565
+ *
566
+ * While the filter is armed (`configModal.isFilterActive()`), printable text
567
+ * tokens go to the query instead of nav/action dispatch — this is WHY 'j'/'k'
568
+ * lose their vim-nav meaning mid-filter (you're typing, not navigating) but
569
+ * Escape, Backspace, and the arrow/tab nav keys keep their ordinary meaning
570
+ * (arrows navigate the FILTERED list; Enter still acts on the selected row).
552
571
  */
553
572
  export function handleConfigModalToken(state: ConfigModalRouteState, token: InputToken): boolean {
554
573
  if (!state.configModal.active) return false;
555
574
 
575
+ // Every token that reaches the modal is a user interaction — after the
576
+ // first one, structure freezes to interaction boundaries (liveness rule).
577
+ // Before it, renders may sync structure so async onOpen loads appear
578
+ // without a keypress (batch refutation finding 3).
579
+ state.configModal.noteInteraction();
580
+
581
+ const filtering = state.configModal.isFilterActive();
582
+
556
583
  if (token.type === 'key') {
557
- switch (token.logicalName) {
558
- case 'escape':
559
- state.handleEscape();
584
+ if (token.logicalName === 'escape') {
585
+ // Two-stage Esc, but ONLY while there is a query to clear (the one
586
+ // documented exception to single-Esc-close): clearFilterOrFallthrough
587
+ // returns false on an empty query, so an armed-but-empty filter (or no
588
+ // filter at all) closes the modal in one press, same as every other
589
+ // modal here.
590
+ if (state.configModal.clearFilterOrFallthrough()) {
591
+ state.requestRender();
560
592
  return true;
593
+ }
594
+ state.handleEscape();
595
+ return true;
596
+ }
597
+ if (filtering && isTextBackspace(token.logicalName ?? '')) {
598
+ state.configModal.backspaceFilter();
599
+ state.requestRender();
600
+ return true;
601
+ }
602
+ if (!filtering && token.logicalName === '/') {
603
+ state.configModal.activateFilter();
604
+ state.requestRender();
605
+ return true;
606
+ }
607
+ switch (token.logicalName) {
561
608
  case 'up':
562
609
  state.configModal.moveUp();
563
610
  state.requestRender();
@@ -576,16 +623,34 @@ export function handleConfigModalToken(state: ConfigModalRouteState, token: Inpu
576
623
  state.requestRender();
577
624
  return true;
578
625
  }
626
+ // Any other 'key' token (e.g. 'enter') falls through to the action table
627
+ // below even while filtering — only printable TEXT tokens are captured
628
+ // by the filter (below), so Enter still fires on the current selection.
579
629
  } else if (token.type === 'text') {
630
+ if (!filtering && token.value === '/') {
631
+ state.configModal.activateFilter();
632
+ state.requestRender();
633
+ return true;
634
+ }
635
+ if (filtering) {
636
+ // The WHOLE token value lands in the filter in one call — including a
637
+ // multi-char paste token — never split into per-char nav/action
638
+ // dispatch (the text-capture invariant this item's brief calls out).
639
+ state.configModal.appendFilterText(token.value);
640
+ state.requestRender();
641
+ return true;
642
+ }
580
643
  if (token.value === 'j') { state.configModal.moveDown(); state.requestRender(); return true; }
581
644
  if (token.value === 'k') { state.configModal.moveUp(); state.requestRender(); return true; }
582
645
  }
583
646
 
584
647
  const actionKey = token.type === 'key' ? (token.logicalName ?? '') : token.type === 'text' ? token.value : '';
648
+ const submitInput = state.commandContext?.submitInput;
585
649
  const fired = actionKey.length > 0 && state.configModal.fireAction(actionKey, {
586
650
  print: (message: string) => state.commandContext?.print(message),
587
651
  executeCommand: state.commandContext?.executeCommand,
588
652
  openModal: state.commandContext?.openModal,
653
+ ...(submitInput ? { submitInput: (text: string) => submitInput(text) } : {}),
589
654
  });
590
655
  if (fired) {
591
656
  state.requestRender();
@@ -148,8 +148,17 @@ export function handleEscape(state: EscapeState): {
148
148
  if (state.modalStack[i] === 'command') state.modalStack.splice(i, 1);
149
149
  }
150
150
  state.autocompleteReset();
151
- prompt = '';
152
- cursorPos = 0;
151
+ // Esc's palette-dismiss convention: a bare '/' with nothing typed
152
+ // after it IS the palette, so Esc clears it along with the slash
153
+ // (first Esc, one step, done — no lingering '/' to backspace out).
154
+ // Once the user has typed a command name (or anything else) past
155
+ // the slash, Esc's job is only to dismiss the ghost-suggestion
156
+ // overlay; the typed text is real composer content and stays put,
157
+ // just no longer treated as an in-progress command.
158
+ if (prompt === '/') {
159
+ prompt = '';
160
+ cursorPos = 0;
161
+ }
153
162
  },
154
163
  });
155
164
  };
@@ -0,0 +1,149 @@
1
+ /**
2
+ * F1 (One-Platform Wave 2 friction fix): the onboarding wizard's "Connect to
3
+ * this daemon now" action, wired from the Network step's `network.daemon-source`
4
+ * = 'adopt' fields. Extracted from handler-onboarding.ts to keep that file
5
+ * under the architecture line cap.
6
+ */
7
+ import { join } from 'node:path';
8
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
9
+ import { resolveDaemonCompanionToken } from '../runtime/operator-token-cleanup.ts';
10
+ import {
11
+ buildManagedDaemonServiceManager,
12
+ detectLegacyUnit,
13
+ resolveInstalledDaemonBinary,
14
+ runLegacyDaemonMigration,
15
+ MANAGED_SERVICE_NAME,
16
+ } from '../runtime/legacy-daemon-migration.ts';
17
+ import type { InputHandlerLike as InputHandler } from './handler-types.ts';
18
+ import {
19
+ formatRuntimeActiveSuccessMessage,
20
+ getRuntimeEndpointStatus,
21
+ isRuntimeEndpointActive,
22
+ runtimePortDiagnostic,
23
+ } from './onboarding/onboarding-runtime-status.ts';
24
+
25
+ /**
26
+ * Immediately (not deferred to the final wizard Apply):
27
+ * 1. Installs the pasted token into <homeDirectory>/.goodvibes/daemon/operator-tokens.json
28
+ * via `resolveDaemonCompanionToken` — the same durable-persistence logic
29
+ * the `GOODVIBES_DAEMON_TOKEN` non-interactive override uses.
30
+ * 2. Points `controlPlane.host`/`controlPlane.port` at the pasted host/port.
31
+ * 3. Restarts the external-services controller and reports back, honestly,
32
+ * whether the daemon at that host/port was actually adopted — reusing
33
+ * the same `onboarding-runtime-status.ts` helpers the normal
34
+ * start-a-new-daemon path uses to report success/failure after Apply.
35
+ *
36
+ * Feedback is printed to the conversation feed (matching the existing
37
+ * start-openai-subscription action's pattern) rather than stored as wizard
38
+ * field state, because a 'status' field's value is recomputed fresh from the
39
+ * step builder on every render and is not a place to durably stash an
40
+ * out-of-band result.
41
+ */
42
+ export async function handleConnectExistingDaemonForHandler(handler: InputHandler): Promise<void> {
43
+ if (handler.onboardingApplyPending) return;
44
+ const host = handler.onboardingWizard.getStringFieldValue('network.adopt-daemon-host', '127.0.0.1').trim() || '127.0.0.1';
45
+ const port = handler.onboardingWizard.getPortFieldValue('network.adopt-daemon-port', 3421);
46
+ const token = handler.onboardingWizard.getStringFieldValue('network.adopt-daemon-token', '').trim();
47
+ if (token.length === 0) {
48
+ handler.commandContext?.print?.('Connect to existing daemon: paste that daemon\'s token first.');
49
+ return;
50
+ }
51
+
52
+ const externalServices = handler.uiServices.platform.externalServices;
53
+ if (!externalServices) {
54
+ handler.commandContext?.print?.('Connect to existing daemon: background service controller is unavailable in this build.');
55
+ return;
56
+ }
57
+
58
+ handler.onboardingApplyPending = true;
59
+ handler.requestRender();
60
+ try {
61
+ const daemonHomeDir = join(handler.uiServices.environment.homeDirectory, '.goodvibes', 'daemon');
62
+ resolveDaemonCompanionToken(daemonHomeDir, token);
63
+ handler.uiServices.platform.configManager.setDynamic('controlPlane.host', host);
64
+ handler.uiServices.platform.configManager.setDynamic('controlPlane.port', port);
65
+
66
+ const state = await externalServices.restart();
67
+ const binding = { label: 'GoodVibes daemon', host, port };
68
+ if (isRuntimeEndpointActive(state, 'daemon')) {
69
+ handler.commandContext?.print?.([
70
+ 'Connect to existing daemon: succeeded.',
71
+ ` ${formatRuntimeActiveSuccessMessage('daemon', state)}`,
72
+ ' This host/port and token are applied now; Apply at the end of the wizard will persist them.',
73
+ ].join('\n'));
74
+ } else {
75
+ const status = getRuntimeEndpointStatus(state, 'daemon');
76
+ handler.commandContext?.print?.([
77
+ 'Connect to existing daemon: could not verify a connection.',
78
+ ` ${runtimePortDiagnostic(binding, undefined, status)}`,
79
+ ' Check the host, port, and token, then try again.',
80
+ ].join('\n'));
81
+ }
82
+ } catch (error) {
83
+ handler.commandContext?.print?.(`Connect to existing daemon failed: ${summarizeError(error)}`);
84
+ } finally {
85
+ handler.onboardingApplyPending = false;
86
+ handler.requestRender();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * W4-D1: the guided UX entry point for migrating a legacy
92
+ * `goodvibes-daemon.service` unit (Wave 3 shipped detect+disclose only; this
93
+ * closes that inheritance). This is a thin wrapper over
94
+ * `detectLegacyUnit` + `runLegacyDaemonMigration`
95
+ * (`../runtime/legacy-daemon-migration.ts`) — the actual migration mechanics
96
+ * (never auto-migrate, new-up-then-old-down with a health check before the
97
+ * legacy unit is touched, adopt-or-warn for an unrecognized process on the
98
+ * port) live there and are exercised against fakes in
99
+ * `src/test/daemon/service-commands.test.ts`; this function only builds the
100
+ * same `PlatformServiceManager` the daemon CLI's `migrate-service` subcommand
101
+ * builds (via the shared `buildManagedDaemonServiceManager`, so a migration
102
+ * triggered from either surface installs the identical unit) and prints the
103
+ * honest result lines to the conversation feed, mirroring
104
+ * `handleConnectExistingDaemonForHandler`'s pattern above.
105
+ *
106
+ * This lives in `src/input/` rather than importing
107
+ * `../daemon/service-commands.ts` directly because the architecture gate's
108
+ * `input-no-entrypoints` rule forbids `src/input/**` from depending on
109
+ * `src/daemon/**` (input must stay a pure event-handling layer) — the shared
110
+ * engine lives in the entrypoint-agnostic `src/runtime/` layer instead.
111
+ *
112
+ * `confirm` is caller-supplied rather than read from a fixed field name here
113
+ * so this stays reusable from any surface: the onboarding wizard's Network
114
+ * step (`onboarding-wizard-network-adopt.ts`'s `pushLegacyDaemonMigrationFields`,
115
+ * dispatched from `handler-onboarding.ts`'s `migrate-legacy-daemon-service`
116
+ * action) reads its own `network.migrate-legacy-daemon-confirm` checklist
117
+ * toggle and passes it through as this parameter — unchecked previews the
118
+ * dry-run plan (`confirm: false`), checked executes the real migration
119
+ * (`confirm: true`).
120
+ */
121
+ export async function handleMigrateLegacyDaemonServiceForHandler(handler: InputHandler, confirm: boolean): Promise<void> {
122
+ if (handler.onboardingApplyPending) return;
123
+
124
+ handler.onboardingApplyPending = true;
125
+ handler.requestRender();
126
+ try {
127
+ const homeDirectory = handler.uiServices.environment.homeDirectory;
128
+ const configManager = handler.uiServices.platform.configManager;
129
+ const host = String(configManager.get('controlPlane.host') ?? '127.0.0.1');
130
+ const port = Number(configManager.get('controlPlane.port') ?? 3421);
131
+ const binaryPath = resolveInstalledDaemonBinary({ moduleUrl: import.meta.url });
132
+
133
+ const manager = buildManagedDaemonServiceManager({ binaryPath, homeDir: homeDirectory, host, port });
134
+ const legacy = detectLegacyUnit({ homeDir: homeDirectory });
135
+ const result = await runLegacyDaemonMigration(
136
+ { host, port, trackedServiceName: MANAGED_SERVICE_NAME, confirmMigration: confirm },
137
+ manager,
138
+ legacy,
139
+ );
140
+ handler.commandContext?.print?.(
141
+ ['Migrate legacy daemon service:', ...result.lines.map((line: string) => ` ${line}`)].join('\n'),
142
+ );
143
+ } catch (error) {
144
+ handler.commandContext?.print?.(`Migrate legacy daemon service failed: ${summarizeError(error)}`);
145
+ } finally {
146
+ handler.onboardingApplyPending = false;
147
+ handler.requestRender();
148
+ }
149
+ }
@@ -1,8 +1,10 @@
1
- import { createOAuthLocalListener } from '@pellux/goodvibes-sdk/platform/config';
1
+ import { createOAuthLocalListener, resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
3
3
  import { summarizeError, openExternalUrl } from '@pellux/goodvibes-sdk/platform/utils';
4
4
  import { getProviderIdFromModel } from '../config/provider-model.ts';
5
5
  import { buildProviderAccountSnapshot } from '@/runtime/index.ts';
6
+ import { handleConnectExistingDaemonForHandler, handleMigrateLegacyDaemonServiceForHandler } from './handler-onboarding-daemon-adopt.ts';
7
+ import { detectLegacyUnit, resolveConfiguredServiceName } from '../runtime/legacy-daemon-migration.ts';
6
8
  import { OnboardingWizardController, type OnboardingWizardAction, type OnboardingWizardApplyFeedback } from './onboarding/onboarding-wizard.ts';
7
9
  import { handleCloudflareOnboardingActionForHandler, maybeProvisionCloudflareOnFinalApplyForHandler } from './handler-onboarding-cloudflare.ts';
8
10
  import { applyOnboardingRequest, collectOnboardingSnapshot, deleteWizardProgress, verifyOnboardingRequest, writeOnboardingCheckMarker, writeWizardProgress } from '../runtime/onboarding/index.ts';
@@ -74,6 +76,41 @@ function formatRuntimeStoppedFailureMessage(
74
76
  return `${binding.label} was disabled for ${disabledSurface}, but ${binding.host}:${binding.port} is still occupied. Settings were saved; another GoodVibes process or external service may still be running on that port.${statusDetail}`;
75
77
  }
76
78
 
79
+ /**
80
+ * Verification item(s) for one runtime endpoint given whether it is expected
81
+ * to be running. Shared by both the "nothing server-backed selected" early
82
+ * path and the full server-backed path so daemon-by-default (the loopback
83
+ * daemon can be `expected` even when nothing else was selected) is handled
84
+ * identically in both places.
85
+ */
86
+ function buildEndpointVerificationItems(
87
+ handler: InputHandler,
88
+ request: OnboardingApplyRequest,
89
+ endpoint: OnboardingRuntimeEndpoint,
90
+ expected: boolean,
91
+ state: OnboardingExternalServiceState | undefined,
92
+ ): OnboardingVerificationItem[] {
93
+ const idPrefix = endpoint === 'daemon' ? 'daemon' : 'http-listener';
94
+ if (expected) {
95
+ const active = isRuntimeEndpointActive(state, endpoint);
96
+ return [{
97
+ id: `runtime:${idPrefix}-active`,
98
+ status: active ? 'pass' : 'fail',
99
+ message: active
100
+ ? formatRuntimeActiveSuccessMessage(endpoint, state)
101
+ : formatRuntimeActiveFailureMessage(handler, request, endpoint, state),
102
+ target: 'service',
103
+ }];
104
+ }
105
+ if (!isRuntimeEndpointOccupyingConfiguredPort(state, endpoint)) return [];
106
+ return [{
107
+ id: `runtime:${idPrefix}-stopped`,
108
+ status: 'fail',
109
+ message: formatRuntimeStoppedFailureMessage(handler, request, endpoint, state),
110
+ target: 'service',
111
+ }];
112
+ }
113
+
77
114
  function showOnboardingApplyFeedbackForHandler(handler: InputHandler, feedback: OnboardingWizardApplyFeedback): void {
78
115
  handler.onboardingWizard.setApplyFeedback(feedback);
79
116
  const reviewIndex = handler.onboardingWizard.steps.findIndex((step) => step.id === 'review');
@@ -185,6 +222,15 @@ export function handleModelPickerCommitForHandler(handler: InputHandler): boolea
185
222
  }
186
223
 
187
224
  export async function handleOnboardingActionForHandler(handler: InputHandler, action: OnboardingWizardAction): Promise<void> {
225
+ if (action === 'connect-existing-daemon') {
226
+ await handleConnectExistingDaemonForHandler(handler);
227
+ return;
228
+ }
229
+ if (action === 'migrate-legacy-daemon-service') {
230
+ const confirm = handler.onboardingWizard.getBooleanFieldValue('network.migrate-legacy-daemon-confirm', false);
231
+ await handleMigrateLegacyDaemonServiceForHandler(handler, confirm);
232
+ return;
233
+ }
188
234
  if (action === 'start-openai-subscription') {
189
235
  await handler.handleOpenAiSubscriptionStart();
190
236
  return;
@@ -309,6 +355,7 @@ export async function handleOnboardingActionForHandler(handler: InputHandler, ac
309
355
  handler.requestRender();
310
356
  }
311
357
 
358
+
312
359
  export async function refreshOnboardingHydrationForHandler(handler: InputHandler, options: {
313
360
  readonly preserveValues?: boolean;
314
361
  readonly targetStepId?: string;
@@ -336,6 +383,9 @@ export async function refreshOnboardingHydrationForHandler(handler: InputHandler
336
383
  secretsManager: handler.uiServices.platform.secretsManager,
337
384
  }),
338
385
  },
386
+ legacyDaemon: {
387
+ detect: () => ({ ...detectLegacyUnit({ homeDir: handler.uiServices.environment.homeDirectory }), trackedServiceName: resolveConfiguredServiceName(handler.uiServices.platform.configManager) }),
388
+ },
339
389
  });
340
390
  if (!handler.onboardingWizard.active || hydrationSerial !== handler.onboardingHydrationSerial) return;
341
391
  handler.onboardingWizard.hydrateRuntimeState({ snapshot }, { resetValues: !(options.preserveValues ?? false) });
@@ -572,7 +622,8 @@ export function getOnboardingRuntimePostureForHandler(handler: InputHandler, req
572
622
  const serviceEnabled = getConfigValue('service.enabled') === true;
573
623
  const serviceAutostart = getConfigValue('service.autostart') === true;
574
624
  const restartOnFailure = getConfigValue('service.restartOnFailure') === true;
575
- const daemonEnabled = getConfigValue('danger.daemon') === true || getConfigValue('controlPlane.enabled') === true;
625
+ const daemonReader = { get: (key: string): boolean | string | number | undefined => getConfigValue(key) as boolean | string | number | undefined };
626
+ const daemonEnabled = resolveDaemonEnabled(daemonReader) || getConfigValue('controlPlane.enabled') === true;
576
627
  const listenerEnabled = getConfigValue('danger.httpListener') === true;
577
628
  const webEnabled = getConfigValue('web.enabled') === true;
578
629
  const controlPlaneRemote = getConfigValue('controlPlane.hostMode') === 'network'
@@ -593,7 +644,10 @@ export function getOnboardingRuntimePostureForHandler(handler: InputHandler, req
593
644
  restartOnFailure,
594
645
  expectedDaemon: daemonEnabled || webEnabled,
595
646
  expectedHttpListener: listenerEnabled,
596
- serverBacked: serviceEnabled || daemonEnabled || listenerEnabled || webEnabled,
647
+ // Daemon-by-default: the loopback session daemon can be expected to run
648
+ // even when nothing else was explicitly enabled here, so it does not
649
+ // belong in "did onboarding opt into a network-facing bundle."
650
+ serverBacked: serviceEnabled || listenerEnabled || webEnabled,
597
651
  remoteExposure,
598
652
  };
599
653
  }
@@ -683,28 +737,20 @@ export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler,
683
737
  }];
684
738
  }
685
739
 
686
- const stoppedItems: OnboardingVerificationItem[] = [];
687
- if (isRuntimeEndpointOccupyingConfiguredPort(externalState, 'daemon')) {
688
- stoppedItems.push({
689
- id: 'runtime:daemon-stopped',
690
- status: 'fail',
691
- message: formatRuntimeStoppedFailureMessage(handler, request, 'daemon', externalState),
692
- target: 'service',
693
- });
694
- }
695
- if (isRuntimeEndpointOccupyingConfiguredPort(externalState, 'httpListener')) {
696
- stoppedItems.push({
697
- id: 'runtime:http-listener-stopped',
698
- status: 'fail',
699
- message: formatRuntimeStoppedFailureMessage(handler, request, 'httpListener', externalState),
700
- target: 'service',
701
- });
702
- }
740
+ // Daemon-by-default: expectedDaemon can be true here (loopback daemon
741
+ // running for cross-surface visibility) even though nothing else was
742
+ // selected, so this is not simply "everything must be stopped."
743
+ const stoppedItems: OnboardingVerificationItem[] = [
744
+ ...buildEndpointVerificationItems(handler, request, 'daemon', posture.expectedDaemon, externalState),
745
+ ...buildEndpointVerificationItems(handler, request, 'httpListener', posture.expectedHttpListener, externalState),
746
+ ];
703
747
  if (externalState && stoppedItems.length === 0) {
704
748
  stoppedItems.push({
705
749
  id: 'runtime:external-services-stopped',
706
750
  status: 'pass',
707
- message: 'Background daemon and HTTP listener are stopped for Local TUI Only.',
751
+ message: posture.expectedDaemon
752
+ ? 'HTTP listener is stopped for Local TUI Only; the background daemon is running as expected for cross-surface visibility on this machine.'
753
+ : 'Background daemon and HTTP listener are stopped for Local TUI Only.',
708
754
  target: 'service',
709
755
  });
710
756
  }
@@ -742,44 +788,10 @@ export function verifyOnboardingRuntimePostureForHandler(handler: InputHandler,
742
788
  });
743
789
  }
744
790
 
745
- if (posture.expectedDaemon) {
746
- const daemonActive = isRuntimeEndpointActive(externalState, 'daemon');
747
- items.push({
748
- id: 'runtime:daemon-active',
749
- status: daemonActive ? 'pass' : 'fail',
750
- message: daemonActive
751
- ? formatRuntimeActiveSuccessMessage('daemon', externalState)
752
- : formatRuntimeActiveFailureMessage(handler, request, 'daemon', externalState),
753
- target: 'service',
754
- });
755
- }
756
- if (!posture.expectedDaemon && isRuntimeEndpointOccupyingConfiguredPort(externalState, 'daemon')) {
757
- items.push({
758
- id: 'runtime:daemon-stopped',
759
- status: 'fail',
760
- message: formatRuntimeStoppedFailureMessage(handler, request, 'daemon', externalState),
761
- target: 'service',
762
- });
763
- }
764
- if (posture.expectedHttpListener) {
765
- const httpListenerActive = isRuntimeEndpointActive(externalState, 'httpListener');
766
- items.push({
767
- id: 'runtime:http-listener-active',
768
- status: httpListenerActive ? 'pass' : 'fail',
769
- message: httpListenerActive
770
- ? formatRuntimeActiveSuccessMessage('httpListener', externalState)
771
- : formatRuntimeActiveFailureMessage(handler, request, 'httpListener', externalState),
772
- target: 'service',
773
- });
774
- }
775
- if (!posture.expectedHttpListener && isRuntimeEndpointOccupyingConfiguredPort(externalState, 'httpListener')) {
776
- items.push({
777
- id: 'runtime:http-listener-stopped',
778
- status: 'fail',
779
- message: formatRuntimeStoppedFailureMessage(handler, request, 'httpListener', externalState),
780
- target: 'service',
781
- });
782
- }
791
+ items.push(
792
+ ...buildEndpointVerificationItems(handler, request, 'daemon', posture.expectedDaemon, externalState),
793
+ ...buildEndpointVerificationItems(handler, request, 'httpListener', posture.expectedHttpListener, externalState),
794
+ );
783
795
 
784
796
  return items;
785
797
  }
@@ -22,13 +22,15 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
22
22
 
23
23
  if (token.type === 'key') {
24
24
  if (token.logicalName === 'escape') {
25
- if (state.modelPicker.searchFocused && state.modelPicker.mode !== 'contextCap') {
26
- if (state.modelPicker.query.length > 0) {
27
- state.modelPicker.clearQuery();
28
- } else {
29
- state.modelPicker.blurSearch();
30
- }
31
- } else if (state.modelPicker.query.length > 0) {
25
+ // W3-T2: search now starts focused by default (see
26
+ // ModelPickerModal.openAllModels() doc comment). Escape with an empty
27
+ // query used to only blurSearch() here, leaving the picker open and
28
+ // requiring a SECOND Escape to actually close it — confusing on a
29
+ // freshly-opened, untouched picker where there is nothing to "clear".
30
+ // Clearing a non-empty query is still a distinct first Escape (whether
31
+ // or not search is focused); an empty query falls through to the same
32
+ // effort/contextCap/previousMode/close cascade as before.
33
+ if (state.modelPicker.mode !== 'contextCap' && state.modelPicker.query.length > 0) {
32
34
  state.modelPicker.clearQuery();
33
35
  } else if (state.modelPicker.mode === 'effort') {
34
36
  state.modelPicker.mode = 'model';
@@ -129,7 +131,12 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
129
131
  }
130
132
  if (state.modelPicker.canFocusSearch() && !state.modelPicker.searchFocused && state.modelPicker.selectedIndex === 0) {
131
133
  state.modelPicker.focusSearch();
132
- } else if (!state.modelPicker.searchFocused) {
134
+ } else if (state.modelPicker.searchFocused) {
135
+ // Symmetric with 'down' (below): search now starts focused by default
136
+ // (W3-T2), so 'up' must have a way out of it too, or it would be a
137
+ // silent no-op the very first time a user reaches for the list.
138
+ state.modelPicker.blurSearch();
139
+ } else {
133
140
  const maxVis = Math.max(5, state.getViewportHeight() - MODEL_PICKER_CHROME_LINES - 4);
134
141
  state.modelPicker.moveUp(maxVis);
135
142
  }