@pellux/goodvibes-tui 0.18.13 → 0.18.18

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 (71) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +3 -2
  5. package/src/daemon/cli.ts +82 -6
  6. package/src/input/command-registry.ts +2 -0
  7. package/src/input/commands/control-room-runtime.ts +1 -1
  8. package/src/input/commands/health-runtime.ts +1 -1
  9. package/src/input/commands/local-setup-review.ts +1 -1
  10. package/src/input/commands/platform-access-runtime.ts +1 -1
  11. package/src/input/commands/qrcode-runtime.ts +20 -0
  12. package/src/input/commands/subscription-runtime.ts +1 -1
  13. package/src/input/commands.ts +2 -0
  14. package/src/input/handler-feed.ts +6 -0
  15. package/src/input/handler-modal-routes.ts +19 -2
  16. package/src/input/handler-modal-token-routes.ts +3 -0
  17. package/src/input/handler-picker-routes.ts +4 -2
  18. package/src/input/model-picker.ts +11 -0
  19. package/src/input/settings-modal.ts +31 -3
  20. package/src/panels/agent-logs-panel.ts +23 -24
  21. package/src/panels/base-panel.ts +6 -0
  22. package/src/panels/builtin/session.ts +66 -0
  23. package/src/panels/builtin/shared.ts +1 -1
  24. package/src/panels/provider-account-snapshot.ts +1 -1
  25. package/src/panels/provider-accounts-panel.ts +23 -27
  26. package/src/panels/qr-panel.ts +182 -0
  27. package/src/panels/scrollable-list-panel.ts +407 -0
  28. package/src/panels/services-panel.ts +1 -1
  29. package/src/panels/subscription-panel.ts +1 -1
  30. package/src/panels/types.ts +6 -0
  31. package/src/panels/worktree-panel.ts +20 -19
  32. package/src/renderer/buffer.ts +19 -0
  33. package/src/renderer/compositor.ts +19 -6
  34. package/src/renderer/panel-composite.ts +24 -3
  35. package/src/renderer/qr-renderer.ts +117 -0
  36. package/src/renderer/settings-modal-helpers.ts +122 -0
  37. package/src/renderer/settings-modal.ts +147 -111
  38. package/src/runtime/bootstrap-command-context.ts +1 -1
  39. package/src/runtime/bootstrap-command-parts.ts +31 -15
  40. package/src/runtime/bootstrap-core.ts +23 -1
  41. package/src/runtime/bootstrap.ts +6 -1
  42. package/src/runtime/diagnostics/panels/index.ts +5 -5
  43. package/src/runtime/services.ts +1 -1
  44. package/src/runtime/store/domains/domain-read-matrix.ts +0 -2
  45. package/src/runtime/ui-events.ts +1 -46
  46. package/src/runtime/ui-read-model-helpers.ts +1 -32
  47. package/src/runtime/ui-read-models-observability-maintenance.ts +1 -81
  48. package/src/runtime/ui-read-models-observability-options.ts +1 -5
  49. package/src/runtime/ui-read-models-observability-remote.ts +1 -73
  50. package/src/runtime/ui-read-models-observability-security.ts +1 -172
  51. package/src/runtime/ui-read-models-observability-system.ts +1 -217
  52. package/src/runtime/ui-read-models-observability.ts +1 -59
  53. package/src/runtime/ui-service-queries.ts +1 -114
  54. package/src/version.ts +1 -1
  55. package/src/config/service-registry.ts +0 -1
  56. package/src/config/subscription-providers.ts +0 -1
  57. package/src/runtime/diagnostics/actions.ts +0 -776
  58. package/src/runtime/diagnostics/index.ts +0 -99
  59. package/src/runtime/diagnostics/panels/agents.ts +0 -252
  60. package/src/runtime/diagnostics/panels/events.ts +0 -188
  61. package/src/runtime/diagnostics/panels/health.ts +0 -242
  62. package/src/runtime/diagnostics/panels/tasks.ts +0 -251
  63. package/src/runtime/diagnostics/panels/tool-calls.ts +0 -267
  64. package/src/runtime/diagnostics/provider.ts +0 -262
  65. package/src/runtime/store/domains/conversation.ts +0 -1
  66. package/src/runtime/store/domains/permissions.ts +0 -1
  67. package/src/runtime/store/helpers/reducers/conversation.ts +0 -1
  68. package/src/runtime/store/helpers/reducers/lifecycle.ts +0 -1
  69. package/src/runtime/store/helpers/reducers/shared.ts +0 -60
  70. package/src/runtime/store/helpers/reducers/sync.ts +0 -555
  71. package/src/runtime/store/helpers/reducers.ts +0 -30
