@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.
- package/CHANGELOG.md +11 -0
- package/README.md +10 -9
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +2 -2
- package/src/config/credential-availability.ts +122 -0
- package/src/config/index.ts +15 -0
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +71 -14
- package/src/core/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +68 -1
- package/src/core/turn-cancellation.ts +7 -2
- package/src/core/turn-event-wiring.ts +10 -2
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +46 -4
- package/src/input/commands/codebase-runtime.ts +46 -6
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/health-runtime.ts +9 -1
- package/src/input/commands/memory.ts +68 -35
- package/src/input/commands/operator-panel-runtime.ts +31 -7
- package/src/input/commands/planning-runtime.ts +95 -6
- package/src/input/commands/qrcode-runtime.ts +25 -5
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/settings-sync-runtime.ts +15 -3
- package/src/input/commands/shell-core.ts +10 -1
- package/src/input/commands/workstream-runtime.ts +168 -18
- package/src/input/config-modal-types.ts +15 -1
- package/src/input/config-modal.ts +227 -12
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-command-route.ts +10 -3
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +43 -121
- package/src/input/handler-feed.ts +32 -4
- package/src/input/handler-modal-routes.ts +78 -13
- package/src/input/handler-modal-stack.ts +11 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +15 -8
- package/src/input/handler-shortcuts.ts +59 -9
- package/src/input/handler.ts +6 -1
- package/src/input/keybindings.ts +6 -5
- package/src/input/model-picker.ts +19 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +51 -2
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +28 -27
- package/src/panels/builtin/shared.ts +9 -3
- package/src/panels/fleet-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +89 -131
- package/src/panels/fleet-read-model.ts +43 -0
- package/src/panels/fleet-steer.ts +35 -2
- package/src/panels/fleet-stop.ts +29 -1
- package/src/panels/modals/keybindings-modal.ts +16 -1
- package/src/panels/modals/modal-theme.ts +35 -29
- package/src/panels/modals/pairing-modal.ts +25 -6
- package/src/panels/modals/planning-modal.ts +43 -21
- package/src/panels/modals/work-plan-modal.ts +28 -0
- package/src/panels/panel-manager.ts +15 -4
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -13
- package/src/panels/types.ts +24 -0
- package/src/permissions/prompt.ts +160 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +19 -5
- package/src/renderer/footer-tips.ts +5 -1
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +3 -1
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +18 -3
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +22 -3
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +6 -0
- package/src/runtime/bootstrap-command-parts.ts +8 -4
- package/src/runtime/bootstrap-core.ts +46 -24
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +19 -1
- package/src/runtime/bootstrap.ts +118 -5
- package/src/runtime/code-index-services.ts +25 -2
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +55 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +10 -0
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +12 -8
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +19 -3
- package/src/runtime/workstream-services.ts +160 -28
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +46 -3
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +42 -9
- package/src/utils/terminal-width.ts +52 -0
- 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
|
-
|
|
316
|
-
|
|
317
|
-
context
|
|
318
|
-
context.
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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:
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 (
|
|
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
|
}
|