@pellux/goodvibes-tui 0.22.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +17 -8
  3. package/package.json +1 -1
  4. package/src/cli/management-commands.ts +1 -1
  5. package/src/cli/management-utils.ts +352 -0
  6. package/src/cli/management.ts +116 -344
  7. package/src/cli/surface-command.ts +1 -1
  8. package/src/core/context-auto-compact.ts +43 -10
  9. package/src/core/conversation-rendering.ts +5 -2
  10. package/src/core/conversation-types.ts +24 -0
  11. package/src/core/conversation.ts +7 -12
  12. package/src/core/long-task-notifier.ts +145 -0
  13. package/src/core/session-recovery.ts +147 -0
  14. package/src/core/stream-event-wiring.ts +199 -7
  15. package/src/core/transcript-journal.ts +339 -0
  16. package/src/core/turn-event-wiring.ts +67 -4
  17. package/src/input/commands/channel-runtime.ts +139 -0
  18. package/src/input/commands/control-room-runtime.ts +0 -2
  19. package/src/input/commands/diff-runtime.ts +1 -1
  20. package/src/input/commands/eval.ts +1 -1
  21. package/src/input/commands/health-runtime.ts +23 -4
  22. package/src/input/commands/knowledge.ts +1 -1
  23. package/src/input/commands/local-runtime.ts +1 -2
  24. package/src/input/commands/memory-product-runtime.ts +2 -2
  25. package/src/input/commands/memory.ts +1 -1
  26. package/src/input/commands/onboarding-runtime.ts +0 -1
  27. package/src/input/commands/policy.ts +1 -1
  28. package/src/input/commands/profile-sync-runtime.ts +4 -3
  29. package/src/input/commands/provider.ts +1 -1
  30. package/src/input/commands/qrcode-runtime.ts +0 -1
  31. package/src/input/commands/runtime-services.ts +30 -1
  32. package/src/input/commands/session-content.ts +2 -2
  33. package/src/input/commands/session-workflow.ts +32 -2
  34. package/src/input/commands/session.ts +1 -1
  35. package/src/input/commands/settings-sync-runtime.ts +9 -9
  36. package/src/input/commands/share-runtime.ts +1 -1
  37. package/src/input/commands/shell-core.ts +56 -6
  38. package/src/input/commands/work-plan-runtime.ts +8 -8
  39. package/src/input/commands.ts +2 -0
  40. package/src/input/feed-context-factory.ts +6 -0
  41. package/src/input/handler-feed-routes.ts +19 -1
  42. package/src/input/handler-feed.ts +11 -0
  43. package/src/input/handler-prompt-buffer.ts +28 -0
  44. package/src/input/handler-shortcuts.ts +88 -2
  45. package/src/input/handler-ui-state.ts +2 -2
  46. package/src/input/handler.ts +39 -3
  47. package/src/input/keybindings.ts +33 -3
  48. package/src/input/kill-ring.ts +134 -0
  49. package/src/input/model-picker.ts +18 -1
  50. package/src/input/search.ts +18 -6
  51. package/src/input/settings-modal-activation.ts +134 -0
  52. package/src/input/settings-modal-adjustment.ts +124 -0
  53. package/src/input/settings-modal-data.ts +53 -0
  54. package/src/input/settings-modal.ts +48 -145
  55. package/src/main.ts +50 -50
  56. package/src/panels/base-panel.ts +2 -1
  57. package/src/panels/provider-health-domains.ts +3 -3
  58. package/src/panels/provider-health-panel.ts +13 -9
  59. package/src/panels/provider-health-tracker.ts +7 -4
  60. package/src/panels/settings-sync-panel.ts +3 -3
  61. package/src/panels/work-plan-panel.ts +2 -2
  62. package/src/renderer/compaction-history-modal.ts +55 -0
  63. package/src/renderer/compaction-preview.ts +146 -0
  64. package/src/renderer/diff-view.ts +2 -2
  65. package/src/renderer/help-overlay.ts +1 -0
  66. package/src/renderer/model-picker-overlay.ts +23 -11
  67. package/src/renderer/progress.ts +3 -3
  68. package/src/renderer/search-overlay.ts +8 -5
  69. package/src/renderer/settings-modal-helpers.ts +2 -2
  70. package/src/renderer/settings-modal.ts +1 -1
  71. package/src/renderer/ui-factory.ts +11 -0
  72. package/src/runtime/bootstrap-core.ts +92 -0
  73. package/src/runtime/bootstrap-hook-bridge.ts +18 -0
  74. package/src/runtime/bootstrap-shell.ts +1 -0
  75. package/src/shell/blocking-input.ts +32 -0
  76. package/src/shell/recovery-input-helpers.ts +71 -0
  77. package/src/utils/browser.ts +29 -0
  78. package/src/utils/terminal-width.ts +10 -3
  79. package/src/version.ts +1 -1