package/CHANGELOG.md CHANGED
@@ -4,6 +4,145 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.18.18] — 2026-04-16
8
+
9
+ ### Performance
10
+
11
+ - **R1 — Render coalescing** (`src/runtime/bootstrap-core.ts`): `requestRender()` is now wrapped in a `setImmediate`-based coalescer. A burst of N synchronous `requestRender()` calls in a single microtask produces exactly one render pass. A 16ms minimum-interval gate is applied to cap rendering at ~60fps during streaming (prevents hundreds of full pipeline runs per second on LLM token bursts).
12
+ - **R2 — Panel dirty-flag activation** (`src/renderer/panel-composite.ts`, `src/panels/base-panel.ts`, `src/panels/types.ts`): The existing `needsRender` field is now enforced. Added `invalidate(): void` and `markRendered(): void` to the `Panel` interface and `BasePanel`. `buildPanelCompositeData` routes all panel renders through a new `renderPanel()` helper backed by a per-panel `WeakMap` cache — panels that have not changed and whose dimensions are unchanged are skipped entirely. `ScrollableListPanel` and all 40+ panels that write `needsRender = true` on state mutation are compatible without changes (the contract was already partially in place; this activates it).
13
+ - **R3 — Buffer reuse** (`src/renderer/buffer.ts`, `src/renderer/compositor.ts`): `TerminalBuffer` gains a `reset(width, height): void` method that overwrites cells in-place instead of reallocating. `Compositor` now holds two long-lived `TerminalBuffer` instances (front/back). Each `composite()` call resets the back buffer, composites into it, diffs against the front buffer (the last-rendered frame), writes the diff, then swaps front/back. The `clone()` call that doubled allocation cost every frame is eliminated. `TerminalBuffer` constructor is called twice per session (once per buffer), not once per frame.
14
+
15
+ ### Tests
16
+
17
+ - Added `src/test/renderer/render-perf.test.ts` with 10 new tests covering R1 coalescing logic, R2 dirty-flag skip/invalidate/markRendered contract, and R3 `TerminalBuffer.reset()` behavior.
18
+ - Extended `src/test/renderer/compositor.test.ts` with 3 new tests covering R3 double-buffer reuse correctness (buffer identity after swap, resetDiff clearing both buffers, resize handling).
19
+ - Updated mock `Panel` objects in `src/test/renderer/panel-navigation.test.ts`, `src/test/panels/panel-manager.test.ts`, `src/test/panels/panel-list-panel.test.ts`, and `src/test/daemon/server.test.ts` to implement the new `invalidate()`/`markRendered()` interface methods.
20
+ - Test suite: 438/438 passing, typecheck clean, architecture check green.
21
+
22
+ ---
23
+
24
+ ## [0.18.17] — 2026-04-16
25
+
26
+ ### Bug Fixes
27
+
28
+ - **Companion pairing token registered with embedded daemon**: `src/runtime/bootstrap.ts` now loads the persistent companion-pairing token via `getOrCreateCompanionToken('tui')` and passes it as `sharedDaemonToken` to `startExternalServices`. The TUI's QR panel advertises this token as the bearer for phone pairing; before this fix, the embedded daemon was started with no shared token and rejected every scanned token with `authenticated: false, authMode: "invalid"`
29
+ - **QR code visual alignment**: `src/renderer/qr-renderer.ts` now uses `leftPad = 1` (down from 2) and prepends a single top quiet-band row. The QR's finder patterns now register symmetrically on both axes; previous rendering was mis-aligned by one cell horizontally and had no top quiet band
30
+
31
+ ### Dependencies
32
+
33
+ - Bumped `@pellux/goodvibes-sdk` 0.18.36 → 0.18.37, picking up: `sharedDaemonToken`/`sharedHttpListenerToken` factory options on `startHostServices`, bootstrap credential drift detection that warns when `auth-bootstrap.txt` falls out of sync with `auth-users.json`
34
+ - Regenerated `docs/foundation-artifacts/*` against SDK 0.18.37
35
+
36
+ ### Tests
37
+
38
+ - Updated `src/test/runtime/bootstrap-services.test.ts` `daemonEnable`/`listenerEnable` expectations to include the new second argument (`undefined` when no shared token is supplied)
39
+ - Test suite: 437/437 passing, typecheck clean, architecture check green
40
+
41
+ ---
42
+
43
+ ## [0.18.16] — 2026-04-16
44
+
45
+ ### Bug Fixes
46
+
47
+ - **resolveToolLLM tests**: enabled `tools.llmEnabled` by default in `createTestManagers()` (`src/test/helpers/test-managers.ts`) so tool LLM resolution tests exercise the resolution logic directly; previously every test hit the gate and resolved to `null`
48
+ - **Domain boundary contract test (GC-ARCH-001)**: removed `'conversation'` and `'permissions'` from the `DOMAINS` array in `src/runtime/store/domains/domain-read-matrix.ts` — the files they referenced were deleted in 0.18.15 and the filesystem↔array consistency check was failing
49
+
50
+ ### Architecture
51
+
52
+ - **settings-modal decomposition**: extracted 10 pure helpers (`formatValue`, `valueColor`, `flagStateColor`, `mcpTrustColor`, `subscriptionStateColor`, `inferSubscriptionRouteReason`, `CATEGORY_LABELS`, `SETTING_LABELS`, `getSettingLabel`, `describeUiRouting`) into `src/renderer/settings-modal-helpers.ts`. `settings-modal.ts` drops from 844 → 737 lines, back under the 800-line architecture cap
53
+
54
+ ### Dependencies
55
+
56
+ - Bumped `@pellux/goodvibes-sdk` 0.18.33 → 0.18.36, picking up: daemon shutdown symmetry, event bus iteration fix, atomic session writes, rate limiter TTL + LRU + sweep, `fetchWithTimeout` helper, restored port honoring in `resolveHostBinding` for `local`/`network` hostModes, and restored constructor-injected port/host in `resolveDaemonFacadeRuntime`
57
+ - Regenerated `docs/foundation-artifacts/*` against the new SDK
58
+
59
+ ### Tests & Checks
60
+
61
+ - Test suite: 437/437 passing (was 431/437 after 0.18.15)
62
+ - Architecture check: passing (was failing with `settings-modal.ts` 844 > 800-line cap)
63
+ - Typecheck: clean
64
+
65
+ ---
66
+
67
+ ## [0.18.15] — 2026-04-16
68
+
69
+ ### Correctness Fix
70
+
71
+ - **daemon SIGINT/SIGTERM drain**: `src/daemon/cli.ts` — added AbortController signaling for in-flight requests, 15-second `Promise.race` shutdown deadline, hard `process.exit(1)` if deadline exceeded, debounced double-signal guard (`shutdownInFlight` flag)
72
+
73
+ ### Dead Code Removal (Tier 3 items 15-17)
74
+
75
+ - Deleted 15 TUI mirror files with zero external importers:
76
+ - `src/runtime/diagnostics/index.ts`, `provider.ts`, `actions.ts`
77
+ - `src/runtime/diagnostics/panels/agents.ts`, `events.ts`, `health.ts`, `tasks.ts`, `tool-calls.ts`
78
+ - `src/runtime/store/helpers/reducers.ts` (barrel) and 4 sub-reducers
79
+ - `src/runtime/store/domains/permissions.ts`, `conversation.ts`
80
+ - Updated `src/runtime/diagnostics/panels/index.ts` to re-export deleted panels from SDK
81
+
82
+ ### Config Re-export Shim Inlining (Tier 3 item 18)
83
+
84
+ - Deleted `src/config/service-registry.ts` and `src/config/subscription-providers.ts` (1-line SDK re-exports)
85
+ - Updated 29 call sites to import directly from `@pellux/goodvibes-sdk/platform/config/*`
86
+
87
+ ### SDK Consolidation — UI Read Models (Tier 3 items 19-20)
88
+
89
+ - Converted 9 TUI mirror files to 1-line SDK re-exports (preserving all call-site import paths):
90
+ - `ui-events.ts`, `ui-service-queries.ts`, `ui-read-model-helpers.ts`
91
+ - `ui-read-models-observability.ts` and 4 observability sub-files (maintenance, options, remote, security, system)
92
+ - Skipped (TUI-specific divergence): `ui-services.ts` (uses TUI `SecretsManager` subclass), `ui-read-models.ts` (depends on TUI `RuntimeServices`)
93
+ - panel-resources drift: TUI version (119 lines) uses `panel-health-monitor.ts`; SDK version (152 lines) uses `component-health-monitor.js` — different monitor interfaces, TUI-specific binding kept
94
+
95
+ ---
96
+
97
+ ## [0.18.14] — 2026-04-16
98
+
99
+ ### Panel Navigation Overhaul
100
+
101
+ - Created `ScrollableListPanel<T>` and `SearchableListPanel<T>` base classes in `src/panels/scrollable-list-panel.ts`
102
+ - Migrated 30 panels from hand-coded scroll/cursor management to the shared base classes
103
+ - All list panels now have consistent navigation: up/down/j/k, pageup/pagedown, home/end/g/G, enter to select
104
+ - Selection is always visible within the viewport — guaranteed by `getVisibleWindow()` from `surface-layout.ts`
105
+ - Removed ~150 lines of duplicated scroll boilerplate across panels
106
+
107
+ ### Modal Viewport Fixes
108
+
109
+ - Fixed modal sizing: height is exactly 45% of viewport (both min and max — all modals same size), width is 50% with 25% minimum
110
+ - Fixed modal scroll/selection: 6 modal/overlay files updated to use shared `getVisibleWindow()` instead of inline scroll math
111
+ - Autocomplete overlay, file picker, bookmark modal, session picker, profile picker, and live tail modal all use the same viewport function
112
+
113
+ ### Settings Modal: Tools Tab
114
+
115
+ - Added proper tools tab UI with "Tool LLM" and "Helper Model" section headers
116
+ - Helper config keys (`helper.enabled`, `helper.globalProvider`, `helper.globalModel`) now routed into the tools tab
117
+ - Boolean settings display as [on]/[off] toggles
118
+ - Selecting a provider/model setting opens the full model picker instead of a text field
119
+ - Model picker now supports 3 target modes: main, helper, and tool
120
+ - Selecting a helper/tool model auto-enables the feature (`helper.enabled: true` / `tools.llmEnabled: true`)
121
+
122
+ ### QR Code Pairing for Companion Apps
123
+
124
+ - Added `/qrcode` command (aliases `/qr`, `/pair`) that opens a QR code panel
125
+ - QR panel displays connection info (daemon URL, token, username) + scannable QR code
126
+ - QR rendered using Unicode half-block characters (▀/▄/█) for compact terminal display
127
+ - Supports `r` to regenerate token (invalidates old one) and `c` to copy token to clipboard
128
+ - Daemon standalone mode (`goodvibes-daemon`) now prints QR + connection info to stdout on startup
129
+ - Companion tokens persist to `.goodvibes/tui/companion-token.json` with `gv_` prefix
130
+ - Built on SDK 0.18.30 pairing module
131
+
132
+ ### Health Monitoring Rename
133
+
134
+ - Renamed `panelHealthMonitor` to `componentHealthMonitor` across 20 files to align with SDK 0.18.29's generic naming
135
+ - Deprecated `Panel*` type aliases preserved for backward compatibility
136
+
137
+ ### SDK 0.18.30 Update
138
+
139
+ - Updated to `@pellux/goodvibes-sdk@0.18.30`
140
+ - Consumes new pairing module, `tools.llmEnabled` config, and all 0.18.29 boundary cleanup
141
+
142
+ ### Verification
143
+
144
+ - Full typecheck passes: `bun x tsc --noEmit` — 0 errors
145
+
7
146
  ## [0.18.13] — 2026-04-16
