@pellux/goodvibes-tui 1.21.1 → 1.23.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 +21 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +714 -177
- package/docs/foundation-artifacts/peer-contract.json +8 -4
- package/package.json +9 -7
- package/src/cli/entrypoint.ts +6 -0
- package/src/cli/service-posture.ts +6 -2
- package/src/config/run-daemon-config-migration.ts +46 -0
- package/src/core/conversation-compaction-render.ts +88 -0
- package/src/core/conversation-line-cache.ts +120 -92
- package/src/core/conversation-rendering.ts +290 -233
- package/src/core/conversation-turn-structure.ts +464 -0
- package/src/core/conversation-types.ts +8 -9
- package/src/daemon/cli.ts +71 -5
- package/src/input/commands/local-runtime.ts +14 -14
- package/src/input/commands/operator-rpc.ts +15 -4
- package/src/input/daemon-owned-settings-descriptions.ts +53 -0
- package/src/input/handler-content-actions.ts +4 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +10 -0
- package/src/input/onboarding/onboarding-feature-units.ts +38 -0
- package/src/input/onboarding/onboarding-wizard-types.ts +1 -0
- package/src/input/search.ts +1 -1
- package/src/input/settings-modal-data.ts +15 -85
- package/src/input/settings-modal-search.ts +99 -0
- package/src/input/settings-modal-types.ts +2 -1
- package/src/renderer/conversation-surface.ts +37 -11
- package/src/renderer/conversation-tree.ts +179 -0
- package/src/renderer/settings-modal-helpers.ts +5 -3
- package/src/renderer/settings-modal.ts +8 -0
- package/src/renderer/tool-call.ts +38 -5
- package/src/runtime/cloudflare-control-plane.ts +4 -1
- package/src/runtime/feature-settings.ts +7 -0
- package/src/runtime/index.ts +22 -1
- package/src/runtime/legacy-daemon-migration.ts +2 -0
- package/src/runtime/services.ts +2 -2
- package/src/verification/verification-ledger.ts +115 -2
- package/src/version.ts +1 -1
- package/src/core/conversation-tool-groups.ts +0 -152
|
@@ -46,11 +46,22 @@ export interface OperatorRpcAvailable {
|
|
|
46
46
|
export type OperatorRpc = OperatorRpcUnavailable | OperatorRpcAvailable;
|
|
47
47
|
|
|
48
48
|
function resolveControlPlaneBaseUrl(configManager: ConfigManager): string | null {
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// An explicitly declared external address wins; otherwise the URL is DERIVED
|
|
50
|
+
// from hostMode/host/port/tls.mode. This used to prefer a stored
|
|
51
|
+
// `controlPlane.baseUrl`, which had no writers and so drifted from the bind
|
|
52
|
+
// on port, scheme, and host at once.
|
|
53
|
+
// The cast drops once a published SDK carries this key in ConfigKey; the
|
|
54
|
+
// installed 1.14.0 predates it (same idiom cloudflare-control-plane.ts uses).
|
|
55
|
+
const explicit = String(configManager.get('controlPlane.publicBaseUrl' as never) ?? '').trim();
|
|
56
|
+
if (explicit) return explicit.replace(/\/+$/, '');
|
|
51
57
|
const host = String(configManager.get('controlPlane.host') ?? '').trim();
|
|
52
58
|
const port = configManager.get('controlPlane.port');
|
|
53
|
-
if (host && typeof port === 'number' && port > 0)
|
|
59
|
+
if (host && typeof port === 'number' && port > 0) {
|
|
60
|
+
const scheme = String(configManager.get('controlPlane.tls.mode') ?? 'off') !== 'off' ? 'https' : 'http';
|
|
61
|
+
// A wildcard bind is not a dial target; loopback is the interface it answers on.
|
|
62
|
+
const dialHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
63
|
+
return `${scheme}://${dialHost.includes(':') && !dialHost.startsWith('[') ? `[${dialHost}]` : dialHost}:${port}`;
|
|
64
|
+
}
|
|
54
65
|
return null;
|
|
55
66
|
}
|
|
56
67
|
|
|
@@ -70,7 +81,7 @@ export function resolveOperatorRpc(deps: { readonly configManager: ConfigManager
|
|
|
70
81
|
}
|
|
71
82
|
const baseUrl = resolveControlPlaneBaseUrl(configManager);
|
|
72
83
|
if (!baseUrl) {
|
|
73
|
-
return { available: false, reason: 'no control-plane base URL is configured (controlPlane.
|
|
84
|
+
return { available: false, reason: 'no control-plane base URL is configured (controlPlane.publicBaseUrl / controlPlane.host+port) — cannot reach the operator surface.' };
|
|
74
85
|
}
|
|
75
86
|
// Resolve the home directory only AFTER the static refusals — a caller whose
|
|
76
87
|
// shell paths are not wired (a disabled-daemon path) must still get the honest
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-owned-settings-descriptions.ts — appends an honest storage-location
|
|
3
|
+
* note to any settings-modal entry whose config key is daemon-owned (see
|
|
4
|
+
* `@pellux/goodvibes-sdk/platform/config`'s `isDaemonOwnedConfigKey`).
|
|
5
|
+
*
|
|
6
|
+
* Before the daemon-owned-config migration, every product wrote every key
|
|
7
|
+
* into its own per-surface silo, and a value like `surfaces.telegram.token`
|
|
8
|
+
* looked — from the settings modal — exactly like any other TUI-local
|
|
9
|
+
* setting. It never was: the daemon is the only process that reads it, so a
|
|
10
|
+
* value the TUI wrote to its own surface file could silently do nothing.
|
|
11
|
+
* This enrichment tells the truth in the one place a user checks: the
|
|
12
|
+
* setting's own detail text says the value lives in the daemon's store and
|
|
13
|
+
* applies to every client, naming the actual file path via
|
|
14
|
+
* `ConfigManager.getDaemonTierPath()`.
|
|
15
|
+
*
|
|
16
|
+
* Entries are shallow-cloned (never mutating the shared `ConfigSetting`
|
|
17
|
+
* objects CONFIG_SCHEMA exports), following the same pattern as
|
|
18
|
+
* relay-settings-descriptions.ts, so re-running this on the same groups map
|
|
19
|
+
* stays idempotent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { isDaemonOwnedConfigKey, type ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
23
|
+
import type { SettingEntry, SettingsCategory } from './settings-modal-types.ts';
|
|
24
|
+
|
|
25
|
+
/** Build the daemon-owned disclosure note for a given daemon store path. */
|
|
26
|
+
export function daemonOwnedSettingNote(daemonTierPath: string | null): string {
|
|
27
|
+
return daemonTierPath
|
|
28
|
+
? `This value is stored in the daemon's own configuration (${daemonTierPath}) and applies to every client — not just this one.`
|
|
29
|
+
: "This value is stored in the daemon's own configuration and applies to every client — not just this one.";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Append the daemon-owned disclosure note to every entry whose config key is
|
|
34
|
+
* daemon-owned, across every category in `groups`, in place.
|
|
35
|
+
*/
|
|
36
|
+
export function enrichDaemonOwnedSettingDescriptions(
|
|
37
|
+
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
38
|
+
configManager: Pick<ConfigManager, 'getDaemonTierPath'>,
|
|
39
|
+
): void {
|
|
40
|
+
const note = daemonOwnedSettingNote(configManager.getDaemonTierPath());
|
|
41
|
+
for (const entries of groups.values()) {
|
|
42
|
+
for (let i = 0; i < entries.length; i++) {
|
|
43
|
+
const entry = entries[i];
|
|
44
|
+
if (!entry) continue;
|
|
45
|
+
if (!isDaemonOwnedConfigKey(entry.setting.key)) continue;
|
|
46
|
+
if (entry.setting.description.includes(note)) continue;
|
|
47
|
+
entries[i] = {
|
|
48
|
+
...entry,
|
|
49
|
+
setting: { ...entry.setting, description: `${entry.setting.description} ${note}` },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -254,8 +254,10 @@ export function describeBlockForReceipt(block: BlockMeta): string {
|
|
|
254
254
|
switch (block.type) {
|
|
255
255
|
case 'tool':
|
|
256
256
|
return block.toolName ? `tool result: ${block.toolName} (${lineText})` : `tool result (${lineText})`;
|
|
257
|
-
case '
|
|
258
|
-
return
|
|
257
|
+
case 'assistant_turn':
|
|
258
|
+
return block.toolName
|
|
259
|
+
? `assistant turn: ${block.toolName} (${lineText})`
|
|
260
|
+
: `assistant turn (${lineText})`;
|
|
259
261
|
case 'diff':
|
|
260
262
|
return block.filePath ? `diff: ${block.filePath} (${lineText})` : `diff (${lineText})`;
|
|
261
263
|
case 'code':
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
9
9
|
import { resolveDaemonCompanionToken } from '../runtime/operator-token-cleanup.ts';
|
|
10
|
+
import { isLoopbackAddress } from './onboarding/onboarding-wizard-helpers.ts';
|
|
10
11
|
import {
|
|
11
12
|
buildManagedDaemonServiceManager,
|
|
12
13
|
detectLegacyUnit,
|
|
@@ -60,6 +61,15 @@ export async function handleConnectExistingDaemonForHandler(handler: InputHandle
|
|
|
60
61
|
try {
|
|
61
62
|
const daemonHomeDir = join(handler.uiServices.environment.homeDirectory, '.goodvibes', 'daemon');
|
|
62
63
|
resolveDaemonCompanionToken(daemonHomeDir, token);
|
|
64
|
+
// hostMode has to move with the host. The control-plane URL is DERIVED from
|
|
65
|
+
// hostMode/host/port, and 'local' pins the dial target to 127.0.0.1 no
|
|
66
|
+
// matter what host is stored — so adopting a daemon on another machine
|
|
67
|
+
// while leaving hostMode alone would derive a loopback URL for a remote
|
|
68
|
+
// daemon. Adopting a loopback address stays 'local'.
|
|
69
|
+
handler.uiServices.platform.configManager.setDynamic(
|
|
70
|
+
'controlPlane.hostMode',
|
|
71
|
+
isLoopbackAddress(host) ? 'local' : 'custom',
|
|
72
|
+
);
|
|
63
73
|
handler.uiServices.platform.configManager.setDynamic('controlPlane.host', host);
|
|
64
74
|
handler.uiServices.platform.configManager.setDynamic('controlPlane.port', port);
|
|
65
75
|
|
|
@@ -281,6 +281,13 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
|
|
|
281
281
|
hint: 'Managed watcher/listener services with checkpointing and recovery. Enabling this turns on watchers.enabled.',
|
|
282
282
|
impliedConfig: [{ key: 'watchers.enabled' as ConfigKey, value: true }],
|
|
283
283
|
},
|
|
284
|
+
{
|
|
285
|
+
flagId: 'watcher-triggers',
|
|
286
|
+
label: 'Trigger family',
|
|
287
|
+
hint: 'Three unattended watcher kinds over one supervision spine: stream watchers that regex-filter and batch a long-lived command\'s output, model-free condition checks running a probe/extract/rule pipeline with no LLM in the loop, and one-shot on-exit triggers that fire exactly one payload when a launched command terminates. A firing trigger runs an agent turn or a pre-registered digest-pinned action grant — never a command composed at fire time. Off by default because a trigger supervises real processes with nobody watching; with it on and no triggers defined the supervisor idles and consumes nothing.',
|
|
288
|
+
note: 'Backoff ladder, strike breaker, retention bounds, batching and process caps are tuned through watchers.triggers.* in /settings.',
|
|
289
|
+
impliedConfig: [{ key: 'watchers.triggers.enabled' as ConfigKey, value: true }],
|
|
290
|
+
},
|
|
284
291
|
{
|
|
285
292
|
flagId: 'integration-delivery-slo',
|
|
286
293
|
label: 'Integration delivery SLO',
|
|
@@ -315,6 +322,37 @@ export const FEATURE_ONBOARDING_SECTIONS: readonly FeatureSection[] = [
|
|
|
315
322
|
},
|
|
316
323
|
],
|
|
317
324
|
},
|
|
325
|
+
{
|
|
326
|
+
stepId: 'features-devices',
|
|
327
|
+
title: 'Paired devices and voice input',
|
|
328
|
+
shortLabel: 'Devices',
|
|
329
|
+
description: 'What a paired phone may be asked to do on your behalf, and whether GoodVibes listens for a spoken wake phrase. Every phone capability asks the person holding the phone before it runs; the wake word is off and states plainly that it is not wired up in this build.',
|
|
330
|
+
units: [
|
|
331
|
+
{
|
|
332
|
+
flagId: 'paired-device-capabilities',
|
|
333
|
+
label: 'Paired phone capabilities',
|
|
334
|
+
hint: 'Turning this on grants access to nothing by itself — it lets the agent ASK to use a paired phone as a tool: either camera, its screen, its location, its clipboard, and a small set of device effects (notification, link, buzz). It rides the existing peer transport as a native contract, never an MCP server. Every capture and every effect asks the person holding the phone first, and answering one request grants nothing beyond it; choosing "always allow" is a separate, explicit choice you make later on that prompt, and it writes one durable, revocable grant for that one capability on that one phone, with an age limit and a count cap so nothing is granted forever.',
|
|
335
|
+
note: 'Captures are kept 24 hours by default and then deleted, and every housekeeping sweep records exactly what it removed and why. Retention, grant limits, clipboard and location posture are tuned through device.* in /settings.',
|
|
336
|
+
subOptions: [
|
|
337
|
+
{
|
|
338
|
+
key: 'mode',
|
|
339
|
+
configKey: 'device.capabilities.mode' as ConfigKey,
|
|
340
|
+
label: 'Consent posture',
|
|
341
|
+
hint: 'honor-grants (stock) asks the first time and every time after unless you chose "always allow" for that capability on that phone; ask-every-time prompts on every single request and never consults a durable grant — use it when someone else is holding the phone; off stops any capability request reaching any paired device.',
|
|
342
|
+
valueType: 'enum',
|
|
343
|
+
options: ['off', 'ask-every-time', 'honor-grants'],
|
|
344
|
+
defaultValue: 'honor-grants',
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
flagId: 'wake-word-detection',
|
|
350
|
+
label: 'Wake-word detection',
|
|
351
|
+
hint: 'Listen continuously on a capture device for the pinned "hey goodvibes" phrase and hand the utterance that follows to speech-to-text. Off by default because holding a microphone open must be an explicit act.',
|
|
352
|
+
note: 'NOT AVAILABLE IN THIS BUILD — turning this on does nothing yet. The detector is complete, but no surface captures microphone audio or supplies it an inference runtime, so nothing is listening. Your choice is remembered and takes effect in the release that adds capture.',
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
},
|
|
318
356
|
{
|
|
319
357
|
stepId: 'features-provider',
|
|
320
358
|
title: 'Provider and runtime behavior',
|
package/src/input/search.ts
CHANGED
|
@@ -140,7 +140,7 @@ export class SearchManager {
|
|
|
140
140
|
for (const block of registry) {
|
|
141
141
|
if (!conversationManager.isCollapsed(block.blockIndex)) continue;
|
|
142
142
|
|
|
143
|
-
if (block.type === '
|
|
143
|
+
if (block.type === 'assistant_turn' && block.groupMemberIndexes) {
|
|
144
144
|
// The header's own synthetic summary rarely matches, but count it
|
|
145
145
|
// honestly too — expanding the whole group is the only way to even
|
|
146
146
|
// attempt to reveal a header-corpus hit, since there is no single
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
type SubscriptionEntry,
|
|
53
53
|
} from './settings-modal-types.ts';
|
|
54
54
|
import { enrichRelaySettingDescriptions } from './relay-settings-descriptions.ts';
|
|
55
|
+
import { enrichDaemonOwnedSettingDescriptions } from './daemon-owned-settings-descriptions.ts';
|
|
55
56
|
import { applyFeatureUnitLayout } from './feature-unit-layout.ts';
|
|
56
57
|
import { FEATURE_SETTINGS_BY_ID } from '../runtime/feature-settings.ts';
|
|
57
58
|
|
|
@@ -123,6 +124,14 @@ export function buildSettingGroups(
|
|
|
123
124
|
}
|
|
124
125
|
// fleet.maxSize (was orchestration.maxActiveAgents) surfaces under orchestration.
|
|
125
126
|
if (rawCat === 'fleet' && groups.has('orchestration')) groups.get('orchestration')!.push(entry);
|
|
127
|
+
// push.* configures the delivery side of the very notifications the
|
|
128
|
+
// notifications.* keys already shape (pushApproval, pushNeedsInput,
|
|
129
|
+
// pushCompletion), so the contact address and the subscription
|
|
130
|
+
// housekeeping bounds belong in that same category rather than in one of
|
|
131
|
+
// their own. Without this they match no category at all and would be
|
|
132
|
+
// dropped from the workspace entirely — reachable only by editing the
|
|
133
|
+
// config file by hand.
|
|
134
|
+
if (rawCat === 'push' && groups.has('notifications')) groups.get('notifications')!.push(entry);
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
// Synthetic display.themeMode enum (auto|dark|light) — TUI-local, see theme-mode-config.ts.
|
|
@@ -238,6 +247,10 @@ export function buildSettingGroups(
|
|
|
238
247
|
// the SDK's own descriptions don't carry (see relay-settings-descriptions.ts).
|
|
239
248
|
enrichRelaySettingDescriptions(groups);
|
|
240
249
|
|
|
250
|
+
// Daemon-owned keys (e.g. surfaces.telegram.*) live only in the daemon's
|
|
251
|
+
// own config store — say so in the detail text (daemon-owned-settings-descriptions.ts).
|
|
252
|
+
enrichDaemonOwnedSettingDescriptions(groups, configManager);
|
|
253
|
+
|
|
241
254
|
// Feature-unit presentation: each capability becomes ONE unit (its real
|
|
242
255
|
// enablement row + the settings keys it tunes) in its settings domain,
|
|
243
256
|
// sourced from FEATURE_SETTINGS. Runs AFTER synthetic injection so
|
|
@@ -698,93 +711,10 @@ export function updateEntryForKey(
|
|
|
698
711
|
}
|
|
699
712
|
|
|
700
713
|
// ---------------------------------------------------------------------------
|
|
701
|
-
//
|
|
714
|
+
// Ranked settings search (see settings-modal-search.ts)
|
|
702
715
|
// ---------------------------------------------------------------------------
|
|
703
716
|
|
|
704
|
-
|
|
705
|
-
* Score a single SettingEntry against a search query.
|
|
706
|
-
*
|
|
707
|
-
* Score tiers (higher = better match):
|
|
708
|
-
* - 3000–3999: exact key substring match (3000 + position bonus 0–999)
|
|
709
|
-
* - 2000–2999: exact label substring match (2000 + position bonus 0–999)
|
|
710
|
-
* - 1000–1999: exact description substring match (1000 + position bonus 0–999)
|
|
711
|
-
* - 1–99: subsequence match across key+label+description
|
|
712
|
-
*
|
|
713
|
-
* Returns null when the query does not match at all.
|
|
714
|
-
*
|
|
715
|
-
* @param query - The search string (already lowercased).
|
|
716
|
-
* @param entry - The setting entry to test.
|
|
717
|
-
* @param getLabel - Pure function mapping an entry to its display label.
|
|
718
|
-
*/
|
|
719
|
-
export function fuzzyScoreSettingEntry(
|
|
720
|
-
query: string,
|
|
721
|
-
entry: SettingEntry,
|
|
722
|
-
getLabel: (e: SettingEntry) => string,
|
|
723
|
-
): number | null {
|
|
724
|
-
if (query.length === 0) return 0;
|
|
725
|
-
const lq = query.toLowerCase();
|
|
726
|
-
const key = entry.setting.key.toLowerCase();
|
|
727
|
-
const label = getLabel(entry).toLowerCase();
|
|
728
|
-
const description = (entry.setting.description ?? '').toLowerCase();
|
|
729
|
-
|
|
730
|
-
// Tier 1: key substring — base 3000, position bonus up to 999
|
|
731
|
-
// A key match at position 0 scores 3999; at position 999 scores 3000.
|
|
732
|
-
const keyIdx = key.indexOf(lq);
|
|
733
|
-
if (keyIdx !== -1) return 3000 + Math.max(0, 999 - keyIdx);
|
|
734
|
-
|
|
735
|
-
// Tier 2: label substring — base 2000, position bonus up to 999
|
|
736
|
-
const labelIdx = label.indexOf(lq);
|
|
737
|
-
if (labelIdx !== -1) return 2000 + Math.max(0, 999 - labelIdx);
|
|
738
|
-
|
|
739
|
-
// Tier 3: description substring — base 1000, position bonus up to 999
|
|
740
|
-
const descIdx = description.indexOf(lq);
|
|
741
|
-
if (descIdx !== -1) return 1000 + Math.max(0, 999 - descIdx);
|
|
742
|
-
|
|
743
|
-
// Tier 4: subsequence across concatenated key + label + description — 1..99
|
|
744
|
-
const haystack = `${key} ${label} ${description}`;
|
|
745
|
-
let qi = 0;
|
|
746
|
-
let score = 0;
|
|
747
|
-
for (let ci = 0; ci < haystack.length && qi < lq.length; ci++) {
|
|
748
|
-
if (haystack[ci] === lq[qi]) {
|
|
749
|
-
qi++;
|
|
750
|
-
score++;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
if (qi === lq.length) return Math.min(99, score);
|
|
754
|
-
return null;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
/**
|
|
758
|
-
* Search all setting entries across all groups, returning results ranked by
|
|
759
|
-
* relevance score (highest first). Excludes the flags, mcp, and subscriptions
|
|
760
|
-
* special categories (which have their own entry types).
|
|
761
|
-
*
|
|
762
|
-
* @param query - User input string. Empty string returns [].
|
|
763
|
-
* @param groups - The settings group map from buildSettingGroups.
|
|
764
|
-
* @param getLabel - Pure function mapping an entry to its display label.
|
|
765
|
-
*/
|
|
766
|
-
export function searchSettingEntries(
|
|
767
|
-
query: string,
|
|
768
|
-
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
769
|
-
getLabel: (e: SettingEntry) => string,
|
|
770
|
-
): SettingEntry[] {
|
|
771
|
-
if (query.trim().length === 0) return [];
|
|
772
|
-
const lq = query.trim().toLowerCase();
|
|
773
|
-
const seen = new Set<string>();
|
|
774
|
-
const scored: Array<{ entry: SettingEntry; score: number }> = [];
|
|
775
|
-
for (const entries of groups.values()) {
|
|
776
|
-
for (const entry of entries) {
|
|
777
|
-
// Deduplicate: network tab cross-lists keys already in controlPlane/httpListener/web
|
|
778
|
-
if (seen.has(entry.setting.key)) continue;
|
|
779
|
-
seen.add(entry.setting.key);
|
|
780
|
-
const score = fuzzyScoreSettingEntry(lq, entry, getLabel);
|
|
781
|
-
if (score !== null) scored.push({ entry, score });
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
scored.sort((a, b) => b.score - a.score);
|
|
785
|
-
return scored.map(r => r.entry);
|
|
786
|
-
}
|
|
787
|
-
|
|
717
|
+
export { fuzzyScoreSettingEntry, searchSettingEntries } from './settings-modal-search.ts';
|
|
788
718
|
// ---------------------------------------------------------------------------
|
|
789
719
|
// Re-export SubscriptionEntry for convenience
|
|
790
720
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings-modal-search — ranked search over the settings workspace.
|
|
3
|
+
*
|
|
4
|
+
* Split out of settings-modal-data.ts, which had reached its 800-line ceiling.
|
|
5
|
+
* Scoring and search are a self-contained unit: they read a SettingEntry and a
|
|
6
|
+
* label function and depend on nothing else in the assembly path, so moving
|
|
7
|
+
* them changes no behavior. Both are re-exported from settings-modal-data.ts,
|
|
8
|
+
* so no import site moved.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SettingEntry, SettingsCategory } from './settings-modal-types.ts';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// fuzzyScoreSettingEntry — score an entry against a query for ranked search
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Score a single SettingEntry against a search query.
|
|
19
|
+
*
|
|
20
|
+
* Score tiers (higher = better match):
|
|
21
|
+
* - 3000–3999: exact key substring match (3000 + position bonus 0–999)
|
|
22
|
+
* - 2000–2999: exact label substring match (2000 + position bonus 0–999)
|
|
23
|
+
* - 1000–1999: exact description substring match (1000 + position bonus 0–999)
|
|
24
|
+
* - 1–99: subsequence match across key+label+description
|
|
25
|
+
*
|
|
26
|
+
* Returns null when the query does not match at all.
|
|
27
|
+
*
|
|
28
|
+
* @param query - The search string (already lowercased).
|
|
29
|
+
* @param entry - The setting entry to test.
|
|
30
|
+
* @param getLabel - Pure function mapping an entry to its display label.
|
|
31
|
+
*/
|
|
32
|
+
export function fuzzyScoreSettingEntry(
|
|
33
|
+
query: string,
|
|
34
|
+
entry: SettingEntry,
|
|
35
|
+
getLabel: (e: SettingEntry) => string,
|
|
36
|
+
): number | null {
|
|
37
|
+
if (query.length === 0) return 0;
|
|
38
|
+
const lq = query.toLowerCase();
|
|
39
|
+
const key = entry.setting.key.toLowerCase();
|
|
40
|
+
const label = getLabel(entry).toLowerCase();
|
|
41
|
+
const description = (entry.setting.description ?? '').toLowerCase();
|
|
42
|
+
|
|
43
|
+
// Tier 1: key substring — base 3000, position bonus up to 999
|
|
44
|
+
// A key match at position 0 scores 3999; at position 999 scores 3000.
|
|
45
|
+
const keyIdx = key.indexOf(lq);
|
|
46
|
+
if (keyIdx !== -1) return 3000 + Math.max(0, 999 - keyIdx);
|
|
47
|
+
|
|
48
|
+
// Tier 2: label substring — base 2000, position bonus up to 999
|
|
49
|
+
const labelIdx = label.indexOf(lq);
|
|
50
|
+
if (labelIdx !== -1) return 2000 + Math.max(0, 999 - labelIdx);
|
|
51
|
+
|
|
52
|
+
// Tier 3: description substring — base 1000, position bonus up to 999
|
|
53
|
+
const descIdx = description.indexOf(lq);
|
|
54
|
+
if (descIdx !== -1) return 1000 + Math.max(0, 999 - descIdx);
|
|
55
|
+
|
|
56
|
+
// Tier 4: subsequence across concatenated key + label + description — 1..99
|
|
57
|
+
const haystack = `${key} ${label} ${description}`;
|
|
58
|
+
let qi = 0;
|
|
59
|
+
let score = 0;
|
|
60
|
+
for (let ci = 0; ci < haystack.length && qi < lq.length; ci++) {
|
|
61
|
+
if (haystack[ci] === lq[qi]) {
|
|
62
|
+
qi++;
|
|
63
|
+
score++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (qi === lq.length) return Math.min(99, score);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Search all setting entries across all groups, returning results ranked by
|
|
72
|
+
* relevance score (highest first). Excludes the flags, mcp, and subscriptions
|
|
73
|
+
* special categories (which have their own entry types).
|
|
74
|
+
*
|
|
75
|
+
* @param query - User input string. Empty string returns [].
|
|
76
|
+
* @param groups - The settings group map from buildSettingGroups.
|
|
77
|
+
* @param getLabel - Pure function mapping an entry to its display label.
|
|
78
|
+
*/
|
|
79
|
+
export function searchSettingEntries(
|
|
80
|
+
query: string,
|
|
81
|
+
groups: Map<SettingsCategory, SettingEntry[]>,
|
|
82
|
+
getLabel: (e: SettingEntry) => string,
|
|
83
|
+
): SettingEntry[] {
|
|
84
|
+
if (query.trim().length === 0) return [];
|
|
85
|
+
const lq = query.trim().toLowerCase();
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
const scored: Array<{ entry: SettingEntry; score: number }> = [];
|
|
88
|
+
for (const entries of groups.values()) {
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
// Deduplicate: network tab cross-lists keys already in controlPlane/httpListener/web
|
|
91
|
+
if (seen.has(entry.setting.key)) continue;
|
|
92
|
+
seen.add(entry.setting.key);
|
|
93
|
+
const score = fuzzyScoreSettingEntry(lq, entry, getLabel);
|
|
94
|
+
if (score !== null) scored.push({ entry, score });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
scored.sort((a, b) => b.score - a.score);
|
|
98
|
+
return scored.map(r => r.entry);
|
|
99
|
+
}
|
|
@@ -27,6 +27,7 @@ export type SettingsCategory =
|
|
|
27
27
|
| 'network'
|
|
28
28
|
| 'relay'
|
|
29
29
|
| 'surfaces'
|
|
30
|
+
| 'device'
|
|
30
31
|
| 'conversationGate'
|
|
31
32
|
| 'cloudflare'
|
|
32
33
|
| 'batch'
|
|
@@ -61,7 +62,7 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
|
|
|
61
62
|
{ label: 'Interface', categories: ['display', 'ui', 'behavior', 'notifications', 'permissions', 'policy', 'security'] },
|
|
62
63
|
{ label: 'AI Routing', categories: ['provider', 'pricing', 'subscriptions', 'helper', 'tools', 'tts', 'voice'] },
|
|
63
64
|
{ label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web', 'relay'] },
|
|
64
|
-
{ label: 'Surfaces & Cloud', categories: ['surfaces', 'conversationGate', 'integrations', 'mcp', 'cloudflare'] },
|
|
65
|
+
{ label: 'Surfaces & Cloud', categories: ['surfaces', 'device', 'conversationGate', 'integrations', 'mcp', 'cloudflare'] },
|
|
65
66
|
{ label: 'Automation', categories: ['batch', 'automation', 'checkin', 'watchers', 'orchestration', 'planner', 'wrfc'] },
|
|
66
67
|
{ label: 'Runtime & Data', categories: ['storage', 'atRest', 'sandbox', 'fetch', 'agents', 'runtime', 'power', 'memory', 'cache', 'telemetry', 'diagnostics', 'learning'] },
|
|
67
68
|
{ label: 'Advanced', categories: ['release', 'update', 'danger'] },
|
|
@@ -102,10 +102,14 @@ export function renderConversationFragment(
|
|
|
102
102
|
content: string,
|
|
103
103
|
width: number,
|
|
104
104
|
palette: ConversationFragmentPalette,
|
|
105
|
+
indentCols = 0,
|
|
105
106
|
): Line[] {
|
|
106
|
-
const margin = LAYOUT.USER_BOX_MARGIN;
|
|
107
|
+
const margin = LAYOUT.USER_BOX_MARGIN + Math.max(0, indentCols);
|
|
107
108
|
const prefixWidth = getDisplayWidth(palette.prefix);
|
|
108
|
-
|
|
109
|
+
// The indent is charged to the content budget rather than allowed to push the
|
|
110
|
+
// fragment past the right edge — a narrow terminal shrinks the preview text
|
|
111
|
+
// instead of silently truncating its tail.
|
|
112
|
+
const maxContentWidth = Math.max(1, width - margin - LAYOUT.USER_BOX_MARGIN - prefixWidth - 2);
|
|
109
113
|
const wrapped = wrapText(content, maxContentWidth);
|
|
110
114
|
const contentWidth = wrapped.length > 0 ? Math.max(...wrapped.map((line) => getDisplayWidth(line))) : 0;
|
|
111
115
|
const fragmentWidth = Math.max(prefixWidth + 2, prefixWidth + contentWidth + 2);
|
|
@@ -173,6 +177,8 @@ export function renderConversationCollapsedFragment(
|
|
|
173
177
|
readonly bodyBg?: string;
|
|
174
178
|
readonly dim?: boolean;
|
|
175
179
|
readonly italic?: boolean;
|
|
180
|
+
/** Tree-branch indent, in columns (see conversation-tree.ts). */
|
|
181
|
+
readonly indentCols?: number;
|
|
176
182
|
} = {},
|
|
177
183
|
): Line[] {
|
|
178
184
|
return renderConversationFragment(content, width, {
|
|
@@ -182,7 +188,7 @@ export function renderConversationCollapsedFragment(
|
|
|
182
188
|
bodyBg: options.bodyBg ?? '#1a1a1a',
|
|
183
189
|
dim: options.dim ?? true,
|
|
184
190
|
italic: options.italic ?? false,
|
|
185
|
-
});
|
|
191
|
+
}, options.indentCols ?? 0);
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
export function renderConversationStatusLine(
|
|
@@ -193,17 +199,28 @@ export function renderConversationStatusLine(
|
|
|
193
199
|
readonly markerFg?: string;
|
|
194
200
|
readonly markerBg?: string;
|
|
195
201
|
readonly bodyBg?: string;
|
|
202
|
+
/**
|
|
203
|
+
* Tree-branch indent in columns (see conversation-tree.ts). Shifts the
|
|
204
|
+
* marker and content columns together, so a branch row keeps the same
|
|
205
|
+
* marker→content relationship a flush row has. Callers pass an indent
|
|
206
|
+
* already clamped by treeIndentCols(), so this never eats the content
|
|
207
|
+
* budget below the guaranteed minimum.
|
|
208
|
+
*/
|
|
209
|
+
readonly indentCols?: number;
|
|
196
210
|
} = {},
|
|
197
211
|
): Line {
|
|
198
212
|
const line = createEmptyLine(width);
|
|
199
|
-
const
|
|
200
|
-
const
|
|
213
|
+
const indent = Math.max(0, options.indentCols ?? 0);
|
|
214
|
+
const markerCol = LAYOUT.LEFT_MARGIN - 1 + indent;
|
|
215
|
+
const startCol = LAYOUT.LEFT_MARGIN + 1 + indent;
|
|
201
216
|
const endCol = Math.max(startCol, width - LAYOUT.RIGHT_MARGIN);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
217
|
+
if (markerCol >= 0 && markerCol < width) {
|
|
218
|
+
line[markerCol] = createStyledCell(options.marker ?? '▌', {
|
|
219
|
+
fg: options.markerFg ?? '#64748b',
|
|
220
|
+
bg: options.markerBg ?? options.bodyBg ?? '',
|
|
221
|
+
bold: true,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
207
224
|
let col = startCol;
|
|
208
225
|
for (const segment of segments) {
|
|
209
226
|
if (col >= endCol) break;
|
|
@@ -222,11 +239,19 @@ export function renderConversationEventLine(
|
|
|
222
239
|
width: number,
|
|
223
240
|
tone: ConversationEventTone,
|
|
224
241
|
details: readonly ConversationStatusSegment[] = [],
|
|
242
|
+
indentCols = 0,
|
|
225
243
|
): Line {
|
|
244
|
+
// An empty label is legitimate on a branch row: the tree already says what
|
|
245
|
+
// the row is (a result hanging under its call), so repeating "tool result"
|
|
246
|
+
// on every one of them is exactly the boilerplate this layout removes. The
|
|
247
|
+
// row then leads with its own first informative detail segment.
|
|
248
|
+
const labelSegments = tone.label
|
|
249
|
+
? [{ text: ` ${tone.label} `, fg: tone.labelFg, bold: true }]
|
|
250
|
+
: [];
|
|
226
251
|
return renderConversationStatusLine(
|
|
227
252
|
width,
|
|
228
253
|
[
|
|
229
|
-
|
|
254
|
+
...labelSegments,
|
|
230
255
|
...details.map((segment) => ({
|
|
231
256
|
...segment,
|
|
232
257
|
fg: segment.fg || tone.detailFg || tone.labelFg,
|
|
@@ -235,6 +260,7 @@ export function renderConversationEventLine(
|
|
|
235
260
|
{
|
|
236
261
|
marker: tone.marker,
|
|
237
262
|
markerFg: tone.markerFg,
|
|
263
|
+
indentCols,
|
|
238
264
|
},
|
|
239
265
|
);
|
|
240
266
|
}
|