@pellux/goodvibes-tui 0.20.3 → 0.22.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 +50 -0
- package/README.md +23 -2
- package/docs/foundation-artifacts/operator-contract.json +78 -1
- package/package.json +4 -2
- package/src/audio/spoken-turn-controller.ts +31 -1
- package/src/audio/spoken-turn-wiring.ts +26 -4
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/completions/generate.ts +658 -0
- package/src/cli/config-overrides.ts +68 -0
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/help.ts +4 -2
- package/src/cli/management-commands.ts +1 -1
- package/src/cli/management.ts +1 -8
- package/src/cli/parser.ts +31 -18
- package/src/cli/service-command.ts +1 -1
- package/src/cli/surface-command.ts +1 -1
- package/src/cli/tui-startup.ts +72 -10
- package/src/cli/types.ts +14 -3
- package/src/cli-flags.ts +1 -0
- package/src/config/atomic-write.ts +70 -0
- package/src/config/goodvibes-home-audit.ts +2 -0
- package/src/config/read-versioned.ts +115 -0
- package/src/core/context-auto-compact.ts +77 -0
- package/src/core/conversation-rendering.ts +49 -15
- package/src/core/conversation.ts +101 -16
- package/src/core/format-user-error.ts +192 -0
- package/src/core/stream-event-wiring.ts +144 -0
- package/src/core/stream-stall-watchdog.ts +103 -0
- package/src/core/system-message-router.ts +5 -1
- package/src/core/turn-event-wiring.ts +124 -0
- package/src/daemon/cli.ts +5 -0
- package/src/export/cost-utils.ts +71 -0
- package/src/export/gist-uploader.ts +136 -0
- package/src/input/command-registry.ts +32 -1
- package/src/input/commands/control-room-runtime.ts +10 -10
- package/src/input/commands/experience-runtime.ts +5 -4
- package/src/input/commands/knowledge.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +27 -5
- package/src/input/commands/local-setup.ts +4 -6
- package/src/input/commands/memory-product-runtime.ts +8 -6
- package/src/input/commands/operator-panel-runtime.ts +1 -1
- package/src/input/commands/operator-runtime.ts +3 -10
- package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
- package/src/input/commands/provider.ts +57 -3
- package/src/input/commands/recall-review.ts +26 -2
- package/src/input/commands/services-runtime.ts +2 -2
- package/src/input/commands/session-workflow.ts +8 -16
- package/src/input/commands/session.ts +70 -20
- package/src/input/commands/share-runtime.ts +99 -12
- package/src/input/commands/tts-runtime.ts +30 -4
- package/src/input/commands.ts +2 -4
- package/src/input/delete-key-policy.ts +46 -0
- package/src/input/feed-context-factory.ts +2 -0
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +2 -15
- package/src/input/handler-modal-routes.ts +128 -12
- package/src/input/handler-modal-token-routes.ts +22 -5
- package/src/input/handler-onboarding-cloudflare.ts +1 -1
- package/src/input/handler-onboarding.ts +73 -69
- package/src/input/handler-types.ts +163 -0
- package/src/input/handler.ts +6 -2
- package/src/input/input-history.ts +76 -6
- package/src/input/model-picker-filter.ts +265 -0
- package/src/input/model-picker-items.ts +208 -0
- package/src/input/model-picker.ts +92 -325
- package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
- package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
- package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
- package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
- package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
- package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
- package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
- package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
- package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
- package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
- package/src/input/onboarding/onboarding-wizard.ts +3 -3
- package/src/input/settings-modal-behavior.ts +5 -0
- package/src/input/settings-modal-data.ts +378 -0
- package/src/input/settings-modal-mutations.ts +157 -0
- package/src/input/settings-modal-reset.ts +154 -0
- package/src/input/settings-modal.ts +236 -232
- package/src/main.ts +93 -85
- package/src/panels/agent-inspector-panel.ts +120 -18
- package/src/panels/agent-inspector-shared.ts +29 -0
- package/src/panels/builtin/agent.ts +4 -1
- package/src/panels/builtin/development.ts +5 -1
- package/src/panels/builtin/knowledge.ts +14 -13
- package/src/panels/builtin/operations.ts +22 -1
- package/src/panels/builtin/shared.ts +7 -0
- package/src/panels/cockpit-panel.ts +123 -3
- package/src/panels/cockpit-read-model.ts +232 -0
- package/src/panels/confirm-state.ts +27 -12
- package/src/panels/cost-tracker-panel.ts +23 -67
- package/src/panels/eval-panel.ts +10 -9
- package/src/panels/index.ts +1 -1
- package/src/panels/knowledge-graph-panel.ts +84 -0
- package/src/panels/local-auth-panel.ts +124 -4
- package/src/panels/memory-panel.ts +370 -40
- package/src/panels/project-planning-panel.ts +42 -4
- package/src/panels/search-focus.ts +11 -5
- package/src/panels/session-maintenance.ts +66 -15
- package/src/panels/subscription-panel.ts +33 -25
- package/src/panels/types.ts +28 -1
- package/src/panels/wrfc-panel.ts +224 -41
- package/src/renderer/agent-detail-modal.ts +118 -13
- package/src/renderer/code-block.ts +10 -2
- package/src/renderer/compositor.ts +18 -4
- package/src/renderer/context-inspector.ts +1 -5
- package/src/renderer/context-status-hint.ts +54 -0
- package/src/renderer/diff.ts +94 -21
- package/src/renderer/markdown.ts +29 -13
- package/src/renderer/settings-modal-helpers.ts +1 -1
- package/src/renderer/settings-modal.ts +90 -10
- package/src/renderer/shell-surface.ts +10 -0
- package/src/renderer/syntax-highlighter.ts +10 -3
- package/src/renderer/term-caps.ts +318 -0
- package/src/renderer/theme.ts +158 -0
- package/src/renderer/tool-call.ts +12 -2
- package/src/renderer/ui-factory.ts +50 -6
- package/src/runtime/bootstrap-command-context.ts +1 -0
- package/src/runtime/bootstrap-command-parts.ts +18 -0
- package/src/runtime/bootstrap-core.ts +145 -13
- package/src/runtime/bootstrap-shell.ts +11 -0
- package/src/runtime/bootstrap.ts +9 -0
- package/src/runtime/onboarding/apply.ts +4 -6
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/markers.ts +42 -49
- package/src/runtime/onboarding/progress.ts +148 -0
- package/src/runtime/onboarding/state.ts +133 -55
- package/src/runtime/onboarding/types.ts +20 -0
- package/src/runtime/services.ts +27 -1
- package/src/runtime/wrfc-persistence.ts +237 -0
- package/src/shell/blocking-input.ts +20 -5
- package/src/tools/wrfc-agent-guard.ts +64 -3
- package/src/utils/format-elapsed.ts +30 -0
- package/src/utils/terminal-width.ts +45 -0
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +4 -6
- package/src/panels/knowledge-panel.ts +0 -345
- package/src/planning/project-planning-coordinator.ts +0 -543
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings-modal-data — pure data-assembly helpers for SettingsModal.
|
|
3
|
+
*
|
|
4
|
+
* All functions are stateless: they take dependencies as arguments and return
|
|
5
|
+
* derived data without mutating state. The class in settings-modal.ts delegates
|
|
6
|
+
* to these during open() and tab-switch operations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CONFIG_SCHEMA, type ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
10
|
+
import type { ConfigManager, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
|
|
11
|
+
import { getResolvedSettingLookup } from '@/runtime/index.ts';
|
|
12
|
+
import type { FeatureFlagManager } from '@/runtime/index.ts';
|
|
13
|
+
import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
|
|
14
|
+
import { buildSubscriptionEntries } from './settings-modal-subscriptions.ts';
|
|
15
|
+
import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
16
|
+
import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
|
|
17
|
+
import {
|
|
18
|
+
SETTINGS_CATEGORIES,
|
|
19
|
+
type FlagEntry,
|
|
20
|
+
type McpEntry,
|
|
21
|
+
type SettingEntry,
|
|
22
|
+
type SettingsCategory,
|
|
23
|
+
type SubscriptionEntry,
|
|
24
|
+
} from './settings-modal-types.ts';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// deepEqual — structural equality for isDefault comparisons
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Structural equality check for setting default comparisons.
|
|
32
|
+
* Handles scalars, arrays, and plain objects. Does NOT support
|
|
33
|
+
* circular references or non-plain prototypes — config defaults
|
|
34
|
+
* are always JSON-safe primitives, arrays, or plain objects.
|
|
35
|
+
*/
|
|
36
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
37
|
+
if (a === b) return true;
|
|
38
|
+
if (a === null || b === null) return false;
|
|
39
|
+
if (a === undefined || b === undefined) return false;
|
|
40
|
+
if (typeof a !== typeof b) return false;
|
|
41
|
+
if (typeof a !== 'object') return false;
|
|
42
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
43
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
44
|
+
if (a.length !== b.length) return false;
|
|
45
|
+
for (let i = 0; i < a.length; i++) {
|
|
46
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
const ao = a as Record<string, unknown>;
|
|
51
|
+
const bo = b as Record<string, unknown>;
|
|
52
|
+
const aKeys = Object.keys(ao);
|
|
53
|
+
const bKeys = Object.keys(bo);
|
|
54
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
55
|
+
for (const key of aKeys) {
|
|
56
|
+
if (!Object.prototype.hasOwnProperty.call(bo, key)) return false;
|
|
57
|
+
if (!deepEqual(ao[key], bo[key])) return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// buildSettingGroups — loads CONFIG_SCHEMA into per-category SettingEntry maps
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export function buildSettingGroups(
|
|
67
|
+
configManager: ConfigManager,
|
|
68
|
+
): Map<SettingsCategory, SettingEntry[]> {
|
|
69
|
+
const groups = new Map<SettingsCategory, SettingEntry[]>();
|
|
70
|
+
for (const cat of SETTINGS_CATEGORIES) {
|
|
71
|
+
if (cat === 'flags') continue;
|
|
72
|
+
groups.set(cat, []);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const setting of CONFIG_SCHEMA) {
|
|
76
|
+
const rawCat = setting.key.split('.')[0] as string;
|
|
77
|
+
const cat = rawCat as SettingsCategory;
|
|
78
|
+
const currentValue = configManager.get(setting.key as ConfigKey);
|
|
79
|
+
const resolved = getResolvedSettingLookup(configManager, setting.key as ConfigKey)?.entry;
|
|
80
|
+
const entry: SettingEntry = {
|
|
81
|
+
setting,
|
|
82
|
+
currentValue,
|
|
83
|
+
isDefault: deepEqual(currentValue, setting.default),
|
|
84
|
+
effectiveSource: resolved?.effectiveSource,
|
|
85
|
+
locked: resolved?.locked,
|
|
86
|
+
conflict: resolved?.conflict,
|
|
87
|
+
sourceLabel: resolved?.sourceLabel,
|
|
88
|
+
lockReason: resolved?.lockReason,
|
|
89
|
+
};
|
|
90
|
+
if (groups.has(cat)) groups.get(cat)!.push(entry);
|
|
91
|
+
if ((rawCat === 'controlPlane' || rawCat === 'httpListener' || rawCat === 'web') && groups.has('network')) {
|
|
92
|
+
groups.get('network')!.push(entry);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const uiEntries = groups.get('ui');
|
|
97
|
+
if (uiEntries) {
|
|
98
|
+
const uiPriority: Record<string, number> = {
|
|
99
|
+
'ui.systemMessages': 0,
|
|
100
|
+
'ui.operationalMessages': 1,
|
|
101
|
+
'ui.wrfcMessages': 2,
|
|
102
|
+
'ui.voiceEnabled': 3,
|
|
103
|
+
};
|
|
104
|
+
uiEntries.sort((a, b) => (uiPriority[a.setting.key] ?? 99) - (uiPriority[b.setting.key] ?? 99));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Cross-list ui.voiceEnabled into the 'tts' category so that /config tts
|
|
108
|
+
// shows the always-speak toggle alongside the other TTS settings.
|
|
109
|
+
const ttsEntries = groups.get('tts');
|
|
110
|
+
if (ttsEntries && uiEntries) {
|
|
111
|
+
const voiceEnabledEntry = uiEntries.find((e) => e.setting.key === 'ui.voiceEnabled');
|
|
112
|
+
if (voiceEnabledEntry && !ttsEntries.some((e) => e.setting.key === 'ui.voiceEnabled')) {
|
|
113
|
+
ttsEntries.unshift(voiceEnabledEntry);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Inject the synthetic tts.speed entry into the tts category.
|
|
118
|
+
// tts.speed is not yet a ConfigKey in the SDK schema (pending SDK addition).
|
|
119
|
+
// The entry is surfaced here with an honest description caveat so users can
|
|
120
|
+
// see and understand the setting before the SDK schema catches up.
|
|
121
|
+
if (ttsEntries && !ttsEntries.some((e) => e.setting.key === ('tts.speed' as ConfigKey))) {
|
|
122
|
+
ttsEntries.push(buildTtsSpeedSyntheticEntry(configManager));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return groups;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// TTS_SPEED_DEFAULT — the pending-SDK default for tts.speed
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Pending default for tts.speed. Matches the value the SDK will use once
|
|
134
|
+
* the schema field is added: 1 (normal speed, provider default).
|
|
135
|
+
* Used for the synthetic settings-modal entry and isDefault comparisons.
|
|
136
|
+
*/
|
|
137
|
+
export const TTS_SPEED_DEFAULT = 1;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The synthetic ConfigSetting descriptor for tts.speed.
|
|
141
|
+
* `tts.speed` is not yet a ConfigKey in the SDK schema. This descriptor is
|
|
142
|
+
* TUI-local and is injected into the tts settings group so users can see
|
|
143
|
+
* and interact with the setting before the SDK schema catches up.
|
|
144
|
+
*
|
|
145
|
+
* The key is cast to ConfigKey because ConfigSetting requires it and the SDK
|
|
146
|
+
* will add this key in a future release. The cast is safe: configManager.get
|
|
147
|
+
* returns undefined for unknown keys rather than throwing.
|
|
148
|
+
*/
|
|
149
|
+
export const TTS_SPEED_SYNTHETIC_SETTING: ConfigSetting = {
|
|
150
|
+
key: 'tts.speed' as ConfigKey,
|
|
151
|
+
type: 'number',
|
|
152
|
+
default: TTS_SPEED_DEFAULT,
|
|
153
|
+
description: 'Playback speed multiplier passed to the TTS provider (1.0 = normal). Takes effect immediately via the TUI bridge; SDK schema registration is pending (native typing only).',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build the synthetic SettingEntry for tts.speed.
|
|
158
|
+
*
|
|
159
|
+
* Reads the raw value from configManager using a cast key (tts.speed is not
|
|
160
|
+
* yet a valid ConfigKey). If the value is absent or not a positive finite
|
|
161
|
+
* number, falls back to TTS_SPEED_DEFAULT and marks isDefault true.
|
|
162
|
+
*/
|
|
163
|
+
export function buildTtsSpeedSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
|
|
164
|
+
const raw = configManager.get('tts.speed' as ConfigKey);
|
|
165
|
+
const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
|
|
166
|
+
const currentValue: number = isFinite(parsed) && parsed > 0 ? parsed : TTS_SPEED_DEFAULT;
|
|
167
|
+
return {
|
|
168
|
+
setting: TTS_SPEED_SYNTHETIC_SETTING,
|
|
169
|
+
currentValue,
|
|
170
|
+
isDefault: deepEqual(currentValue, TTS_SPEED_DEFAULT),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// buildFlagEntries — snapshot of current feature flag states
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
export function buildFlagEntries(featureFlagManager: FeatureFlagManager | null): FlagEntry[] {
|
|
179
|
+
if (!featureFlagManager) return [];
|
|
180
|
+
return Array.from(featureFlagManager.getAll().values()).map(({ flag, state }) => ({
|
|
181
|
+
flag,
|
|
182
|
+
state,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// buildMcpEntries — snapshot of current MCP server security entries
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
export function buildMcpEntries(mcpRegistry: McpRegistry | null): McpEntry[] {
|
|
191
|
+
if (!mcpRegistry) return [];
|
|
192
|
+
return mcpRegistry.listServerSecurity().map((entry) => ({
|
|
193
|
+
name: entry.name,
|
|
194
|
+
connected: entry.connected,
|
|
195
|
+
role: entry.role,
|
|
196
|
+
trustMode: entry.trustMode,
|
|
197
|
+
allowedPaths: [...entry.allowedPaths],
|
|
198
|
+
allowedHosts: [...entry.allowedHosts],
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// buildSubscriptionEntries — re-export for use by SettingsModal
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
export { buildSubscriptionEntries };
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// buildNetworkFilteredItems — applies host-mode visibility rules for 'network' tab
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
export function buildNetworkFilteredItems(
|
|
213
|
+
items: SettingEntry[],
|
|
214
|
+
configManager: ConfigManager | null,
|
|
215
|
+
): SettingEntry[] {
|
|
216
|
+
return items.filter(entry => {
|
|
217
|
+
if (entry.setting.key === 'controlPlane.host') {
|
|
218
|
+
return configManager?.get('controlPlane.hostMode') === 'custom';
|
|
219
|
+
}
|
|
220
|
+
if (entry.setting.key === 'httpListener.host') {
|
|
221
|
+
return configManager?.get('httpListener.hostMode') === 'custom';
|
|
222
|
+
}
|
|
223
|
+
if (entry.setting.key === 'web.host') {
|
|
224
|
+
return configManager?.get('web.hostMode') === 'custom';
|
|
225
|
+
}
|
|
226
|
+
return true;
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// refreshEntryValues — re-reads currentValue/isDefault for all loaded entries
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Normalize a raw config value for the tts.speed synthetic entry.
|
|
236
|
+
* Returns the raw value if it is a positive finite number, otherwise falls
|
|
237
|
+
* back to TTS_SPEED_DEFAULT. Mirrors the logic in buildTtsSpeedSyntheticEntry.
|
|
238
|
+
*/
|
|
239
|
+
function normalizeTtsSpeedValue(raw: unknown): number {
|
|
240
|
+
const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
|
|
241
|
+
return isFinite(parsed) && parsed > 0 ? parsed : TTS_SPEED_DEFAULT;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function refreshEntryValues(
|
|
245
|
+
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
246
|
+
configManager: ConfigManager,
|
|
247
|
+
): void {
|
|
248
|
+
for (const entries of groups.values()) {
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
const raw = configManager.get(entry.setting.key as ConfigKey);
|
|
251
|
+
// Synthetic entries (e.g. tts.speed) that have no SDK schema key return
|
|
252
|
+
// undefined from configManager. Normalize using the same logic used at
|
|
253
|
+
// construction time so isDefault stays accurate.
|
|
254
|
+
if (entry.setting.key === ('tts.speed' as ConfigKey)) {
|
|
255
|
+
entry.currentValue = normalizeTtsSpeedValue(raw);
|
|
256
|
+
} else {
|
|
257
|
+
entry.currentValue = raw;
|
|
258
|
+
}
|
|
259
|
+
entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// updateEntryForKey — updates a single setting entry after a value change
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
export function updateEntryForKey(
|
|
269
|
+
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
270
|
+
key: ConfigKey,
|
|
271
|
+
configManager: ConfigManager,
|
|
272
|
+
): void {
|
|
273
|
+
for (const entries of groups.values()) {
|
|
274
|
+
const entry = entries.find((candidate) => candidate.setting.key === key);
|
|
275
|
+
if (entry) {
|
|
276
|
+
const raw = configManager.get(key);
|
|
277
|
+
// Synthetic tts.speed entry: normalize using the same fallback logic.
|
|
278
|
+
entry.currentValue = key === ('tts.speed' as ConfigKey) ? normalizeTtsSpeedValue(raw) : raw;
|
|
279
|
+
entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// fuzzyScoreSettingEntry — score an entry against a query for ranked search
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Score a single SettingEntry against a search query.
|
|
290
|
+
*
|
|
291
|
+
* Score tiers (higher = better match):
|
|
292
|
+
* - 3000–3999: exact key substring match (3000 + position bonus 0–999)
|
|
293
|
+
* - 2000–2999: exact label substring match (2000 + position bonus 0–999)
|
|
294
|
+
* - 1000–1999: exact description substring match (1000 + position bonus 0–999)
|
|
295
|
+
* - 1–99: subsequence match across key+label+description
|
|
296
|
+
*
|
|
297
|
+
* Returns null when the query does not match at all.
|
|
298
|
+
*
|
|
299
|
+
* @param query - The search string (already lowercased).
|
|
300
|
+
* @param entry - The setting entry to test.
|
|
301
|
+
* @param getLabel - Pure function mapping an entry to its display label.
|
|
302
|
+
*/
|
|
303
|
+
export function fuzzyScoreSettingEntry(
|
|
304
|
+
query: string,
|
|
305
|
+
entry: SettingEntry,
|
|
306
|
+
getLabel: (e: SettingEntry) => string,
|
|
307
|
+
): number | null {
|
|
308
|
+
if (query.length === 0) return 0;
|
|
309
|
+
const lq = query.toLowerCase();
|
|
310
|
+
const key = entry.setting.key.toLowerCase();
|
|
311
|
+
const label = getLabel(entry).toLowerCase();
|
|
312
|
+
const description = (entry.setting.description ?? '').toLowerCase();
|
|
313
|
+
|
|
314
|
+
// Tier 1: key substring — base 3000, position bonus up to 999
|
|
315
|
+
// A key match at position 0 scores 3999; at position 999 scores 3000.
|
|
316
|
+
const keyIdx = key.indexOf(lq);
|
|
317
|
+
if (keyIdx !== -1) return 3000 + Math.max(0, 999 - keyIdx);
|
|
318
|
+
|
|
319
|
+
// Tier 2: label substring — base 2000, position bonus up to 999
|
|
320
|
+
const labelIdx = label.indexOf(lq);
|
|
321
|
+
if (labelIdx !== -1) return 2000 + Math.max(0, 999 - labelIdx);
|
|
322
|
+
|
|
323
|
+
// Tier 3: description substring — base 1000, position bonus up to 999
|
|
324
|
+
const descIdx = description.indexOf(lq);
|
|
325
|
+
if (descIdx !== -1) return 1000 + Math.max(0, 999 - descIdx);
|
|
326
|
+
|
|
327
|
+
// Tier 4: subsequence across concatenated key + label + description — 1..99
|
|
328
|
+
const haystack = `${key} ${label} ${description}`;
|
|
329
|
+
let qi = 0;
|
|
330
|
+
let score = 0;
|
|
331
|
+
for (let ci = 0; ci < haystack.length && qi < lq.length; ci++) {
|
|
332
|
+
if (haystack[ci] === lq[qi]) {
|
|
333
|
+
qi++;
|
|
334
|
+
score++;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (qi === lq.length) return Math.min(99, score);
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Search all setting entries across all groups, returning results ranked by
|
|
343
|
+
* relevance score (highest first). Excludes the flags, mcp, and subscriptions
|
|
344
|
+
* special categories (which have their own entry types).
|
|
345
|
+
*
|
|
346
|
+
* @param query - User input string. Empty string returns [].
|
|
347
|
+
* @param groups - The settings group map from buildSettingGroups.
|
|
348
|
+
* @param getLabel - Pure function mapping an entry to its display label.
|
|
349
|
+
*/
|
|
350
|
+
export function searchSettingEntries(
|
|
351
|
+
query: string,
|
|
352
|
+
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
353
|
+
getLabel: (e: SettingEntry) => string,
|
|
354
|
+
): SettingEntry[] {
|
|
355
|
+
if (query.trim().length === 0) return [];
|
|
356
|
+
const lq = query.trim().toLowerCase();
|
|
357
|
+
const seen = new Set<string>();
|
|
358
|
+
const scored: Array<{ entry: SettingEntry; score: number }> = [];
|
|
359
|
+
for (const entries of groups.values()) {
|
|
360
|
+
for (const entry of entries) {
|
|
361
|
+
// Deduplicate: network tab cross-lists keys already in controlPlane/httpListener/web
|
|
362
|
+
if (seen.has(entry.setting.key)) continue;
|
|
363
|
+
seen.add(entry.setting.key);
|
|
364
|
+
const score = fuzzyScoreSettingEntry(lq, entry, getLabel);
|
|
365
|
+
if (score !== null) scored.push({ entry, score });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
scored.sort((a, b) => b.score - a.score);
|
|
369
|
+
return scored.map(r => r.entry);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Re-export SubscriptionEntry for convenience
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
export type { SubscriptionEntry } from './settings-modal-types.ts';
|
|
377
|
+
export type { SubscriptionManager };
|
|
378
|
+
export type { ServiceInspectionQuery };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings-modal-mutations — pure mutation helpers for SettingsModal.
|
|
3
|
+
*
|
|
4
|
+
* These functions encapsulate the side-effectful write operations:
|
|
5
|
+
* applying config values, persisting feature flag state, and applying flag
|
|
6
|
+
* runtime toggles. Each function takes its dependencies as explicit arguments
|
|
7
|
+
* rather than accessing class-level state.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ConfigKey, PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config';
|
|
11
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
12
|
+
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
13
|
+
import type { FeatureFlagManager } from '@/runtime/index.ts';
|
|
14
|
+
import type { FeatureFlag, FlagState } from '@/runtime/index.ts';
|
|
15
|
+
import type { FlagEntry, SettingEntry } from './settings-modal-types.ts';
|
|
16
|
+
import { deepEqual } from './settings-modal-data.ts';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// ApplyValueResult — returned by applySettingValue so the caller can react
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface ApplyValueResult {
|
|
23
|
+
/** Restart domain that was triggered, if any. */
|
|
24
|
+
readonly restartDomain: 'control-plane' | 'http-listener' | 'web' | null;
|
|
25
|
+
/** Message from onSettingApplied handler, if any. */
|
|
26
|
+
readonly effectMessage: string | null;
|
|
27
|
+
/** Whether the value actually changed (false = no-op). */
|
|
28
|
+
readonly changed: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SettingAppliedCallback = (change: {
|
|
32
|
+
readonly key: ConfigKey;
|
|
33
|
+
readonly previousValue: unknown;
|
|
34
|
+
readonly value: unknown;
|
|
35
|
+
}) => { readonly message?: string } | void;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// applySettingValue
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export function applySettingValue({
|
|
42
|
+
key,
|
|
43
|
+
value,
|
|
44
|
+
configManager,
|
|
45
|
+
groups,
|
|
46
|
+
onSettingApplied,
|
|
47
|
+
refreshGroups,
|
|
48
|
+
}: {
|
|
49
|
+
key: ConfigKey;
|
|
50
|
+
value: unknown;
|
|
51
|
+
configManager: ConfigManager;
|
|
52
|
+
groups: Map<string, SettingEntry[]>;
|
|
53
|
+
onSettingApplied: SettingAppliedCallback | null;
|
|
54
|
+
/** Called after applying the value so the caller can re-read currentValues. */
|
|
55
|
+
refreshGroups: () => void;
|
|
56
|
+
}): ApplyValueResult {
|
|
57
|
+
const previousValue = configManager.get(key);
|
|
58
|
+
// REQUIRES_RESTART: SDK's ConfigSetting has no requiresRestart field yet (see
|
|
59
|
+
// goodvibes-sdk HANDOFF-FROM-TUI-SESSION-20260611.md §Item 8). Until it does,
|
|
60
|
+
// we detect restart-triggering keys by sub-key name heuristic below.
|
|
61
|
+
const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
configManager.setDynamic(key, value);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
logger.error('SettingsModal: failed to set config value', { key, error: summarizeError(e) });
|
|
67
|
+
return {
|
|
68
|
+
restartDomain: null,
|
|
69
|
+
effectMessage: `Save failed: ${summarizeError(e)}`,
|
|
70
|
+
changed: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Update the entry in the groups map
|
|
75
|
+
for (const entries of groups.values()) {
|
|
76
|
+
const entry = entries.find((candidate) => candidate.setting.key === key);
|
|
77
|
+
if (entry) {
|
|
78
|
+
entry.currentValue = configManager.get(key);
|
|
79
|
+
entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Determine restart domain
|
|
84
|
+
let restartDomain: 'control-plane' | 'http-listener' | 'web' | null = null;
|
|
85
|
+
if (previousValue !== value && isRestartKey) {
|
|
86
|
+
const rawCat = key.split('.')[0] as string;
|
|
87
|
+
if (rawCat === 'controlPlane') restartDomain = 'control-plane';
|
|
88
|
+
else if (rawCat === 'httpListener') restartDomain = 'http-listener';
|
|
89
|
+
else if (rawCat === 'web') restartDomain = 'web';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fire change callback
|
|
93
|
+
let effectMessage: string | null = null;
|
|
94
|
+
if (previousValue !== value && onSettingApplied) {
|
|
95
|
+
const result = onSettingApplied({ key, previousValue, value });
|
|
96
|
+
effectMessage = result?.message ?? null;
|
|
97
|
+
refreshGroups();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { restartDomain, effectMessage, changed: previousValue !== value };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// persistFlagState — write a flag override to config
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
export function persistFlagState(
|
|
108
|
+
configManager: ConfigManager,
|
|
109
|
+
flagId: string,
|
|
110
|
+
newState: FlagState,
|
|
111
|
+
defaultState: FlagState,
|
|
112
|
+
): void {
|
|
113
|
+
if (newState === 'killed') return; // never persist killed state
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const current = (configManager.getCategory('featureFlags') as Record<string, PersistedFlagState>) ?? {};
|
|
117
|
+
if (newState === defaultState) {
|
|
118
|
+
delete current[flagId];
|
|
119
|
+
} else {
|
|
120
|
+
current[flagId] = newState;
|
|
121
|
+
}
|
|
122
|
+
configManager.mergeCategory('featureFlags', current);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
logger.error('SettingsModal: failed to persist flag state', { flagId, error: summarizeError(e) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// applyFlagState — toggle a feature flag (runtime + persist)
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export function applyFlagState(
|
|
133
|
+
flagEntry: FlagEntry,
|
|
134
|
+
newState: FlagState,
|
|
135
|
+
featureFlagManager: FeatureFlagManager,
|
|
136
|
+
configManager: ConfigManager,
|
|
137
|
+
): void {
|
|
138
|
+
const flag: FeatureFlag = flagEntry.flag;
|
|
139
|
+
|
|
140
|
+
if (!flag.runtimeToggleable) {
|
|
141
|
+
persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
|
|
142
|
+
flagEntry.state = newState;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
if (newState === 'enabled') {
|
|
148
|
+
featureFlagManager.enable(flag.id);
|
|
149
|
+
} else {
|
|
150
|
+
featureFlagManager.disable(flag.id);
|
|
151
|
+
}
|
|
152
|
+
persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
|
|
153
|
+
flagEntry.state = newState;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
logger.error('SettingsModal: failed to toggle feature flag', { flag: flag.id, error: summarizeError(e) });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings-modal-reset — pure reset helpers for SettingsModal.
|
|
3
|
+
*
|
|
4
|
+
* These functions encapsulate the three reset operations:
|
|
5
|
+
* - resetSelected: reset the currently selected setting to its schema default
|
|
6
|
+
* - initiateResetCategory: arm the category-reset confirmation gate
|
|
7
|
+
* - initiateResetAll: arm the reset-all confirmation gate
|
|
8
|
+
* - handleResetConfirmKey: route a keypress through the active gate
|
|
9
|
+
*
|
|
10
|
+
* Each function takes its dependencies as explicit arguments rather than
|
|
11
|
+
* accessing class-level state. resetCategoryConfirm and resetAllConfirm
|
|
12
|
+
* remain public class fields on SettingsModal — the renderer reads them
|
|
13
|
+
* directly to decide the footer state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
17
|
+
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
18
|
+
import { buildGoodVibesSecretKey, isSecretConfigKey } from '../config/secret-config.ts';
|
|
19
|
+
import type { SettingEntry, SettingsCategory } from './settings-modal-types.ts';
|
|
20
|
+
import type { SettingsSecretsManager } from './settings-modal-secrets.ts';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// resetSelected
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export function resetSelected({
|
|
27
|
+
editingMode,
|
|
28
|
+
hasConfigManager,
|
|
29
|
+
selected,
|
|
30
|
+
secretsManager,
|
|
31
|
+
setValue,
|
|
32
|
+
}: {
|
|
33
|
+
editingMode: boolean;
|
|
34
|
+
hasConfigManager: boolean;
|
|
35
|
+
selected: SettingEntry | null;
|
|
36
|
+
secretsManager: SettingsSecretsManager | null;
|
|
37
|
+
setValue: (key: ConfigKey, value: unknown) => void;
|
|
38
|
+
}): { key: ConfigKey; value: unknown } | null {
|
|
39
|
+
if (editingMode || !hasConfigManager) return null;
|
|
40
|
+
if (!selected) return null;
|
|
41
|
+
const key = selected.setting.key as ConfigKey;
|
|
42
|
+
setValue(key, selected.setting.default);
|
|
43
|
+
if (isSecretConfigKey(key) && secretsManager) {
|
|
44
|
+
void secretsManager.delete(buildGoodVibesSecretKey(key), { scope: 'user' }).catch((error) => {
|
|
45
|
+
logger.error('SettingsModal: failed to clear secret while resetting setting', { key, error: summarizeError(error) });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return { key, value: selected.setting.default };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// initiateResetCategory
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
export function initiateResetCategory({
|
|
56
|
+
hasConfigManager,
|
|
57
|
+
currentCategory,
|
|
58
|
+
setResetCategoryConfirm,
|
|
59
|
+
setResetAllConfirm,
|
|
60
|
+
}: {
|
|
61
|
+
hasConfigManager: boolean;
|
|
62
|
+
currentCategory: string;
|
|
63
|
+
setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
|
|
64
|
+
setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
|
|
65
|
+
}): void {
|
|
66
|
+
if (!hasConfigManager) return;
|
|
67
|
+
setResetCategoryConfirm({ subject: currentCategory });
|
|
68
|
+
setResetAllConfirm(null);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// initiateResetAll
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export function initiateResetAll({
|
|
76
|
+
hasConfigManager,
|
|
77
|
+
setResetCategoryConfirm,
|
|
78
|
+
setResetAllConfirm,
|
|
79
|
+
}: {
|
|
80
|
+
hasConfigManager: boolean;
|
|
81
|
+
setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
|
|
82
|
+
setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
|
|
83
|
+
}): void {
|
|
84
|
+
if (!hasConfigManager) return;
|
|
85
|
+
setResetAllConfirm({ subject: 'all' });
|
|
86
|
+
setResetCategoryConfirm(null);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// handleResetConfirmKey
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export type ResetConfirmKeyResult =
|
|
94
|
+
| { result: 'confirmed'; entries: ReadonlyArray<{ key: string; value: unknown }> }
|
|
95
|
+
| 'cancelled'
|
|
96
|
+
| 'absorbed'
|
|
97
|
+
| 'inactive';
|
|
98
|
+
|
|
99
|
+
export function handleResetConfirmKey({
|
|
100
|
+
key,
|
|
101
|
+
resetCategoryConfirm,
|
|
102
|
+
resetAllConfirm,
|
|
103
|
+
hasConfigManager,
|
|
104
|
+
currentItems,
|
|
105
|
+
groups,
|
|
106
|
+
setValue,
|
|
107
|
+
setResetCategoryConfirm,
|
|
108
|
+
setResetAllConfirm,
|
|
109
|
+
}: {
|
|
110
|
+
key: string;
|
|
111
|
+
resetCategoryConfirm: { readonly subject: string } | null;
|
|
112
|
+
resetAllConfirm: { readonly subject: 'all' } | null;
|
|
113
|
+
hasConfigManager: boolean;
|
|
114
|
+
currentItems: () => SettingEntry[];
|
|
115
|
+
groups: Map<SettingsCategory, SettingEntry[]>;
|
|
116
|
+
setValue: (key: ConfigKey, value: unknown) => void;
|
|
117
|
+
setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
|
|
118
|
+
setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
|
|
119
|
+
}): ResetConfirmKeyResult {
|
|
120
|
+
const gate = resetCategoryConfirm ?? resetAllConfirm;
|
|
121
|
+
if (!gate || !hasConfigManager) return 'inactive';
|
|
122
|
+
|
|
123
|
+
if (key === 'enter' || key === 'y') {
|
|
124
|
+
const entries: Array<{ key: string; value: unknown }> = [];
|
|
125
|
+
if (resetCategoryConfirm) {
|
|
126
|
+
// Reset all settings in the current category to defaults.
|
|
127
|
+
const items = currentItems();
|
|
128
|
+
for (const item of items) {
|
|
129
|
+
setValue(item.setting.key as ConfigKey, item.setting.default);
|
|
130
|
+
entries.push({ key: item.setting.key, value: item.setting.default });
|
|
131
|
+
}
|
|
132
|
+
setResetCategoryConfirm(null);
|
|
133
|
+
} else {
|
|
134
|
+
// Reset ALL settings across all categories to defaults.
|
|
135
|
+
for (const [, items] of groups) {
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
setValue(item.setting.key as ConfigKey, item.setting.default);
|
|
138
|
+
entries.push({ key: item.setting.key, value: item.setting.default });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
setResetAllConfirm(null);
|
|
142
|
+
}
|
|
143
|
+
return { result: 'confirmed', entries };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (key === 'escape' || key === 'n') {
|
|
147
|
+
setResetCategoryConfirm(null);
|
|
148
|
+
setResetAllConfirm(null);
|
|
149
|
+
return 'cancelled';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// All other keys are absorbed while the gate is active.
|
|
153
|
+
return 'absorbed';
|
|
154
|
+
}
|