8
147
 
9
148
  ### SDK/TUI Boundary Separation
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.18.13-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.18.18-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "0.18.29"
6
+ "version": "0.18.37"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.18.13",
3
+ "version": "0.18.18",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -16,6 +16,7 @@
16
16
  "src",
17
17
  "!src/test",
18
18
  "!src/**/*.test.ts",
19
+ "!src/**/__tests__",
19
20
  "scripts/postinstall.js",
20
21
  "README.md",
21
22
  "CHANGELOG.md",
@@ -88,7 +89,7 @@
88
89
  "@anthropic-ai/vertex-sdk": "^0.16.0",
89
90
  "@ast-grep/napi": "^0.42.0",
90
91
  "@aws/bedrock-token-generator": "^1.1.0",
91
- "@pellux/goodvibes-sdk": "0.18.29",
92
+ "@pellux/goodvibes-sdk": "0.18.37",
92
93
  "bash-language-server": "^5.6.0",
93
94
  "fuse.js": "^7.1.0",
94
95
  "graphql": "^16.13.2",
package/src/daemon/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { homedir } from 'node:os';
1
+ import { homedir, networkInterfaces } from 'node:os';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
3
4
  import { RuntimeEventBus } from '@pellux/goodvibes-sdk/platform/runtime/events/index';