@@ -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
- this.currentMatch = (this.currentMatch + 1) % this.matches.length;
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
- this.currentMatch = (this.currentMatch - 1 + this.matches.length) % this.matches.length;
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
+ }
@@ -122,6 +122,14 @@ export function buildSettingGroups(
122
122
  ttsEntries.push(buildTtsSpeedSyntheticEntry(configManager));
123
123
  }
124
124
 
125
+ // Inject the synthetic behavior.notifyAfterSeconds entry into the behavior
126
+ // category. This key is TUI-local (not in the SDK ConfigKey union) and
127
+ // controls the long-task push notification threshold.
128
+ const behaviorEntries = groups.get('behavior');
129
+ if (behaviorEntries && !behaviorEntries.some((e) => e.setting.key === ('behavior.notifyAfterSeconds' as ConfigKey))) {
130
+ behaviorEntries.push(buildNotifyAfterSecondsSyntheticEntry(configManager));
131
+ }
132
+
125
133
  return groups;
126
134
  }
127
135
 
@@ -171,6 +179,51 @@ export function buildTtsSpeedSyntheticEntry(configManager: Pick<ConfigManager, '
171
179
  };
172
180
  }
173
181
 
182
+ // ---------------------------------------------------------------------------
183
+ // behavior.notifyAfterSeconds synthetic setting
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /** Default threshold in seconds for the synthetic notifyAfterSeconds setting. */
187
+ export const NOTIFY_AFTER_SECONDS_DEFAULT_SETTING = 60;
188
+
189
+ /**
190
+ * The synthetic ConfigSetting descriptor for behavior.notifyAfterSeconds.
191
+ *
192
+ * This key is TUI-local and is not yet in the SDK ConfigKey union. The
193
+ * descriptor is injected into the behavior settings group so users can
194
+ * configure the long-task push notification threshold from /config behavior.
195
+ *
196
+ * 0 = off (no notifications). Any positive integer = threshold in seconds.
197
+ * Default 60s matches the default in long-task-notifier.ts.
198
+ *
199
+ * The key is cast to ConfigKey because ConfigSetting requires it. The cast
200
+ * is safe: configManager.get returns undefined for unknown keys rather than
201
+ * throwing.
202
+ */
203
+ export const NOTIFY_AFTER_SECONDS_SYNTHETIC_SETTING: ConfigSetting = {
204
+ key: 'behavior.notifyAfterSeconds' as ConfigKey,
205
+ type: 'number',
206
+ default: NOTIFY_AFTER_SECONDS_DEFAULT_SETTING,
207
+ description: 'Seconds a turn must run before a push notification fires (0 = off). Delivers to desktop (notify-send/osascript) and configured ntfy/webhook URLs.',
208
+ };
209
+
210
+ /**
211
+ * Build the synthetic SettingEntry for behavior.notifyAfterSeconds.
212
+ *
213
+ * Reads the raw value from configManager using a cast key. Falls back to
214
+ * NOTIFY_AFTER_SECONDS_DEFAULT_SETTING when absent or invalid.
215
+ */
216
+ export function buildNotifyAfterSecondsSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
217
+ const raw = configManager.get('behavior.notifyAfterSeconds' as ConfigKey);
218
+ const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
219
+ const currentValue: number = Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : NOTIFY_AFTER_SECONDS_DEFAULT_SETTING;
220
+ return {
221
+ setting: NOTIFY_AFTER_SECONDS_SYNTHETIC_SETTING,
222
+ currentValue,
223
+ isDefault: currentValue === NOTIFY_AFTER_SECONDS_DEFAULT_SETTING,
224
+ };
225
+ }
226
+
174
227
  // ---------------------------------------------------------------------------
