@pellux/goodvibes-tui 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +17 -8
- package/package.json +1 -1
- package/src/cli/management.ts +80 -10
- package/src/core/long-task-notifier.ts +145 -0
- package/src/core/session-recovery.ts +147 -0
- package/src/core/stream-event-wiring.ts +77 -3
- package/src/core/transcript-journal.ts +339 -0
- package/src/core/turn-event-wiring.ts +67 -4
- package/src/input/commands/control-room-runtime.ts +0 -2
- package/src/input/commands/diff-runtime.ts +1 -1
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +23 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-runtime.ts +1 -2
- package/src/input/commands/memory-product-runtime.ts +2 -2
- package/src/input/commands/memory.ts +1 -1
- package/src/input/commands/onboarding-runtime.ts +0 -1
- package/src/input/commands/policy.ts +1 -1
- package/src/input/commands/profile-sync-runtime.ts +4 -3
- package/src/input/commands/provider.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +0 -1
- package/src/input/commands/session-content.ts +2 -2
- package/src/input/commands/session-workflow.ts +32 -2
- package/src/input/commands/session.ts +1 -1
- package/src/input/commands/settings-sync-runtime.ts +9 -9
- package/src/input/commands/shell-core.ts +2 -2
- package/src/input/commands/work-plan-runtime.ts +8 -8
- package/src/input/feed-context-factory.ts +6 -0
- package/src/input/handler-feed-routes.ts +19 -1
- package/src/input/handler-feed.ts +11 -0
- package/src/input/handler-prompt-buffer.ts +28 -0
- package/src/input/handler-shortcuts.ts +88 -2
- package/src/input/handler-ui-state.ts +2 -2
- package/src/input/handler.ts +39 -3
- package/src/input/keybindings.ts +33 -3
- package/src/input/kill-ring.ts +134 -0
- package/src/input/model-picker.ts +18 -1
- package/src/input/search.ts +18 -6
- package/src/input/settings-modal-activation.ts +134 -0
- package/src/input/settings-modal-adjustment.ts +124 -0
- package/src/input/settings-modal-data.ts +53 -0
- package/src/input/settings-modal.ts +48 -145
- package/src/main.ts +33 -33
- package/src/panels/base-panel.ts +2 -1
- package/src/panels/provider-health-domains.ts +3 -3
- package/src/panels/provider-health-panel.ts +13 -9
- package/src/panels/provider-health-tracker.ts +7 -4
- package/src/panels/settings-sync-panel.ts +3 -3
- package/src/panels/work-plan-panel.ts +2 -2
- package/src/renderer/diff-view.ts +2 -2
- package/src/renderer/help-overlay.ts +1 -0
- package/src/renderer/model-picker-overlay.ts +23 -11
- package/src/renderer/progress.ts +3 -3
- package/src/renderer/search-overlay.ts +8 -5
- package/src/renderer/settings-modal.ts +1 -1
- package/src/renderer/ui-factory.ts +11 -0
- package/src/runtime/bootstrap-hook-bridge.ts +18 -0
- package/src/runtime/bootstrap-shell.ts +1 -0
- package/src/shell/blocking-input.ts +32 -0
- package/src/shell/recovery-input-helpers.ts +71 -0
- package/src/utils/terminal-width.ts +10 -3
- package/src/version.ts +1 -1
package/src/input/handler.ts
CHANGED
|
@@ -76,10 +76,12 @@ import {
|
|
|
76
76
|
moveCursorVertical,
|
|
77
77
|
redoPromptState,
|
|
78
78
|
saveUndoState,
|
|
79
|
+
shouldCoalesceUndo,
|
|
79
80
|
undoPromptState,
|
|
80
81
|
wordWrapLine,
|
|
81
82
|
type WrappedPromptInfo,
|
|
82
83
|
} from './handler-prompt-buffer.ts';
|
|
84
|
+
import { KillRing } from './kill-ring.ts';
|
|
83
85
|
import { clearModalStack, handleEscape, modalOpened } from './handler-modal-stack.ts';
|
|
84
86
|
import { handleModalTokenRoutes } from './handler-modal-token-routes.ts';
|
|
85
87
|
import {
|
|
@@ -198,7 +200,15 @@ export class InputHandler implements InputHandlerLike {
|
|
|
198
200
|
// ── Undo / Redo ────────────────────────────────────────────────────────────
|
|
199
201
|
public undoStack: Array<{ prompt: string; cursorPos: number }> = [];
|
|
200
202
|
public redoStack: Array<{ prompt: string; cursorPos: number }> = [];
|
|
201
|
-
|
|
203
|
+
/** Maximum undo groups retained. Oldest are evicted when the limit is hit. */
|
|
204
|
+
public static readonly MAX_UNDO = 100;
|
|
205
|
+
/** Timestamp (Date.now()) of the last saveUndoState call, used for coalescing. */
|
|
206
|
+
public lastUndoMs = 0;
|
|
207
|
+
/** Edit kind of the last saveUndoState call, used for coalescing. */
|
|
208
|
+
public lastUndoKind: import('./handler-prompt-buffer.ts').EditKind = 'other';
|
|
209
|
+
|
|
210
|
+
// ── Kill ring ───────────────────────────────────────────────────────────────
|
|
211
|
+
public killRing = new KillRing();
|
|
202
212
|
|
|
203
213
|
// ── Path completion (Tab on path-like token) ───────────────────────────────
|
|
204
214
|
/** Current list of path completions cycling on repeated Tab presses. */
|
|
@@ -301,6 +311,7 @@ export class InputHandler implements InputHandlerLike {
|
|
|
301
311
|
conversationManager: this.conversationManager,
|
|
302
312
|
panelManager: this.uiServices.shell.panelManager,
|
|
303
313
|
keybindingsManager: this.uiServices.shell.keybindingsManager,
|
|
314
|
+
killRing: this.killRing,
|
|
304
315
|
getHistory: this.getHistory,
|
|
305
316
|
getViewportHeight: this.getViewportHeight,
|
|
306
317
|
getScrollTop: this.getScrollTop,
|
|
@@ -320,6 +331,8 @@ export class InputHandler implements InputHandlerLike {
|
|
|
320
331
|
handleRedo: () => { this.handleRedo(); this.syncFeedContextMutableFields(); },
|
|
321
332
|
handlePaste: () => { this.handlePaste(); this.syncFeedContextMutableFields(); },
|
|
322
333
|
saveUndoState: () => this.saveUndoState(),
|
|
334
|
+
saveUndoStateForText: () => this.saveUndoStateForText(),
|
|
335
|
+
breakUndoCoalesce: () => { this.lastUndoKind = 'other'; },
|
|
323
336
|
ensureInputCursorVisible: (contentWidth?: number) => this.ensureInputCursorVisible(contentWidth),
|
|
324
337
|
registerPaste: (content: string) => this.registerPaste(content),
|
|
325
338
|
executeBlockAction: (id: string) => this.executeBlockAction(id),
|
|
@@ -612,11 +625,34 @@ export class InputHandler implements InputHandlerLike {
|
|
|
612
625
|
// ── Undo / Redo methods ─────────────────────────────────────────────────
|
|
613
626
|
|
|
614
627
|
/**
|
|
615
|
-
* saveUndoState -
|
|
616
|
-
*
|
|
628
|
+
* saveUndoState - Unconditionally snapshot current prompt + cursor onto the
|
|
629
|
+
* undo stack (kill, yank, delete, or other non-text edits). Clears redo stack.
|
|
617
630
|
*/
|
|
618
631
|
public saveUndoState(): void {
|
|
619
632
|
saveUndoState(this.undoStack, this.redoStack, this.prompt, this.cursorPos, InputHandler.MAX_UNDO);
|
|
633
|
+
this.lastUndoMs = Date.now();
|
|
634
|
+
this.lastUndoKind = 'other';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* saveUndoStateForText - Snapshot with coalescing for plain text insertions.
|
|
639
|
+
* Consecutive text edits within UNDO_COALESCE_MS are merged into one group
|
|
640
|
+
* (the snapshot is skipped; the group absorbs the characters typed in the
|
|
641
|
+
* burst). Cursor moves, kill, and yank operations break the group.
|
|
642
|
+
*
|
|
643
|
+
* Call this instead of saveUndoState() from the text-insertion path only.
|
|
644
|
+
*/
|
|
645
|
+
public saveUndoStateForText(): void {
|
|
646
|
+
const now = Date.now();
|
|
647
|
+
if (shouldCoalesceUndo(this.lastUndoKind, 'text', this.lastUndoMs, now)) {
|
|
648
|
+
// Coalesce: skip the snapshot but update the timestamp so the window
|
|
649
|
+
// keeps sliding until the burst ends.
|
|
650
|
+
this.lastUndoMs = now;
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
saveUndoState(this.undoStack, this.redoStack, this.prompt, this.cursorPos, InputHandler.MAX_UNDO);
|
|
654
|
+
this.lastUndoMs = now;
|
|
655
|
+
this.lastUndoKind = 'text';
|
|
620
656
|
}
|
|
621
657
|
|
|
622
658
|
/**
|
package/src/input/keybindings.ts
CHANGED
|
@@ -51,7 +51,13 @@ export type KeyAction =
|
|
|
51
51
|
| 'undo'
|
|
52
52
|
| 'redo'
|
|
53
53
|
| 'paste'
|
|
54
|
-
| 'replay-panel'
|
|
54
|
+
| 'replay-panel'
|
|
55
|
+
| 'word-back'
|
|
56
|
+
| 'word-forward'
|
|
57
|
+
| 'kill-to-start'
|
|
58
|
+
| 'kill-word-forward'
|
|
59
|
+
| 'yank'
|
|
60
|
+
| 'yank-pop';
|
|
55
61
|
|
|
56
62
|
/** Human-readable description for each action (used in /keybindings display). */
|
|
57
63
|
export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
|
|
@@ -72,11 +78,17 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
|
|
|
72
78
|
'apply-diff-line-start': 'Apply nearest diff / move to line start',
|
|
73
79
|
'next-error-line-end': 'Navigate to next error / move to line end',
|
|
74
80
|
'kill-line': 'Kill to end of line',
|
|
75
|
-
'clear-prompt': 'Clear the prompt',
|
|
81
|
+
'clear-prompt': 'Clear the entire prompt (Alt+U; kill-to-start owns Ctrl+U)',
|
|
76
82
|
'undo': 'Undo last prompt edit',
|
|
77
83
|
'redo': 'Redo last undone edit',
|
|
78
84
|
'paste': 'Paste from clipboard (image priority)',
|
|
79
85
|
'replay-panel': 'Open / close the Replay panel',
|
|
86
|
+
'word-back': 'Move cursor to start of previous word (Alt+B)',
|
|
87
|
+
'word-forward': 'Move cursor to end of next word (Alt+F)',
|
|
88
|
+
'kill-to-start': 'Kill from cursor to start of line into kill ring (Ctrl+U)',
|
|
89
|
+
'kill-word-forward': 'Kill word forward into kill ring (Alt+D)',
|
|
90
|
+
'yank': 'Yank (paste) from kill ring (Ctrl+Shift+Y)',
|
|
91
|
+
'yank-pop': 'Rotate kill ring and yank next entry (Alt+Y)',
|
|
80
92
|
};
|
|
81
93
|
|
|
82
94
|
/** Default key bindings for all actions. */
|
|
@@ -98,11 +110,29 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
|
|
|
98
110
|
'apply-diff-line-start': [{ key: 'a', ctrl: true }],
|
|
99
111
|
'next-error-line-end': [{ key: 'e', ctrl: true }],
|
|
100
112
|
'kill-line': [{ key: 'k', ctrl: true }],
|
|
101
|
-
|
|
113
|
+
// Alt+U: clear entire prompt. Ctrl+U is owned by kill-to-start (readline
|
|
114
|
+
// convention). Alt+U is unused by any other default and is representable by
|
|
115
|
+
// the tokenizer's { key, alt } combo form.
|
|
116
|
+
'clear-prompt': [{ key: 'u', alt: true }],
|
|
102
117
|
'undo': [{ key: 'z', ctrl: true }],
|
|
103
118
|
'redo': [{ key: 'z', ctrl: true, shift: true }],
|
|
104
119
|
'paste': [{ key: 'v', ctrl: true }],
|
|
105
120
|
'replay-panel': [{ key: 'r', ctrl: true, shift: true }],
|
|
121
|
+
// Word navigation (Alt+B / Alt+F — emacs readline standard)
|
|
122
|
+
'word-back': [{ key: 'b', alt: true }],
|
|
123
|
+
'word-forward': [{ key: 'f', alt: true }],
|
|
124
|
+
// Kill-ring operations.
|
|
125
|
+
// Note: 'kill-line' (Ctrl+K) kills to end; 'kill-to-start' (Ctrl+U) kills to start.
|
|
126
|
+
// 'clear-prompt' (Alt+U) clears the entire buffer regardless of cursor position.
|
|
127
|
+
// kill-to-start owns Ctrl+U (readline convention); clear-prompt uses Alt+U.
|
|
128
|
+
'kill-to-start': [{ key: 'u', ctrl: true }],
|
|
129
|
+
// Alt+D: kill word forward (no prior conflict)
|
|
130
|
+
'kill-word-forward': [{ key: 'd', alt: true }],
|
|
131
|
+
// Ctrl+Shift+Y: yank from kill ring.
|
|
132
|
+
// CONFLICT RESOLVED: Ctrl+Y was 'block-copy'; yank moved to Ctrl+Shift+Y.
|
|
133
|
+
'yank': [{ key: 'y', ctrl: true, shift: true }],
|
|
134
|
+
// Alt+Y: yank-pop (rotate ring after yank)
|
|
135
|
+
'yank-pop': [{ key: 'y', alt: true }],
|
|
106
136
|
};
|
|
107
137
|
|
|
108
138
|
/** Resolved overrides type: each key can be a single combo or array. */
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// KillRing — emacs/readline-compatible kill ring implementation.
|
|
3
|
+
//
|
|
4
|
+
// The ring holds up to MAX_ENTRIES text strings. Kill commands push to the
|
|
5
|
+
// head. Yank pastes from the current yank pointer (default: head). Yank-pop
|
|
6
|
+
// rotates the pointer one step further into the ring without adding a new
|
|
7
|
+
// entry. Consecutive yank-pops cycle through all ring entries.
|
|
8
|
+
//
|
|
9
|
+
// Word-boundary helpers are co-located here because they are used by both
|
|
10
|
+
// kill-word-back (Ctrl+W) and kill-word-forward (Alt+D).
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export const KILL_RING_MAX = 32;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* KillRing — bounded circular ring of killed text segments.
|
|
17
|
+
*
|
|
18
|
+
* All public mutation methods are pure-functional helpers at module level;
|
|
19
|
+
* this class owns the mutable state so the handler can hold a single ref.
|
|
20
|
+
*/
|
|
21
|
+
export class KillRing {
|
|
22
|
+
private entries: string[] = [];
|
|
23
|
+
/** Index into `entries` for the next yank. -1 means the ring is empty. */
|
|
24
|
+
private yankPointer = -1;
|
|
25
|
+
/** Whether the last edit action was a yank (enables yank-pop). */
|
|
26
|
+
public lastActionWasYank = false;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Push a text segment onto the head of the ring.
|
|
30
|
+
* Trims the ring to MAX_ENTRIES. Resets the yank pointer to 0 (head).
|
|
31
|
+
* Empty strings are silently ignored.
|
|
32
|
+
*/
|
|
33
|
+
push(text: string): void {
|
|
34
|
+
if (!text) return;
|
|
35
|
+
this.entries.unshift(text);
|
|
36
|
+
if (this.entries.length > KILL_RING_MAX) {
|
|
37
|
+
this.entries.length = KILL_RING_MAX;
|
|
38
|
+
}
|
|
39
|
+
this.yankPointer = 0;
|
|
40
|
+
this.lastActionWasYank = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Yank — return the entry at the current yank pointer.
|
|
45
|
+
* Returns '' if the ring is empty.
|
|
46
|
+
* Sets lastActionWasYank so a subsequent yank-pop is valid.
|
|
47
|
+
*/
|
|
48
|
+
yank(): string {
|
|
49
|
+
if (this.entries.length === 0) return '';
|
|
50
|
+
this.yankPointer = Math.max(0, Math.min(this.yankPointer, this.entries.length - 1));
|
|
51
|
+
this.lastActionWasYank = true;
|
|
52
|
+
return this.entries[this.yankPointer] ?? '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* YankPop — advance the yank pointer by one step (wrapping) and return the
|
|
57
|
+
* new entry. Only valid after a yank; if the ring has <=1 entry, returns the
|
|
58
|
+
* same string. Returns '' if the ring is empty.
|
|
59
|
+
*/
|
|
60
|
+
yankPop(): string {
|
|
61
|
+
if (this.entries.length === 0) return '';
|
|
62
|
+
this.yankPointer = (this.yankPointer + 1) % this.entries.length;
|
|
63
|
+
this.lastActionWasYank = true;
|
|
64
|
+
return this.entries[this.yankPointer] ?? '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when there is at least one entry in the ring. */
|
|
68
|
+
get hasEntries(): boolean {
|
|
69
|
+
return this.entries.length > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Current ring contents (newest first). Read-only snapshot for tests. */
|
|
73
|
+
getEntries(): readonly string[] {
|
|
74
|
+
return this.entries;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Reset yank state when the user makes a non-yank edit. */
|
|
78
|
+
clearYankState(): void {
|
|
79
|
+
this.lastActionWasYank = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Word boundary helpers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* isWordChar — true when the character is a "word" character.
|
|
89
|
+
* Word = letter (Unicode), digit, or underscore. This is the emacs/readline
|
|
90
|
+
* word boundary definition used for Alt+B, Alt+F, Ctrl+W, Alt+D.
|
|
91
|
+
*/
|
|
92
|
+
function isWordChar(ch: string): boolean {
|
|
93
|
+
return /[\p{L}\p{N}_]/u.test(ch);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* wordBoundaryBack — find the start of the word that the cursor is in, or the
|
|
98
|
+
* start of the previous word if the cursor is at a non-word character.
|
|
99
|
+
*
|
|
100
|
+
* Emacs Alt+B semantics:
|
|
101
|
+
* - Skip non-word chars backward
|
|
102
|
+
* - Skip word chars backward
|
|
103
|
+
* - Return resulting position
|
|
104
|
+
*
|
|
105
|
+
* Returns the new cursor position (>= 0).
|
|
106
|
+
*/
|
|
107
|
+
export function wordBoundaryBack(text: string, pos: number): number {
|
|
108
|
+
let p = pos;
|
|
109
|
+
// Skip non-word chars first (move past punctuation/spaces)
|
|
110
|
+
while (p > 0 && !isWordChar(text[p - 1]!)) p--;
|
|
111
|
+
// Then skip word chars (the word body)
|
|
112
|
+
while (p > 0 && isWordChar(text[p - 1]!)) p--;
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* wordBoundaryForward — find the position just past the end of the next word.
|
|
118
|
+
*
|
|
119
|
+
* Emacs Alt+F semantics:
|
|
120
|
+
* - Skip non-word chars forward
|
|
121
|
+
* - Skip word chars forward
|
|
122
|
+
* - Return resulting position
|
|
123
|
+
*
|
|
124
|
+
* Returns the new cursor position (<= text.length).
|
|
125
|
+
*/
|
|
126
|
+
export function wordBoundaryForward(text: string, pos: number): number {
|
|
127
|
+
let p = pos;
|
|
128
|
+
const len = text.length;
|
|
129
|
+
// Skip non-word chars first
|
|
130
|
+
while (p < len && !isWordChar(text[p]!)) p++;
|
|
131
|
+
// Then skip word chars
|
|
132
|
+
while (p < len && isWordChar(text[p]!)) p++;
|
|
133
|
+
return p;
|
|
134
|
+
}
|
|
@@ -67,7 +67,7 @@ export class ModelPickerModal {
|
|
|
67
67
|
constructor(
|
|
68
68
|
private readonly favoritesStore: Pick<FavoritesStore, 'getRecentModels'>,
|
|
69
69
|
private readonly benchmarkStore: Pick<BenchmarkStore, 'getBenchmarks'>,
|
|
70
|
-
private readonly providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog'>,
|
|
70
|
+
private readonly providerRegistry: Pick<ProviderRegistry, 'getSyntheticModelInfoFromCatalog' | 'getSyntheticCanonicalModels'>,
|
|
71
71
|
) {}
|
|
72
72
|
|
|
73
73
|
public active = false;
|
|
@@ -547,6 +547,23 @@ export class ModelPickerModal {
|
|
|
547
547
|
return this.providerRegistry.getSyntheticModelInfoFromCatalog(modelId);
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Returns the ordered backend ladder for a synthetic model.
|
|
552
|
+
* Each entry describes a real provider/model that the synthetic model routes to.
|
|
553
|
+
* Returns null when the model ID is not found in the synthetic catalog.
|
|
554
|
+
*/
|
|
555
|
+
getSyntheticChain(modelId: string): Array<{ position: number; provider: string; model: string; registryKey: string }> | null {
|
|
556
|
+
const canonical = this.providerRegistry.getSyntheticCanonicalModels();
|
|
557
|
+
const entry = canonical.find((m) => m.id === modelId);
|
|
558
|
+
if (!entry) return null;
|
|
559
|
+
return entry.backends.map((b, idx) => ({
|
|
560
|
+
position: idx,
|
|
561
|
+
provider: b.providerName,
|
|
562
|
+
model: b.modelId,
|
|
563
|
+
registryKey: b.registryKey ?? `${b.providerName}:${b.modelId}`,
|
|
564
|
+
}));
|
|
565
|
+
}
|
|
566
|
+
|
|
550
567
|
getBenchmarkEntry(model: ModelDefinition) {
|
|
551
568
|
return this.benchmarkStore.getBenchmarks(model.id) ?? this.benchmarkStore.getBenchmarks(model.displayName);
|
|
552
569
|
}
|
package/src/input/search.ts
CHANGED
|
@@ -17,6 +17,12 @@ export class SearchManager {
|
|
|
17
17
|
public query = '';
|
|
18
18
|
public matches: SearchMatch[] = [];
|
|
19
19
|
public currentMatch = 0;
|
|
20
|
+
/**
|
|
21
|
+
* Set to true when nextMatch/prevMatch wraps around the match list.
|
|
22
|
+
* Cleared on each navigation call before the wrap check, so it only
|
|
23
|
+
* reflects the most recent navigation step.
|
|
24
|
+
*/
|
|
25
|
+
public wrapAround = false;
|
|
20
26
|
|
|
21
27
|
/** Open search mode. */
|
|
22
28
|
open(): void {
|
|
@@ -25,6 +31,7 @@ export class SearchManager {
|
|
|
25
31
|
this.query = '';
|
|
26
32
|
this.matches = [];
|
|
27
33
|
this.currentMatch = 0;
|
|
34
|
+
this.wrapAround = false;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
/** Lock the query — switches from typing mode to navigation mode. */
|
|
@@ -49,6 +56,7 @@ export class SearchManager {
|
|
|
49
56
|
this.matches = [];
|
|
50
57
|
this.currentMatch = 0;
|
|
51
58
|
|
|
59
|
+
this.wrapAround = false;
|
|
52
60
|
if (query.length === 0) return;
|
|
53
61
|
|
|
54
62
|
const lowerQuery = query.toLowerCase();
|
|
@@ -68,16 +76,20 @@ export class SearchManager {
|
|
|
68
76
|
}
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
/** Jump to next match. */
|
|
79
|
+
/** Jump to next match. Wraps around; sets wrapAround when it does. */
|
|
72
80
|
nextMatch(): void {
|
|
73
|
-
if (this.matches.length === 0) return;
|
|
74
|
-
|
|
81
|
+
if (this.matches.length === 0) { this.wrapAround = false; return; }
|
|
82
|
+
const next = (this.currentMatch + 1) % this.matches.length;
|
|
83
|
+
this.wrapAround = next < this.currentMatch || (this.currentMatch === this.matches.length - 1);
|
|
84
|
+
this.currentMatch = next;
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
/** Jump to previous match. */
|
|
87
|
+
/** Jump to previous match. Wraps around; sets wrapAround when it does. */
|
|
78
88
|
prevMatch(): void {
|
|
79
|
-
if (this.matches.length === 0) return;
|
|
80
|
-
|
|
89
|
+
if (this.matches.length === 0) { this.wrapAround = false; return; }
|
|
90
|
+
const prev = (this.currentMatch - 1 + this.matches.length) % this.matches.length;
|
|
91
|
+
this.wrapAround = prev > this.currentMatch || (this.currentMatch === 0);
|
|
92
|
+
this.currentMatch = prev;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
/** Get the line number of the current match (for scroll). */
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings-modal-activation — pure action helpers for SettingsModal.
|
|
3
|
+
*
|
|
4
|
+
* These functions encapsulate the two activation/interaction operations:
|
|
5
|
+
* - activateSelected: toggle boolean, cycle enum, enter edit mode, launch pickers
|
|
6
|
+
* - handleSubscriptionLogoutKey: route a keypress through the logout confirm gate
|
|
7
|
+
*
|
|
8
|
+
* Each function takes its dependencies as explicit arguments rather than
|
|
9
|
+
* accessing class-level state directly, following the same pattern as
|
|
10
|
+
* settings-modal-reset.ts and settings-modal-mutations.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { handleConfirmInput } from '../panels/confirm-state.ts';
|
|
14
|
+
import type { FlagEntry, McpEntry, SettingEntry, SubscriptionEntry } from './settings-modal-types.ts';
|
|
15
|
+
import { buildMcpEntries, buildSubscriptionEntries } from './settings-modal-data.ts';
|
|
16
|
+
import { modelPickerLaunchForKey } from './settings-modal-behavior.ts';
|
|
17
|
+
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
18
|
+
import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
19
|
+
import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
|
|
20
|
+
import type { ModelPickerTarget } from './model-picker.ts';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// activateSelected
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface ActivateSelectedContext {
|
|
27
|
+
readonly currentCategory: string;
|
|
28
|
+
readonly configManager: { get(key: ConfigKey): unknown } | null;
|
|
29
|
+
getSelectedMcp(): McpEntry | null;
|
|
30
|
+
getSelectedSubscription(): SubscriptionEntry | null;
|
|
31
|
+
getSelected(): SettingEntry | null;
|
|
32
|
+
setValue(key: ConfigKey, value: unknown): void;
|
|
33
|
+
setEditingMode(value: boolean): void;
|
|
34
|
+
setEditBuffer(value: string): void;
|
|
35
|
+
setMcpAllowAllConfirmationTarget(value: string | null): void;
|
|
36
|
+
setSubscriptionLogoutConfirmationTarget(value: string | null): void;
|
|
37
|
+
setPendingSettingsPickerAction(value: 'tts-provider' | 'tts-voice' | null): void;
|
|
38
|
+
setPendingModelPickerTarget(value: ModelPickerTarget | null): void;
|
|
39
|
+
setPendingProviderModelPickerTarget(value: ModelPickerTarget | null): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function activateSelected(ctx: ActivateSelectedContext): void {
|
|
43
|
+
if (ctx.currentCategory === 'mcp') {
|
|
44
|
+
const entry = ctx.getSelectedMcp();
|
|
45
|
+
if (!entry) return;
|
|
46
|
+
ctx.setEditingMode(true);
|
|
47
|
+
ctx.setEditBuffer(entry.trustMode);
|
|
48
|
+
ctx.setMcpAllowAllConfirmationTarget(null);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (ctx.currentCategory === 'subscriptions') {
|
|
53
|
+
const entry = ctx.getSelectedSubscription();
|
|
54
|
+
if (!entry) return;
|
|
55
|
+
if (entry.state === 'active' || entry.state === 'pending') {
|
|
56
|
+
// First press: arm the confirm gate. Subsequent key handling routes
|
|
57
|
+
// through handleSubscriptionLogoutKey() before normal dispatch.
|
|
58
|
+
ctx.setSubscriptionLogoutConfirmationTarget(entry.provider);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const entry = ctx.getSelected();
|
|
64
|
+
if (!entry || !ctx.configManager) return;
|
|
65
|
+
|
|
66
|
+
const { setting } = entry;
|
|
67
|
+
|
|
68
|
+
// Delegate provider/model picker settings to the model picker UI
|
|
69
|
+
if (setting.key === 'tts.provider') {
|
|
70
|
+
ctx.setPendingSettingsPickerAction('tts-provider');
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (setting.key === 'tts.voice') {
|
|
74
|
+
ctx.setPendingSettingsPickerAction('tts-voice');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const pickerLaunch = modelPickerLaunchForKey(setting.key);
|
|
79
|
+
if (pickerLaunch !== null) {
|
|
80
|
+
if (pickerLaunch.flow === 'providerModel') {
|
|
81
|
+
ctx.setPendingProviderModelPickerTarget(pickerLaunch.target);
|
|
82
|
+
} else {
|
|
83
|
+
ctx.setPendingModelPickerTarget(pickerLaunch.target);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (setting.type === 'boolean') {
|
|
89
|
+
const newVal = !entry.currentValue;
|
|
90
|
+
ctx.setValue(setting.key as ConfigKey, newVal);
|
|
91
|
+
} else if (setting.type === 'enum' && setting.enumValues) {
|
|
92
|
+
const idx = setting.enumValues.indexOf(entry.currentValue as string);
|
|
93
|
+
const nextIdx = (idx + 1) % setting.enumValues.length;
|
|
94
|
+
ctx.setValue(setting.key as ConfigKey, setting.enumValues[nextIdx]);
|
|
95
|
+
} else if (setting.type === 'string' || setting.type === 'number') {
|
|
96
|
+
// Enter inline edit mode
|
|
97
|
+
ctx.setEditingMode(true);
|
|
98
|
+
ctx.setEditBuffer(String(entry.currentValue ?? ''));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// handleSubscriptionLogoutKey
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export interface HandleSubscriptionLogoutKeyContext {
|
|
107
|
+
readonly subscriptionLogoutConfirmationTarget: string | null;
|
|
108
|
+
readonly subscriptionManager: SubscriptionManager | null;
|
|
109
|
+
readonly serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'> | null;
|
|
110
|
+
setSubscriptionEntries(entries: SubscriptionEntry[]): void;
|
|
111
|
+
setSubscriptionLogoutConfirmationTarget(value: string | null): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function handleSubscriptionLogoutKey(
|
|
115
|
+
ctx: HandleSubscriptionLogoutKeyContext,
|
|
116
|
+
key: string,
|
|
117
|
+
): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
|
|
118
|
+
const target = ctx.subscriptionLogoutConfirmationTarget;
|
|
119
|
+
if (!target) return 'inactive';
|
|
120
|
+
const confirmState = { subject: target, label: target };
|
|
121
|
+
const result = handleConfirmInput(confirmState, key);
|
|
122
|
+
if (result === 'confirmed') {
|
|
123
|
+
ctx.subscriptionManager?.logout(target);
|
|
124
|
+
ctx.setSubscriptionEntries(buildSubscriptionEntries(ctx.subscriptionManager, ctx.serviceRegistry));
|
|
125
|
+
ctx.setSubscriptionLogoutConfirmationTarget(null);
|
|
126
|
+
} else if (result === 'cancelled') {
|
|
127
|
+
ctx.setSubscriptionLogoutConfirmationTarget(null);
|
|
128
|
+
}
|
|
129
|
+
// 'absorbed': confirm remains pending
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Re-export FlagEntry so callers need not import from two modules.
|
|
134
|
+
export type { FlagEntry };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings-modal-adjustment — pure adjustment helpers for SettingsModal.
|
|
3
|
+
*
|
|
4
|
+
* These functions encapsulate the two directional-adjustment operations:
|
|
5
|
+
* - adjustSelected: cycle enum/boolean/number values via left/right arrow keys
|
|
6
|
+
* - toggleSelectedFlag: toggle a feature flag between enabled and disabled
|
|
7
|
+
*
|
|
8
|
+
* Each function takes its dependencies as explicit arguments rather than
|
|
9
|
+
* accessing class-level state directly, following the same pattern as
|
|
10
|
+
* settings-modal-reset.ts and settings-modal-mutations.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
14
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
15
|
+
import type { FeatureFlagManager } from '@/runtime/index.ts';
|
|
16
|
+
import type { FlagState } from '@/runtime/index.ts';
|
|
17
|
+
import type { FlagEntry, McpEntry, SettingEntry } from './settings-modal-types.ts';
|
|
18
|
+
import { buildMcpEntries } from './settings-modal-data.ts';
|
|
19
|
+
import { getNumericAdjustmentMeta, roundToPrecision } from './settings-modal-behavior.ts';
|
|
20
|
+
import { applyFlagState } from './settings-modal-mutations.ts';
|
|
21
|
+
import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// adjustSelected
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface AdjustSelectedContext {
|
|
28
|
+
readonly editingMode: boolean;
|
|
29
|
+
readonly currentCategory: string;
|
|
30
|
+
readonly configManager: ConfigManager | null;
|
|
31
|
+
readonly featureFlagManager: FeatureFlagManager | null;
|
|
32
|
+
readonly mcpRegistry: McpRegistry | null;
|
|
33
|
+
getSelectedFlag(): FlagEntry | null;
|
|
34
|
+
getSelectedMcp(): McpEntry | null;
|
|
35
|
+
getSelected(): SettingEntry | null;
|
|
36
|
+
setValue(key: ConfigKey, value: unknown): void;
|
|
37
|
+
setMcpEntries(entries: McpEntry[]): void;
|
|
38
|
+
setMcpAllowAllConfirmationTarget(value: string | null): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function adjustSelected(
|
|
42
|
+
ctx: AdjustSelectedContext,
|
|
43
|
+
direction: 'left' | 'right',
|
|
44
|
+
step = 1,
|
|
45
|
+
): void {
|
|
46
|
+
if (ctx.editingMode) return;
|
|
47
|
+
|
|
48
|
+
if (ctx.currentCategory === 'flags') {
|
|
49
|
+
const flagEntry = ctx.getSelectedFlag();
|
|
50
|
+
if (!flagEntry || flagEntry.state === 'killed' || !ctx.featureFlagManager || !ctx.configManager) return;
|
|
51
|
+
const targetState: FlagState = direction === 'right' ? 'enabled' : 'disabled';
|
|
52
|
+
if (flagEntry.state !== targetState) applyFlagState(flagEntry, targetState, ctx.featureFlagManager, ctx.configManager);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (ctx.currentCategory === 'mcp') {
|
|
57
|
+
const entry = ctx.getSelectedMcp();
|
|
58
|
+
if (!entry || !ctx.mcpRegistry) return;
|
|
59
|
+
const modes: McpEntry['trustMode'][] = ['constrained', 'ask-on-risk', 'allow-all', 'blocked'];
|
|
60
|
+
const currentIndex = Math.max(0, modes.indexOf(entry.trustMode));
|
|
61
|
+
const nextIndex = direction === 'right'
|
|
62
|
+
? (currentIndex + 1) % modes.length
|
|
63
|
+
: (currentIndex - 1 + modes.length) % modes.length;
|
|
64
|
+
ctx.mcpRegistry.setServerTrustMode(entry.name, modes[nextIndex]!);
|
|
65
|
+
ctx.setMcpEntries(buildMcpEntries(ctx.mcpRegistry));
|
|
66
|
+
ctx.setMcpAllowAllConfirmationTarget(null);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entry = ctx.getSelected();
|
|
71
|
+
if (!entry || !ctx.configManager) return;
|
|
72
|
+
const { setting } = entry;
|
|
73
|
+
|
|
74
|
+
if (setting.type === 'boolean') {
|
|
75
|
+
ctx.setValue(setting.key as ConfigKey, direction === 'right');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (setting.type === 'enum' && setting.enumValues && setting.enumValues.length > 0) {
|
|
80
|
+
const currentIndex = Math.max(0, setting.enumValues.indexOf(String(entry.currentValue)));
|
|
81
|
+
const nextIndex = direction === 'right'
|
|
82
|
+
? (currentIndex + 1) % setting.enumValues.length
|
|
83
|
+
: (currentIndex - 1 + setting.enumValues.length) % setting.enumValues.length;
|
|
84
|
+
ctx.setValue(setting.key as ConfigKey, setting.enumValues[nextIndex]!);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (setting.type === 'number') {
|
|
89
|
+
const currentNumber = Number(entry.currentValue ?? 0);
|
|
90
|
+
if (!Number.isFinite(currentNumber)) return;
|
|
91
|
+
const adjustment = getNumericAdjustmentMeta(setting);
|
|
92
|
+
const delta = adjustment.step * step;
|
|
93
|
+
const rounded = roundToPrecision(currentNumber + (direction === 'right' ? delta : -delta), adjustment.precision);
|
|
94
|
+
const nextValue = Math.min(
|
|
95
|
+
adjustment.max ?? rounded,
|
|
96
|
+
Math.max(adjustment.min ?? rounded, rounded),
|
|
97
|
+
);
|
|
98
|
+
if (setting.validate && !setting.validate(nextValue)) return;
|
|
99
|
+
ctx.setValue(setting.key as ConfigKey, nextValue);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// toggleSelectedFlag
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export interface ToggleSelectedFlagContext {
|
|
108
|
+
readonly featureFlagManager: FeatureFlagManager | null;
|
|
109
|
+
readonly configManager: ConfigManager | null;
|
|
110
|
+
getSelectedFlag(): FlagEntry | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function toggleSelectedFlag(ctx: ToggleSelectedFlagContext): void {
|
|
114
|
+
const flagEntry = ctx.getSelectedFlag();
|
|
115
|
+
if (!flagEntry || !ctx.featureFlagManager || !ctx.configManager) return;
|
|
116
|
+
|
|
117
|
+
const { state } = flagEntry;
|
|
118
|
+
|
|
119
|
+
// Killed flags are blocked
|
|
120
|
+
if (state === 'killed') return;
|
|
121
|
+
|
|
122
|
+
const newState: FlagState = state === 'enabled' ? 'disabled' : 'enabled';
|
|
123
|
+
applyFlagState(flagEntry, newState, ctx.featureFlagManager, ctx.configManager);
|
|
124
|
+
}
|