@pellux/goodvibes-tui 1.21.1 → 1.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +714 -177
  4. package/docs/foundation-artifacts/peer-contract.json +8 -4
  5. package/package.json +9 -7
  6. package/src/cli/entrypoint.ts +6 -0
  7. package/src/cli/service-posture.ts +6 -2
  8. package/src/config/run-daemon-config-migration.ts +46 -0
  9. package/src/core/conversation-compaction-render.ts +88 -0
  10. package/src/core/conversation-line-cache.ts +120 -92
  11. package/src/core/conversation-rendering.ts +290 -233
  12. package/src/core/conversation-turn-structure.ts +464 -0
  13. package/src/core/conversation-types.ts +8 -9
  14. package/src/daemon/cli.ts +71 -5
  15. package/src/input/commands/local-runtime.ts +14 -14
  16. package/src/input/commands/operator-rpc.ts +15 -4
  17. package/src/input/daemon-owned-settings-descriptions.ts +53 -0
  18. package/src/input/handler-content-actions.ts +4 -2
  19. package/src/input/handler-onboarding-daemon-adopt.ts +10 -0
  20. package/src/input/onboarding/onboarding-feature-units.ts +38 -0
  21. package/src/input/onboarding/onboarding-wizard-types.ts +1 -0
  22. package/src/input/search.ts +1 -1
  23. package/src/input/settings-modal-data.ts +15 -85
  24. package/src/input/settings-modal-search.ts +99 -0
  25. package/src/input/settings-modal-types.ts +2 -1
  26. package/src/renderer/conversation-surface.ts +37 -11
  27. package/src/renderer/conversation-tree.ts +179 -0
  28. package/src/renderer/settings-modal-helpers.ts +5 -3
  29. package/src/renderer/settings-modal.ts +8 -0
  30. package/src/renderer/tool-call.ts +38 -5
  31. package/src/runtime/cloudflare-control-plane.ts +4 -1
  32. package/src/runtime/feature-settings.ts +7 -0
  33. package/src/runtime/index.ts +22 -1
  34. package/src/runtime/legacy-daemon-migration.ts +2 -0
  35. package/src/verification/verification-ledger.ts +115 -2
  36. package/src/version.ts +1 -1
  37. 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
- const explicit = String(configManager.get('controlPlane.baseUrl') ?? '').trim();
50
- if (explicit) return explicit;
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) return `http://${host}:${port}`;
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.baseUrl / controlPlane.host+port) — cannot reach the operator surface.' };
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 'tool_group':
258
- return `tool result group (${lineText})`;
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',
@@ -30,6 +30,7 @@ export type OnboardingWizardStepId =
30
30
  | 'features-context'
31
31
  | 'features-telemetry'
32
32
  | 'features-automation'
33
+ | 'features-devices'
33
34
  | 'features-provider'
34
35
  | 'features-advanced'
35
36
  | 'review';
@@ -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 === 'tool_group' && block.groupMemberIndexes) {
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
- // fuzzyScoreSettingEntry — score an entry against a query for ranked search
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
- const maxContentWidth = Math.max(1, width - (margin * 2) - prefixWidth - 2);
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 markerCol = LAYOUT.LEFT_MARGIN - 1;
200
- const startCol = LAYOUT.LEFT_MARGIN + 1;
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
- line[markerCol] = createStyledCell(options.marker ?? '▌', {
203
- fg: options.markerFg ?? '#64748b',
204
- bg: options.markerBg ?? options.bodyBg ?? '',
205
- bold: true,
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
- { text: ` ${tone.label} `, fg: tone.labelFg, bold: true },
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
  }