175
228
  // buildFlagEntries — snapshot of current feature flag states
176
229
  // ---------------------------------------------------------------------------
@@ -14,23 +14,16 @@
14
14
  */
15
15
 
16
16
  import { type ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
17
- import { handleConfirmInput } from '../panels/confirm-state.ts';
18
17
  import type { ModelPickerTarget } from './model-picker.ts';
19
18
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
19
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
21
20
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
22
21
  import { isSecretConfigKey } from '../config/secret-config.ts';
23
- import {
24
- getNumericAdjustmentMeta,
25
- modelPickerLaunchForKey,
26
- roundToPrecision,
27
- } from './settings-modal-behavior.ts';
28
22
  import {
29
23
  setSecretBackedSettingValue,
30
24
  type SettingsSecretsManager,
31
25
  } from './settings-modal-secrets.ts';
32
26
  import type { FeatureFlagManager } from '@/runtime/index.ts';
33
- import type { FlagState } from '@/runtime/index.ts';
34
27
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
35
28
 
36
29
  import {
@@ -50,16 +43,21 @@ import {
50
43
  buildSubscriptionEntries,
51
44
  buildNetworkFilteredItems,
52
45
  refreshEntryValues,
53
- updateEntryForKey,
54
46
  searchSettingEntries,
55
47
  } from './settings-modal-data.ts';
56
48
  import { getSettingLabel } from '../renderer/settings-modal-helpers.ts';
57
49
  import {
58
50
  applySettingValue,
59
- applyFlagState,
60
- persistFlagState,
61
51
  type SettingAppliedCallback,
62
52
  } from './settings-modal-mutations.ts';
53
+ import {
54
+ activateSelected as _activateSelected,
55
+ handleSubscriptionLogoutKey as _handleSubscriptionLogoutKey,
56
+ } from './settings-modal-activation.ts';
57
+ import {
58
+ adjustSelected as _adjustSelected,
59
+ toggleSelectedFlag as _toggleSelectedFlag,
60
+ } from './settings-modal-adjustment.ts';
63
61
  import {
64
62
  resetSelected as _resetSelected,
65
63
  initiateResetCategory as _initiateResetCategory,
@@ -436,63 +434,21 @@ export class SettingsModal {
436
434
  * Toggle boolean or begin cycling enum values, or enter edit mode for string/number.
437
435
  */
438
436
  activateSelected(): void {
439
- if (this.currentCategory === 'mcp') {
440
- const entry = this.getSelectedMcp();
441
- if (!entry) return;
442
- this.editingMode = true;
443
- this.editBuffer = entry.trustMode;
444
- this.mcpAllowAllConfirmationTarget = null;
445
- return;
446
- }
447
-
448
- if (this.currentCategory === 'subscriptions') {
449
- const entry = this.getSelectedSubscription();
450
- if (!entry) return;
451
- if (entry.state === 'active' || entry.state === 'pending') {
452
- // First press: arm the confirm gate. Subsequent key handling routes
453
- // through handleSubscriptionLogoutKey() before normal dispatch.
454
- this.subscriptionLogoutConfirmationTarget = entry.provider;
455
- }
456
- return;
457
- }
458
-
459
- const entry = this.getSelected();
460
- if (!entry || !this.configManager) return;
461
-
462
- const { setting } = entry;
463
-
464
- // Delegate provider/model picker settings to the model picker UI
465
- if (setting.key === 'tts.provider') {
466
- this.pendingSettingsPickerAction = 'tts-provider';
467
- return;
468
- }
469
- if (setting.key === 'tts.voice') {
470
- this.pendingSettingsPickerAction = 'tts-voice';
471
- return;
472
- }
473
-
474
- const pickerLaunch = modelPickerLaunchForKey(setting.key);
475
- if (pickerLaunch !== null) {
476
- if (pickerLaunch.flow === 'providerModel') {
477
- this.pendingProviderModelPickerTarget = pickerLaunch.target;
478
- } else {
479
- this.pendingModelPickerTarget = pickerLaunch.target;
480
- }
481
- return;
482
- }
483
-
484
- if (setting.type === 'boolean') {
485
- const newVal = !entry.currentValue;
486
- this._setValue(setting.key as ConfigKey, newVal);
487
- } else if (setting.type === 'enum' && setting.enumValues) {
488
- const idx = setting.enumValues.indexOf(entry.currentValue as string);
489
- const nextIdx = (idx + 1) % setting.enumValues.length;
490
- this._setValue(setting.key as ConfigKey, setting.enumValues[nextIdx]);
491
- } else if (setting.type === 'string' || setting.type === 'number') {
492
- // Enter inline edit mode
493
- this.editingMode = true;
494
- this.editBuffer = String(entry.currentValue ?? '');
495
- }
437
+ _activateSelected({
438
+ currentCategory: this.currentCategory,
439
+ configManager: this.configManager,
440
+ getSelectedMcp: () => this.getSelectedMcp(),
441
+ getSelectedSubscription: () => this.getSelectedSubscription(),
442
+ getSelected: () => this.getSelected(),
443
+ setValue: (key, value) => this._setValue(key, value),
444
+ setEditingMode: (v) => { this.editingMode = v; },
445
+ setEditBuffer: (v) => { this.editBuffer = v; },
446
+ setMcpAllowAllConfirmationTarget: (v) => { this.mcpAllowAllConfirmationTarget = v; },
447
+ setSubscriptionLogoutConfirmationTarget: (v) => { this.subscriptionLogoutConfirmationTarget = v; },
448
+ setPendingSettingsPickerAction: (v) => { this.pendingSettingsPickerAction = v; },
449
+ setPendingModelPickerTarget: (v) => { this.pendingModelPickerTarget = v; },
450
+ setPendingProviderModelPickerTarget: (v) => { this.pendingProviderModelPickerTarget = v; },
451
+ });
496
452
  }
497
453
 
498
454
  /**
@@ -505,77 +461,29 @@ export class SettingsModal {
505
461
  * - INACTIVE: no confirm pending → returns 'inactive' (caller continues)
506
462
  */
507
463
  handleSubscriptionLogoutKey(key: string): 'confirmed' | 'cancelled' | 'absorbed' | 'inactive' {
508
- const target = this.subscriptionLogoutConfirmationTarget;
509
- if (!target) return 'inactive';
510
- const confirmState = { subject: target, label: target };
511
- const result = handleConfirmInput(confirmState, key);
512
- if (result === 'confirmed') {
513
- this.subscriptionManager?.logout(target);
514
- this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
515
- this.subscriptionLogoutConfirmationTarget = null;
516
- } else if (result === 'cancelled') {
517
- this.subscriptionLogoutConfirmationTarget = null;
518
- }
519
- // 'absorbed': confirm remains pending
520
- return result;
464
+ return _handleSubscriptionLogoutKey({
465
+ subscriptionLogoutConfirmationTarget: this.subscriptionLogoutConfirmationTarget,
466
+ subscriptionManager: this.subscriptionManager,
467
+ serviceRegistry: this.serviceRegistry,
468
+ setSubscriptionEntries: (entries) => { this.subscriptionEntries = entries; },
469
+ setSubscriptionLogoutConfirmationTarget: (v) => { this.subscriptionLogoutConfirmationTarget = v; },
470
+ }, key);
521
471
  }
522
472
 
523
473
  adjustSelected(direction: 'left' | 'right', step = 1): void {
524
- if (this.editingMode) return;
525
-
526
- if (this.currentCategory === 'flags') {
527
- const flagEntry = this.getSelectedFlag();
528
- if (!flagEntry || flagEntry.state === 'killed' || !this.featureFlagManager || !this.configManager) return;
529
- const targetState: FlagState = direction === 'right' ? 'enabled' : 'disabled';
530
- if (flagEntry.state !== targetState) applyFlagState(flagEntry, targetState, this.featureFlagManager, this.configManager);
531
- return;
532
- }
533
-
534
- if (this.currentCategory === 'mcp') {
535
- const entry = this.getSelectedMcp();
536
- if (!entry || !this.mcpRegistry) return;
537
- const modes: McpEntry['trustMode'][] = ['constrained', 'ask-on-risk', 'allow-all', 'blocked'];
538
- const currentIndex = Math.max(0, modes.indexOf(entry.trustMode));
539
- const nextIndex = direction === 'right'
540
- ? (currentIndex + 1) % modes.length
541
- : (currentIndex - 1 + modes.length) % modes.length;
542
- this.mcpRegistry.setServerTrustMode(entry.name, modes[nextIndex]!);
543
- this.mcpEntries = buildMcpEntries(this.mcpRegistry);
544
- this.mcpAllowAllConfirmationTarget = null;
545
- return;
546
- }
547
-
548
- const entry = this.getSelected();
549
- if (!entry || !this.configManager) return;
550
- const { setting } = entry;
551
-
552
- if (setting.type === 'boolean') {
553
- this._setValue(setting.key as ConfigKey, direction === 'right');
554
- return;
555
- }
556
-
557
- if (setting.type === 'enum' && setting.enumValues && setting.enumValues.length > 0) {
558
- const currentIndex = Math.max(0, setting.enumValues.indexOf(String(entry.currentValue)));
559
- const nextIndex = direction === 'right'
560
- ? (currentIndex + 1) % setting.enumValues.length
561
- : (currentIndex - 1 + setting.enumValues.length) % setting.enumValues.length;
562
- this._setValue(setting.key as ConfigKey, setting.enumValues[nextIndex]!);
563
- return;
564
- }
565
-
566
- if (setting.type === 'number') {
567
- const currentNumber = Number(entry.currentValue ?? 0);
568
- if (!Number.isFinite(currentNumber)) return;
569
- const adjustment = getNumericAdjustmentMeta(setting);
570
- const delta = adjustment.step * step;
571
- const rounded = roundToPrecision(currentNumber + (direction === 'right' ? delta : -delta), adjustment.precision);
572
- const nextValue = Math.min(
573
- adjustment.max ?? rounded,
574
- Math.max(adjustment.min ?? rounded, rounded),
575
- );
576
- if (setting.validate && !setting.validate(nextValue)) return;
577
- this._setValue(setting.key as ConfigKey, nextValue);
578
- }
474
+ _adjustSelected({
475
+ editingMode: this.editingMode,
476
+ currentCategory: this.currentCategory,
477
+ configManager: this.configManager,
478
+ featureFlagManager: this.featureFlagManager,
479
+ mcpRegistry: this.mcpRegistry,
480
+ getSelectedFlag: () => this.getSelectedFlag(),
481
+ getSelectedMcp: () => this.getSelectedMcp(),
482
+ getSelected: () => this.getSelected(),
483
+ setValue: (key, value) => this._setValue(key, value),
484
+ setMcpEntries: (entries) => { this.mcpEntries = entries; },
485
+ setMcpAllowAllConfirmationTarget: (v) => { this.mcpAllowAllConfirmationTarget = v; },
486
+ }, direction, step);
579
487
  }
580
488
 
581
489
  /**
@@ -585,16 +493,11 @@ export class SettingsModal {
585
493
  * only (require restart). runtimeToggleable flags toggle immediately.
586
494
  */
587
495
  toggleSelectedFlag(): void {
588
- const flagEntry = this.getSelectedFlag();
589
- if (!flagEntry || !this.featureFlagManager || !this.configManager) return;
590
-
591
- const { state } = flagEntry;
592
-
593
- // Killed flags are blocked
594
- if (state === 'killed') return;
595
-
596
- const newState: FlagState = state === 'enabled' ? 'disabled' : 'enabled';
597
- applyFlagState(flagEntry, newState, this.featureFlagManager, this.configManager);
496
+ _toggleSelectedFlag({
497
+ featureFlagManager: this.featureFlagManager,
498
+ configManager: this.configManager,
499
+ getSelectedFlag: () => this.getSelectedFlag(),
500
+ });
598
501
  }
599
502
 
600
503
  /**