4
5
  import { createRuntimeStore } from '../runtime/store/index.ts';
@@ -8,6 +9,13 @@ import { HttpListener } from '@pellux/goodvibes-sdk/platform/daemon/http-listene
8
9
  import { logger } from '@pellux/goodvibes-sdk/platform/utils/logger';
9
10
  import { GlobalNetworkTransportInstaller } from '@pellux/goodvibes-sdk/platform/runtime/network/index';
10
11
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
12
+ import {
13
+ getOrCreateCompanionToken,
14
+ buildCompanionConnectionInfo,
15
+ encodeConnectionPayload,
16
+ formatConnectionBlock,
17
+ } from '@pellux/goodvibes-sdk/platform/pairing/index';
18
+ import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing/qr-generator';
11
19
 
12
20
  type DaemonCliOwnership = {
13
21
  readonly workingDirectory: string;
@@ -19,6 +27,32 @@ type DaemonCliTokens = {
19
27
  readonly httpToken: string | undefined;
20
28
  };
21
29
 
30
+ function getLocalNetworkIp(): string {
31
+ const nets = networkInterfaces();
32
+ for (const name of Object.keys(nets)) {
33
+ for (const net of nets[name] ?? []) {
34
+ if (net.family === 'IPv4' && !net.internal) {
35
+ return net.address;
36
+ }
37
+ }
38
+ }
39
+ return 'localhost';
40
+ }
41
+
42
+ function readBootstrapPassword(credentialPath: string): string | undefined {
43
+ try {
44
+ const content = readFileSync(credentialPath, 'utf-8');
45
+ for (const line of content.split('\n')) {
46
+ if (line.startsWith('password=')) {
47
+ return line.slice('password='.length).trim();
48
+ }
49
+ }
50
+ } catch {
51
+ // credential file may not exist yet
52
+ }
53
+ return undefined;
54
+ }
55
+
22
56
  function resolveDaemonCliOwnership(): DaemonCliOwnership {
23
57
  return {
24
58
  workingDirectory: process.cwd(),
@@ -58,26 +92,68 @@ async function main(): Promise<void> {
58
92
  });
59
93
  const { daemonToken, httpToken } = readDaemonCliTokens(process.env);
60
94
 
61
- daemon.enable({ daemon: true }, daemonToken);
62
- listener.enable({ httpListener: true }, httpToken);
95
+ // If no explicit daemon token is set, use the companion token so mobile apps can connect.
96
+ const companionTokenRecord = getOrCreateCompanionToken('tui');
97
+ const effectiveDaemonToken = daemonToken ?? companionTokenRecord.token;
98
+ const effectiveHttpToken = httpToken ?? effectiveDaemonToken;
99
+
100
+ daemon.enable({ daemon: true }, effectiveDaemonToken);
101
+ listener.enable({ httpListener: true }, effectiveHttpToken);
63
102
 
64
103
  await Promise.all([
65
104
  daemon.start(),
66
105
  config.get('danger.httpListener') ? listener.start() : Promise.resolve(),
67
106
  ]);
68
107
 
108
+ const abortController = new AbortController();
109
+
69
110
  const shutdown = async (): Promise<void> => {
70
- await Promise.allSettled([listener.stop(), daemon.stop()]);
111
+ abortController.abort();
112
+ const SHUTDOWN_DEADLINE_MS = 15_000;
113
+ const timeout = new Promise<'timeout'>((resolve) =>
114
+ setTimeout(() => resolve('timeout'), SHUTDOWN_DEADLINE_MS)
115
+ );
116
+ const stop = Promise.allSettled([listener.stop(), daemon.stop()]).then(() => 'done' as const);
117
+ const result = await Promise.race([stop, timeout]);
118
+ if (result === 'timeout') {
119
+ logger.warn('shutdown deadline exceeded — forcing exit');
120
+ process.exit(1);
121
+ }
71
122
  process.exit(0);
72
123
  };
73
124
 
74
- process.on('SIGINT', () => void shutdown());
75
- process.on('SIGTERM', () => void shutdown());
125
+ let shutdownInFlight = false;
126
+ const handleSignal = (): void => {
127
+ if (shutdownInFlight) return;
128
+ shutdownInFlight = true;
129
+ void shutdown();
130
+ };
131
+
132
+ process.on('SIGINT', handleSignal);
133
+ process.on('SIGTERM', handleSignal);
76
134
 
77
135
  logger.info('goodvibes daemon host started', {
78
136
  daemon: config.get('danger.daemon'),
79
137
  httpListener: config.get('danger.httpListener'),
80
138
  });
139
+
140
+ // Print companion connection info + QR code to stdout.
141
+ // Use the config-driven control plane port, not a hardcoded default.
142
+ const daemonPort = config.get('controlPlane.port');
143
+ const daemonHost = String(process.env.GOODVIBES_DAEMON_HOST ?? getLocalNetworkIp());
144
+ const daemonUrl = `http://${daemonHost}:${daemonPort}`;
145
+ const bootstrapPassword = readBootstrapPassword(userAuth.getBootstrapCredentialPath());
146
+ const connectionInfo = buildCompanionConnectionInfo({
147
+ daemonUrl,
148
+ token: companionTokenRecord.token,
149
+ password: bootstrapPassword,
150
+ surface: 'tui',
151
+ });
152
+ const payload = encodeConnectionPayload(connectionInfo);
153
+ const qrMatrix = generateQrMatrix(payload);
154
+ const qrString = renderQrToString(qrMatrix);
155
+ // eslint-disable-next-line no-console
156
+ console.log(formatConnectionBlock(connectionInfo, qrString));
81
157
  }
82
158
 
83
159
  void main().catch(async (error) => {
@@ -62,6 +62,8 @@ export interface CommandUiActions {
62
62
  model: { id: string; provider: string; displayName: string; registryKey: string };
63
63
  effort: string;
64
64
  contextCap?: number | null;
65
+ /** Which config target to write the selected model to. Defaults to 'main'. */
66
+ target?: import('./model-picker.ts').ModelPickerTarget;
65
67
  }) => void;
66
68
  clearScreen?: () => void;
67
69
  activatePlan?: (planId: string, task: string) => void;
@@ -1,7 +1,7 @@
1
1
  import type { CommandRegistry } from '../command-registry.ts';
2
2
  import { buildMcpAttackPathReview } from '@pellux/goodvibes-sdk/platform/runtime/mcp/index';
3
3
  import { buildKnowledgeInjectionPrompt, selectKnowledgeForTask } from '@pellux/goodvibes-sdk/platform/state/knowledge-injection';
4
- import { listBuiltinSubscriptionProviders } from '../../config/subscription-providers.ts';
4
+ import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
5
5
  import { requireReadModels, requireSubscriptionManager, requireTokenAuditor } from './runtime-services.ts';
6
6
  import { getMemoryApi } from './recall-query.ts';
7
7
 
@@ -1,4 +1,4 @@
1
- import { ServiceRegistry } from '../../config/service-registry.ts';
1
+ import { ServiceRegistry } from '@pellux/goodvibes-sdk/platform/config/service-registry';
2
2
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
3
3
  import { evaluateSessionMaintenance, formatSessionMaintenanceLines } from '@pellux/goodvibes-sdk/platform/runtime/session-maintenance';
4
4
  import { estimateConversationTokens } from '@pellux/goodvibes-sdk/platform/core/context-compaction';
@@ -5,7 +5,7 @@ import { discoverSkills } from '../../panels/skills-panel.ts';
5
5
  import { buildSandboxReview, isRunningInWsl } from '@pellux/goodvibes-sdk/platform/runtime/sandbox/manager';
6
6
  import { renderQemuWrapperTemplate } from '@pellux/goodvibes-sdk/platform/runtime/sandbox/qemu-wrapper-template';
7
7
  import { getPluginDirectories } from '../../plugins/loader';
8
- import { listBuiltinSubscriptionProviders } from '../../config/subscription-providers.ts';
8
+ import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
9
9
  import type { SetupReviewSnapshot } from './local-setup-transfer.ts';
10
10
  import { requireProviderApi, requireReadModels, requireServiceRegistry, requireShellPaths, requireSubscriptionManager } from './runtime-services.ts';
11
11
 
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import type { CommandRegistry } from '../command-registry.ts';
4
4
  import { VERSION } from '../../version.ts';
5
- import { listBuiltinSubscriptionProviders } from '../../config/subscription-providers.ts';
5
+ import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
6
6
  import { handleLocalAuthCommand } from './local-auth-runtime.ts';
7
7
  import { buildAuthInspectionSnapshot, inspectProviderAuth } from '@pellux/goodvibes-sdk/platform/runtime/auth/inspection';
8
8
  import { requireProfileManager, requireSecretsManager, requireServiceRegistry, requireShellPaths, requireSubscriptionManager } from './runtime-services.ts';
@@ -0,0 +1,20 @@
1
+ import type { CommandRegistry } from '../command-registry.ts';
2
+ import { openCommandPanel } from './runtime-services.ts';
3
+
4
+ /**
5
+ * Register the /qrcode command.
6
+ *
7
+ * Opens the QR Code panel which displays a scannable QR code for
8
+ * companion app pairing, along with connection URL, token, and username.
9
+ */
10
+ export function registerQrcodeRuntimeCommands(registry: CommandRegistry): void {
11
+ registry.register({
12
+ name: 'qrcode',
13
+ aliases: ['qr', 'pair'],
14
+ description: 'Open the QR code panel for companion app pairing',
15
+ usage: '',
16
+ handler(_args, ctx) {
17
+ openCommandPanel(ctx, 'qr-code');
18
+ },
19
+ });
20
+ }
@@ -4,7 +4,7 @@ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
4
4
  import { createOAuthLocalListener } from '@pellux/goodvibes-sdk/platform/config/oauth-local-listener';
5
5
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config/openai-codex-auth';
6
6
  import type { OAuthProviderConfig, ProviderSubscription } from '@pellux/goodvibes-sdk/platform/config/subscriptions';
7
- import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '../../config/subscription-providers.ts';
7
+ import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
8
8
  import { inspectProviderAuth } from '@pellux/goodvibes-sdk/platform/runtime/auth/inspection';
9
9
  import { openExternalUrl } from '@pellux/goodvibes-sdk/platform/utils/open-external';
10
10
  import { requireSecretsManager, requireServiceRegistry, requireShellPaths, requireSubscriptionManager } from './runtime-services.ts';
@@ -52,6 +52,7 @@ import { registerProviderAccountsRuntimeCommands } from './commands/provider-acc
52
52
  import { registerLocalAuthRuntimeCommands } from './commands/local-auth-runtime.ts';
53
53
  import { registerIntelligenceRuntimeCommands } from './commands/intelligence-runtime.ts';
54
54
  import { registerConversationRuntimeCommands } from './commands/conversation-runtime.ts';
55
+ import { registerQrcodeRuntimeCommands } from './commands/qrcode-runtime.ts';
55
56
 
56
57
  /**
57
58
  * registerBuiltinCommands - Register all built-in slash commands into the registry.
@@ -98,6 +99,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
98
99
  registerLocalAuthRuntimeCommands(registry);
99
100
  registerIntelligenceRuntimeCommands(registry);
100
101
  registerConversationRuntimeCommands(registry);
102
+ registerQrcodeRuntimeCommands(registry);
101
103
  registerLocalRuntimeCommands(registry);
102
104
  registerSessionWorkflowCommands(registry);
103
105
  registerDiscoveryRuntimeCommands(registry);
@@ -166,6 +166,12 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
166
166
  searchManager: context.searchManager,
167
167
  scroll: context.scroll,
168
168
  getScrollTop: context.getScrollTop,
169
+ openModelPickerWithTarget: context.commandContext?.openModelPicker
170
+ ? (target: import('./model-picker.ts').ModelPickerTarget) => {
171
+ context.modelPicker.target = target;
172
+ context.commandContext!.openModelPicker!();
173
+ }
174
+ : undefined,
169
175
  }, token);
170
176
  context.selectionCallback = modalRoute.selectionCallback;
171
177
  context.helpOverlayActive = modalRoute.helpOverlayActive;
@@ -215,7 +215,10 @@ type SettingsRouteState = {
215
215
  nextCategory: () => void;
216
216
  editBackspace: () => void;
217
217
  editChar: (char: string) => void;
218
+ pendingModelPickerTarget: import('./model-picker.ts').ModelPickerTarget | null;
218
219
  };
220
+ /** Called when the settings modal requests the model picker for a non-main target. */
221
+ openModelPickerWithTarget?: (target: import('./model-picker.ts').ModelPickerTarget) => void;
219
222
  requestRender: () => void;
220
223
  handleEscape: () => void;
221
224
  };
@@ -231,7 +234,14 @@ export function handleSettingsModalToken(state: SettingsRouteState, token: Input
231
234
  if (token.logicalName === 'enter' || (token.logicalName === 'space' && !state.settingsModal.editingMode)) {
232
235
  if (state.settingsModal.editingMode) state.settingsModal.commitEdit();
233
236
  else if (state.settingsModal.currentCategory === 'flags') state.settingsModal.toggleSelectedFlag();
234
- else state.settingsModal.activateSelected();
237
+ else {
238
+ state.settingsModal.activateSelected();
239
+ const pickerTarget = state.settingsModal.pendingModelPickerTarget;
240
+ if (pickerTarget !== null) {
241
+ state.settingsModal.pendingModelPickerTarget = null;
242
+ state.openModelPickerWithTarget?.(pickerTarget);
243
+ }
244
+ }
235
245
  } else if ((token.logicalName === 'left' || token.logicalName === 'right') && !state.settingsModal.editingMode) {
236
246
  state.settingsModal.adjustSelected(token.logicalName, token.shift ? 10 : 1);
237
247
  } else if (token.logicalName === 'up') state.settingsModal.moveUp();
@@ -241,7 +251,14 @@ export function handleSettingsModalToken(state: SettingsRouteState, token: Input
241
251
  } else if (token.type === 'text') {
242
252
  if (token.value === ' ' && !state.settingsModal.editingMode) {
243
253
  if (state.settingsModal.currentCategory === 'flags') state.settingsModal.toggleSelectedFlag();
244
- else state.settingsModal.activateSelected();
254
+ else {
255
+ state.settingsModal.activateSelected();
256
+ const pickerTarget = state.settingsModal.pendingModelPickerTarget;
257
+ if (pickerTarget !== null) {
258
+ state.settingsModal.pendingModelPickerTarget = null;
259
+ state.openModelPickerWithTarget?.(pickerTarget);
260
+ }
261
+ }
245
262
  } else if (state.settingsModal.editingMode) {
246
263
  state.settingsModal.editChar(token.value);
247
264
  }
@@ -72,6 +72,8 @@ export type ModalTokenRouteState = {
72
72
  searchManager: SearchManager;
73
73
  scroll: (delta: number) => void;
74
74
  getScrollTop: () => number;
75
+ /** Callback to open the model picker with a specific target (helper or tool). Optional — only wired when available. */
76
+ openModelPickerWithTarget?: (target: import('./model-picker.ts').ModelPickerTarget) => void;
75
77
  };
76
78
 
77
79
  export function handleModalTokenRoutes(state: ModalTokenRouteState, token: InputToken): {
@@ -117,6 +119,7 @@ export function handleModalTokenRoutes(state: ModalTokenRouteState, token: Input
117
119
 
118
120
  if (handleSettingsModalToken({
119
121
  settingsModal: state.settingsModal,
122
+ openModelPickerWithTarget: state.openModelPickerWithTarget,
120
123
  requestRender: state.requestRender,
121
124
  handleEscape: state.handleEscape,
122
125
  }, token)) {
@@ -57,9 +57,11 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
57
57
  if (selected.reasoningEffort && selected.reasoningEffort.length > 0) {
58
58
  state.modelPicker.showEffortPicker(selected, currentEffort);
59
59
  } else {
60
+ const target = state.modelPicker.target;
60
61
  state.commandContext?.completeModelSelection?.({
61
62
  model: selected,
62
63
  effort: currentEffort,
64
+ target,
63
65
  });
64
66
  state.modelPicker.close();
65
67
  if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
@@ -76,7 +78,7 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
76
78
  } else if (mode === 'effort') {
77
79
  const model = state.modelPicker.pendingModel;
78
80
  const effort = state.modelPicker.effortLevels[idx];
79
- if (model && effort) state.commandContext?.completeModelSelection?.({ model, effort });
81
+ if (model && effort) state.commandContext?.completeModelSelection?.({ model, effort, target: state.modelPicker.target });
80
82
  state.modelPicker.close();
81
83
  if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
82
84
  } else if (mode === 'contextCap') {
@@ -86,7 +88,7 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
86
88
  const parsedCap = rawInput.length > 0 ? parseInt(rawInput, 10) : null;
87
89
  const validCap = parsedCap !== null && parsedCap > 0 && parsedCap <= 10_000_000 ? parsedCap : null;
88
90
  const effort = state.commandContext?.session.runtime.reasoningEffort ?? 'medium';
89
- state.commandContext?.completeModelSelection?.({ model: capModel, effort, contextCap: validCap });
91
+ state.commandContext?.completeModelSelection?.({ model: capModel, effort, contextCap: validCap, target: state.modelPicker.target });
90
92
  }
91
93
  state.modelPicker.close();
92
94
  if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
@@ -7,6 +7,14 @@ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers/
7
7
 
8
8
  export type PickerMode = 'model' | 'provider' | 'effort' | 'contextCap';
9
9
 
10
+ /**
11
+ * Which config keys the model picker writes to on commit.
12
+ * 'main' → provider.provider + provider.model (default)
13
+ * 'helper' → helper.globalProvider + helper.globalModel (+ helper.enabled: true)
14
+ * 'tool' → tools.llmProvider + tools.llmModel (+ tools.llmEnabled: true)
15
+ */
16
+ export type ModelPickerTarget = 'main' | 'helper' | 'tool';
17
+
10
18
  /**
11
19
  * Pricing tier filter.
12
20
  * 'paid' matches ModelDefinition tiers 'standard' and 'premium' for forward-compat
@@ -129,6 +137,8 @@ export class ModelPickerModal {
129
137
 
130
138
  public active = false;
131
139
  public mode: PickerMode = 'model';
140
+ /** Which config target this picker session will write to on commit. */
141
+ public target: ModelPickerTarget = 'main';
132
142
  public searchFocused = false;
133
143
  /** Tracks the mode we came from, for back-navigation. */
134
144
  public previousMode: PickerMode | null = null;
@@ -288,6 +298,7 @@ export class ModelPickerModal {
288
298
  close(): void {
289
299
  this.active = false;
290
300
  this.mode = 'model';
301
+ this.target = 'main';
291
302
  this.models = [];
292
303
  this.providers = [];
293
304
  this.pendingModel = null;
@@ -11,9 +11,10 @@
11
11
  */
12
12
 
13
13
  import { CONFIG_SCHEMA, type ConfigSetting, type ConfigKey, type PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config/schema';
14
+ import type { ModelPickerTarget } from './model-picker.ts';
14
15
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
15
16
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config/subscriptions';
16
- import { listBuiltinSubscriptionProviders } from '../config/subscription-providers.ts';
17
+ import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
17
18
  import type { ProviderAuthFreshness, ProviderAuthRoute } from '@pellux/goodvibes-sdk/platform/runtime/provider-accounts/registry';
18
19
  import { getResolvedSettingLookup } from '@pellux/goodvibes-sdk/platform/runtime/settings/control-plane';
19
20
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
@@ -84,6 +85,16 @@ export interface SubscriptionEntry {
84
85
  nextActions?: string[];
85
86
  }
86
87
 
88
+ /**
89
+ * Map a config key to the model picker target it should open, or null if the
90
+ * setting should use the normal inline text-edit flow.
91
+ */
92
+ function _modelPickerTargetForKey(key: string): ModelPickerTarget | null {
93
+ if (key === 'helper.globalProvider' || key === 'helper.globalModel') return 'helper';
94
+ if (key === 'tools.llmProvider' || key === 'tools.llmModel') return 'tool';
95
+ return null;
96
+ }
97
+
87
98
  function roundToPrecision(value: number, precision: number): number {
88
99
  const factor = 10 ** precision;
89
100
  return Math.round(value * factor) / factor;
@@ -121,6 +132,12 @@ export class SettingsModal {
121
132
  public editBuffer = '';
122
133
  /** Server awaiting explicit allow-all confirmation, if any. */
123
134
  public mcpAllowAllConfirmationTarget: string | null = null;
135
+ /**
136
+ * Set by activateSelected() when the highlighted setting should open the
137
+ * model picker rather than entering inline text edit mode.
138
+ * Consumed and cleared by the route handler after each Enter/Space action.
139
+ */
140
+ public pendingModelPickerTarget: ModelPickerTarget | null = null;
124
141
  /** Provider awaiting explicit logout confirmation, if any. */
125
142
  public subscriptionLogoutConfirmationTarget: string | null = null;
126
143
 
@@ -307,6 +324,13 @@ export class SettingsModal {
307
324
 
308
325
  const { setting } = entry;
309
326
 
327
+ // Delegate provider/model picker settings to the model picker UI
328
+ const pickerTarget = _modelPickerTargetForKey(setting.key);
329
+ if (pickerTarget !== null) {
330
+ this.pendingModelPickerTarget = pickerTarget;
331
+ return;
332
+ }
333
+
310
334
  if (setting.type === 'boolean') {
311
335
  const newVal = !entry.currentValue;
312
336
  this._setValue(setting.key, newVal);
@@ -522,7 +546,9 @@ export class SettingsModal {
522
546
  }
523
547
 
524
548
  for (const setting of CONFIG_SCHEMA) {
525
- const cat = setting.key.split('.')[0] as SettingsCategory;
549
+ const rawCat = setting.key.split('.')[0] as string;
550
+ // Route helper.* settings into the tools group for unified display
551
+ const cat = (rawCat === 'helper' ? 'tools' : rawCat) as SettingsCategory;
526
552
  if (!this.groups.has(cat)) continue;
527
553
  const currentValue = configManager.get(setting.key as ConfigKey);
528
554
  const resolved = getResolvedSettingLookup(configManager, setting.key as ConfigKey)?.entry;
@@ -702,7 +728,9 @@ export class SettingsModal {
702
728
  try {
703
729
  this.configManager.setDynamic(key, value);
704
730
  // Update the cached entry in-place — avoids full schema re-scan on each edit
705
- const cat = key.split('.')[0] as SettingsCategory;
731
+ const rawCat = key.split('.')[0] as string;
732
+ // helper.* entries are stored in the tools group
733
+ const cat = (rawCat === 'helper' ? 'tools' : rawCat) as SettingsCategory;
706
734
  const entries = this.groups.get(cat);
707
735
  if (entries) {
708
736
  const entry = entries.find(e => e.setting.key === key);