@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
@@ -1,7 +1,7 @@
1
1
  import { readFileSync, existsSync, watch, type FSWatcher } from 'fs';
2
2
  import type { Line } from '../types/grid.ts';
3
3
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
4
- import { BasePanel } from './base-panel.ts';
4
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
5
  import type { AgentManager, AgentRecord } from '@pellux/goodvibes-sdk/platform/tools/agent/index';
6
6
  import type { AgentEvent } from '@pellux/goodvibes-sdk/platform/runtime/events/index';
7
7
  import type { UiEventFeed } from '../runtime/ui-events.ts';
@@ -38,7 +38,7 @@ export interface AgentLogsPanelDeps {
38
38
  // AgentLogsPanel
39
39
  // ---------------------------------------------------------------------------
40
40
 
41
- export class AgentLogsPanel extends BasePanel {
41
+ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
42
42
  // ── Agent state ─────────────────────────────────────────────────────────
43
43
  private agents: AgentRecord[] = [];
44
44
  private selectedAgentIndex = 0;
@@ -47,7 +47,6 @@ export class AgentLogsPanel extends BasePanel {
47
47
  private allEntries: LogEntry[] = []; // raw parsed JSONL for selected agent
48
48
  private filteredEntries: LogEntry[] = []; // after filter applied
49
49
  private lastFileSize = 0;
50
- private scrollOffset = 0;
51
50
 
52
51
  // ── Modes ────────────────────────────────────────────────────────────────
53
52
  private autoFollow = true;
@@ -68,6 +67,16 @@ export class AgentLogsPanel extends BasePanel {
68
67
  this._subscribeEvents();
69
68
  }
70
69
 
70
+ // ── ScrollableListPanel<LogEntry> contract ────────────────────────────────
71
+
72
+ protected getItems(): readonly LogEntry[] {
73
+ return this.filteredEntries;
74
+ }
75
+
76
+ protected renderItem(entry: LogEntry, _index: number, _selected: boolean, width: number): Line {
77
+ return this._renderEntry(entry, width);
78
+ }
79
+
71
80
  // ── Lifecycle ─────────────────────────────────────────────────────────────
72
81
 
73
82
  override onActivate(): void {
@@ -100,7 +109,7 @@ export class AgentLogsPanel extends BasePanel {
100
109
  this._cycleFilter();
101
110
  return true;
102
111
  case 'g': // g — jump to top
103
- this.scrollOffset = 0;
112
+ this.selectedIndex = 0;
104
113
  this.autoFollow = false;
105
114
  this.markDirty();
106
115
  return true;
@@ -109,19 +118,8 @@ export class AgentLogsPanel extends BasePanel {
109
118
  this._clampScroll();
110
119
  this.markDirty();
111
120
  return true;
112
- case 'k': // k / up
113
- case '\x1b[A':
114
- this.autoFollow = false;
115
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
116
- this.markDirty();
117
- return true;
118
- case 'j': // j / down
119
- case '\x1b[B':
120
- this.scrollOffset++;
121
- this.markDirty();
122
- return true;
123
121
  default:
124
- return false;
122
+ return super.handleInput(key);
125
123
  }
126
124
  }
127
125
 
@@ -205,7 +203,7 @@ export class AgentLogsPanel extends BasePanel {
205
203
 
206
204
  const focusIndex = this.autoFollow
207
205
  ? Math.max(0, this.filteredEntries.length - 1)
208
- : Math.min(this.scrollOffset, Math.max(0, this.filteredEntries.length - 1));
206
+ : Math.min(this.selectedIndex, Math.max(0, this.filteredEntries.length - 1));
209
207
  const summarySection = { title: 'Summary', lines: summaryLines } as const;
210
208
  const agentsSection = { title: 'Agents', lines: [selectorLine] } as const;
211
209
  const logStreamSection = resolveScrollablePanelSection(width, height, {
@@ -217,11 +215,11 @@ export class AgentLogsPanel extends BasePanel {
217
215
  title: 'Log Stream',
218
216
  scrollableLines: this.filteredEntries.map((entry) => this._renderEntry(entry, width)),
219
217
  selectedIndex: focusIndex,
220
- scrollOffset: this.scrollOffset,
218
+ scrollOffset: this.scrollStart,
221
219
  minRows: 8,
222
220
  },
223
221
  });
224
- this.scrollOffset = logStreamSection.scrollOffset;
222
+ this.scrollStart = logStreamSection.scrollOffset;
225
223
 
226
224
  return buildPanelWorkspace(width, height, {
227
225
  title: ' Agents',
@@ -273,7 +271,7 @@ export class AgentLogsPanel extends BasePanel {
273
271
  this.allEntries = parseAgentJsonl(content);
274
272
  this._applyFilter();
275
273
  if (this.autoFollow) {
276
- this.scrollOffset = Math.max(0, this.filteredEntries.length - 1);
274
+ this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
277
275
  }
278
276
  this.markDirty();
279
277
  } catch {
@@ -373,7 +371,8 @@ export class AgentLogsPanel extends BasePanel {
373
371
  this.allEntries = [];
374
372
  this.filteredEntries = [];
375
373
  this.lastFileSize = 0;
376
- this.scrollOffset = 0;
374
+ this.selectedIndex = 0;
375
+ this.scrollStart = 0;
377
376
  this.autoFollow = true;
378
377
  const agent = this._selectedAgent();
379
378
  if (agent) {
@@ -406,7 +405,7 @@ export class AgentLogsPanel extends BasePanel {
406
405
  this.allEntries = parseAgentJsonl(content);
407
406
  this._applyFilter();
408
407
  if (this.autoFollow) {
409
- this.scrollOffset = Math.max(0, this.filteredEntries.length - 1);
408
+ this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
410
409
  }
411
410
  } catch {
412
411
  this.allEntries = [];
@@ -438,7 +437,7 @@ export class AgentLogsPanel extends BasePanel {
438
437
  this.filter = FILTER_CYCLE[(idx + 1) % FILTER_CYCLE.length]!;
439
438
  this._applyFilter();
440
439
  if (this.autoFollow) {
441
- this.scrollOffset = Math.max(0, this.filteredEntries.length - 1);
440
+ this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
442
441
  }
443
442
  this.markDirty();
444
443
  }
@@ -449,7 +448,7 @@ export class AgentLogsPanel extends BasePanel {
449
448
  }
450
449
 
451
450
  private _clampScroll(): void {
452
- this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, this.filteredEntries.length - 1));
451
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredEntries.length - 1));
453
452
  }
454
453
 
455
454
  // ── Private: rendering helpers ─────────────────────────────────────────────
@@ -40,6 +40,12 @@ export abstract class BasePanel implements Panel {
40
40
 
41
41
  abstract render(width: number, height: number): Line[];
42
42
 
43
+ /** R2: Mark this panel dirty — it will be re-rendered on the next compositor frame. */
44
+ public invalidate(): void { this.needsRender = true; }
45
+
46
+ /** R2: Called by the compositor after a successful render to clear the dirty flag. */
47
+ public markRendered(): void { this.needsRender = false; }
48
+
43
49
  protected markDirty(): void { this.needsRender = true; }
44
50
 
45
51
  /**
@@ -1,12 +1,78 @@
1
+ import { networkInterfaces } from 'node:os';
2
+ import { readFileSync } from 'node:fs';
1
3
  import type { PanelManager } from '../panel-manager.ts';
2
4
  import { SessionBrowserPanel } from '../session-browser-panel.ts';
5
+ import { QrPanel } from '../qr-panel.ts';
3
6
  import { DocsPanel } from '../docs-panel.ts';
4
7
  import { PanelListPanel } from '../panel-list-panel.ts';
5
8
  import { TokenBudgetPanel } from '../token-budget-panel.ts';
6
9
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
7
10
  import { requireUiServices } from './shared.ts';
11
+ import {
12
+ getOrCreateCompanionToken,
13
+ regenerateCompanionToken,
14
+ buildCompanionConnectionInfo,
15
+ } from '@pellux/goodvibes-sdk/platform/pairing/index';
16
+ import { copyToClipboard } from '../../utils/clipboard.ts';
17
+
18
+ function getLocalNetworkIp(): string {
19
+ const nets = networkInterfaces();
20
+ for (const name of Object.keys(nets)) {
21
+ for (const net of nets[name] ?? []) {
22
+ if (net.family === 'IPv4' && !net.internal) {
23
+ return net.address;
24
+ }
25
+ }
26
+ }
27
+ return 'localhost';
28
+ }
29
+
30
+ function readBootstrapPassword(credentialPath: string): string | undefined {
31
+ try {
32
+ const content = readFileSync(credentialPath, 'utf-8');
33
+ for (const line of content.split('\n')) {
34
+ if (line.startsWith('password=')) {
35
+ return line.slice('password='.length).trim();
36
+ }
37
+ }
38
+ } catch {
39
+ // credential file may not exist yet
40
+ }
41
+ return undefined;
42
+ }
8
43
 
9
44
  export function registerSessionPanels(manager: PanelManager, deps: ResolvedBuiltinPanelDeps): void {
45
+ manager.registerType({
46
+ id: 'qr-code',
47
+ name: 'QR Code',
48
+ icon: 'Q',
49
+ category: 'session',
50
+ description: 'QR code for companion app pairing — scan to connect a mobile or desktop companion',
51
+ factory: () => {
52
+ const tokenRecord = getOrCreateCompanionToken('tui');
53
+ const daemonPort = deps.configManager.get('controlPlane.port');
54
+ const daemonHost = String(process.env['GOODVIBES_DAEMON_HOST'] ?? getLocalNetworkIp());
55
+ const daemonUrl = `http://${daemonHost}:${daemonPort}`;
56
+ const bootstrapPassword = readBootstrapPassword(deps.localUserAuthManager.getBootstrapCredentialPath());
57
+ const connectionInfo = buildCompanionConnectionInfo({
58
+ daemonUrl,
59
+ token: tokenRecord.token,
60
+ password: bootstrapPassword,
61
+ surface: 'tui',
62
+ });
63
+ const regenerate = (): typeof connectionInfo => {
64
+ const newRecord = regenerateCompanionToken('tui');
65
+ return buildCompanionConnectionInfo({
66
+ daemonUrl,
67
+ token: newRecord.token,
68
+ password: bootstrapPassword,
69
+ surface: 'tui',
70
+ });
71
+ };
72
+ return new QrPanel(connectionInfo, regenerate, copyToClipboard);
73
+ },
74
+ });
75
+
10
76
  manager.registerType({
11
77
  id: 'sessions',
12
78
  name: 'Sessions',
@@ -1,5 +1,5 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
2
- import type { ServiceRegistry } from '../../config/service-registry.ts';
2
+ import type { ServiceRegistry } from '@pellux/goodvibes-sdk/platform/config/service-registry';
3
3
  import type { ToolRegistry } from '@pellux/goodvibes-sdk/platform/tools/registry';
4
4
  import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers/registry';
5
5
  import type { Orchestrator } from '../../core/orchestrator';
@@ -1,4 +1,4 @@
1
- import { listBuiltinSubscriptionProviders } from '../config/subscription-providers.ts';
1
+ import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
2
2
  import type {
3
3
  ProviderAccountInspectionQuery,
4
4
  } from '../runtime/ui-service-queries.ts';
@@ -1,6 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import {
5
5
  buildDetailBlock,
6
6
  buildEmptyState,
@@ -29,11 +29,9 @@ const C = {
29
29
  selectBg: '#1e293b',
30
30
  } as const;
31
31
 
32
- export class ProviderAccountsPanel extends BasePanel {
32
+ export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRecord> {
33
33
  private records: ProviderAccountRecord[] = [];
34
34
  private loading = false;
35
- private selectedIndex = 0;
36
- private scrollOffset = 0;
37
35
  private readonly providerAccounts: ProviderAccountSnapshotQuery;
38
36
 
39
37
  public constructor(deps: ProviderAccountsPanelDeps) {
@@ -47,22 +45,26 @@ export class ProviderAccountsPanel extends BasePanel {
47
45
  if (!this.loading) void this.refresh();
48
46
  }
49
47
 
48
+ protected getItems(): readonly ProviderAccountRecord[] {
49
+ return this.records;
50
+ }
51
+
52
+ protected renderItem(item: ProviderAccountRecord, _index: number, selected: boolean, width: number): Line {
53
+ return buildPanelListRow(width, [
54
+ { text: item.providerId.padEnd(16), fg: item.active ? C.good : C.value },
55
+ { text: ` ${item.activeRoute.padEnd(14)}`, fg: item.activeRoute === 'subscription' ? C.info : item.activeRoute === 'api-key' ? C.warn : item.activeRoute === 'service-oauth' ? C.value : C.dim },
56
+ { text: ` models=${String(item.modelCount).padEnd(4)}`, fg: C.dim },
57
+ { text: ` ${item.authFreshness.padEnd(10)}`, fg: item.authFreshness === 'expired' ? C.bad : item.authFreshness === 'expiring' || item.authFreshness === 'pending' ? C.warn : C.dim },
58
+ { text: ` issues=${String(item.issues.length).padEnd(2)}`, fg: item.issues.length > 0 ? C.bad : C.good },
59
+ ], C, { selected });
60
+ }
61
+
50
62
  public handleInput(key: string): boolean {
51
63
  if (key === 'r') {
52
64
  void this.refresh();
53
65
  return true;
54
66
  }
55
- if (key === 'up' || key === 'k') {
56
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
57
- this.markDirty();
58
- return true;
59
- }
60
- if (key === 'down' || key === 'j') {
61
- this.selectedIndex = Math.min(Math.max(0, this.records.length - 1), this.selectedIndex + 1);
62
- this.markDirty();
63
- return true;
64
- }
65
- return false;
67
+ return super.handleInput(key);
66
68
  }
67
69
 
68
70
  private async refresh(): Promise<void> {
@@ -70,7 +72,7 @@ export class ProviderAccountsPanel extends BasePanel {
70
72
  this.markDirty();
71
73
  const snapshot = await this.buildSnapshot();
72
74
  this.records = [...snapshot.providers];
73
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.records.length - 1));
75
+ this.clampSelection();
74
76
  this.loading = false;
75
77
  this.markDirty();
76
78
  }
@@ -177,15 +179,9 @@ export class ProviderAccountsPanel extends BasePanel {
177
179
  }
178
180
  const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'Provider posture', postureLines, C) };
179
181
  const detailsSection: PanelWorkspaceSection = { lines: buildDetailBlock(width, 'Selected provider', detailRows, C) };
180
- const rawProviderLines: Line[] = this.records.map((record, absolute) => {
181
- return buildPanelListRow(width, [
182
- { text: record.providerId.padEnd(16), fg: record.active ? C.good : C.value },
183
- { text: ` ${record.activeRoute.padEnd(14)}`, fg: record.activeRoute === 'subscription' ? C.info : record.activeRoute === 'api-key' ? C.warn : record.activeRoute === 'service-oauth' ? C.value : C.dim },
184
- { text: ` models=${String(record.modelCount).padEnd(4)}`, fg: C.dim },
185
- { text: ` ${record.authFreshness.padEnd(10)}`, fg: record.authFreshness === 'expired' ? C.bad : record.authFreshness === 'expiring' || record.authFreshness === 'pending' ? C.warn : C.dim },
186
- { text: ` issues=${String(record.issues.length).padEnd(2)}`, fg: record.issues.length > 0 ? C.bad : C.good },
187
- ], C, { selected: absolute === this.selectedIndex });
188
- });
182
+ const rawProviderLines: Line[] = this.records.map((record, absolute) =>
183
+ this.renderItem(record, absolute, absolute === this.selectedIndex, width),
184
+ );
189
185
  const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
190
186
  intro,
191
187
  footerLines,
@@ -195,14 +191,14 @@ export class ProviderAccountsPanel extends BasePanel {
195
191
  title: 'Providers',
196
192
  scrollableLines: rawProviderLines,
197
193
  selectedIndex: this.selectedIndex,
198
- scrollOffset: this.scrollOffset,
194
+ scrollOffset: this.scrollStart,
199
195
  guardRows: 1,
200
196
  minRows: 4,
201
197
  appendWindowSummary: { dimColor: C.dim },
202
198
  },
203
199
  afterSections: [detailsSection],
204
200
  });
205
- this.scrollOffset = resolvedProvidersSection.scrollOffset;
201
+ this.scrollStart = resolvedProvidersSection.scrollOffset;
206
202
  const sections: PanelWorkspaceSection[] = [
207
203
  postureSection,
208
204
  resolvedProvidersSection.section,
@@ -0,0 +1,182 @@
1
+ import type { Line } from '../types/grid.ts';
2
+ import { createEmptyLine } from '../types/grid.ts';
3
+ import { BasePanel } from './base-panel.ts';
4
+ import {
5
+ buildPanelLine,
6
+ DEFAULT_PANEL_PALETTE,
7
+ } from './polish.ts';
8
+ import { renderQrMatrix, generateQrMatrix } from '../renderer/qr-renderer.ts';
9
+ import { encodeConnectionPayload } from '@pellux/goodvibes-sdk/platform/pairing/index';
10
+
11
+ const C = {
12
+ ...DEFAULT_PANEL_PALETTE,
13
+ url: '#38bdf8',
14
+ token: '#a78bfa',
15
+ hint: '#64748b',
16
+ qrFg: '#000000',
17
+ qrBg: '#ffffff',
18
+ } as const;
19
+
20
+ /**
21
+ * Connection info passed to the QR panel.
22
+ * Populated at construction; updated when the token is regenerated.
23
+ */
24
+ export interface QrPanelConnectionInfo {
25
+ /** Full connection URL (e.g. http://192.168.1.x:3141) */
26
+ readonly url: string;
27
+ /** Auth token */
28
+ readonly token: string;
29
+ /** Username associated with the companion session */
30
+ readonly username: string;
31
+ /** Bootstrap password for companion authentication */
32
+ readonly password?: string;
33
+ /** SDK/surface version (defaults to '0.0.0' if omitted) */
34
+ readonly version?: string;
35
+ /** Surface identifier (defaults to 'tui' if omitted) */
36
+ readonly surface?: string;
37
+ }
38
+
39
+ /**
40
+ * Callback used by the panel to regenerate the companion token.
41
+ * Returns updated connection info.
42
+ */
43
+ export type RegenerateTokenFn = () => QrPanelConnectionInfo;
44
+
45
+ /**
46
+ * Callback used by the panel to copy text to the clipboard.
47
+ */
48
+ export type CopyToClipboardFn = (text: string) => void;
49
+
50
+ /**
51
+ * QrPanel - displays a QR code for companion app pairing.
52
+ *
53
+ * Shows connection URL, truncated token, and username above the QR code.
54
+ * Supports `r` to regenerate the token and `c` to copy the token.
55
+ *
56
+ * QR matrix generation uses the SDK's `generateQrMatrix` via `encodeConnectionPayload`.
57
+ */
58
+ export class QrPanel extends BasePanel {
59
+ private connectionInfo: QrPanelConnectionInfo;
60
+ private readonly regenerateToken: RegenerateTokenFn | undefined;
61
+ private readonly copyToClipboard: CopyToClipboardFn | undefined;
62
+ private lastStatus = '';
63
+
64
+ public constructor(
65
+ connectionInfo: QrPanelConnectionInfo,
66
+ regenerateToken?: RegenerateTokenFn,
67
+ copyToClipboard?: CopyToClipboardFn,
68
+ ) {
69
+ super('qr-code', 'QR Code', 'Q', 'session');
70
+ this.connectionInfo = connectionInfo;
71
+ this.regenerateToken = regenerateToken;
72
+ this.copyToClipboard = copyToClipboard;
73
+ }
74
+
75
+ public handleInput(key: string): boolean {
76
+ if (key === 'r') {
77
+ if (this.regenerateToken) {
78
+ this.connectionInfo = this.regenerateToken();
79
+ this.lastStatus = 'Token regenerated.';
80
+ } else {
81
+ this.lastStatus = 'Regeneration not available.';
82
+ }
83
+ this.markDirty();
84
+ return true;
85
+ }
86
+ if (key === 'c') {
87
+ if (this.copyToClipboard) {
88
+ this.copyToClipboard(this.connectionInfo.token);
89
+ this.lastStatus = 'Token copied to clipboard.';
90
+ } else {
91
+ this.lastStatus = 'Clipboard not available.';
92
+ }
93
+ this.markDirty();
94
+ return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ public render(width: number, height: number): Line[] {
100
+ this.needsRender = false;
101
+ const lines: Line[] = [];
102
+
103
+ const { url, token, username, password } = this.connectionInfo;
104
+
105
+ // ── Connection info header ─────────────────────────────────────────────
106
+ lines.push(createEmptyLine(width));
107
+ lines.push(
108
+ buildPanelLine(width, [
109
+ [' URL ', C.label],
110
+ [url.slice(0, Math.max(0, width - 12)), C.url],
111
+ ]),
112
+ );
113
+ lines.push(
114
+ buildPanelLine(width, [
115
+ [' Token ', C.label],
116
+ [token, C.token],
117
+ ]),
118
+ );
119
+ lines.push(
120
+ buildPanelLine(width, [
121
+ [' Username ', C.label],
122
+ [username.slice(0, Math.max(0, width - 12)), C.value],
123
+ ]),
124
+ );
125
+ if (password !== undefined) {
126
+ lines.push(
127
+ buildPanelLine(width, [
128
+ [' Password ', C.label],
129
+ [password, C.value],
130
+ ]),
131
+ );
132
+ }
133
+ lines.push(createEmptyLine(width));
134
+
135
+ // ── QR code ────────────────────────────────────────────────────────────
136
+ const payload = encodeConnectionPayload({
137
+ url: this.connectionInfo.url,
138
+ token: this.connectionInfo.token,
139
+ username: this.connectionInfo.username,
140
+ ...(this.connectionInfo.password !== undefined ? { password: this.connectionInfo.password } : {}),
141
+ version: this.connectionInfo.version ?? '0.0.0',
142
+ surface: this.connectionInfo.surface ?? 'tui',
143
+ });
144
+ const matrix = generateQrMatrix(payload);
145
+ const qrLines = renderQrMatrix(matrix.modules, width, { fg: C.qrFg, bg: C.qrBg });
146
+ for (const qrLine of qrLines) {
147
+ lines.push(qrLine);
148
+ }
149
+
150
+ lines.push(createEmptyLine(width));
151
+
152
+ // ── Status message (ephemeral) ─────────────────────────────────────────
153
+ if (this.lastStatus) {
154
+ lines.push(
155
+ buildPanelLine(width, [
156
+ [` ${this.lastStatus} `, C.hint],
157
+ ]),
158
+ );
159
+ }
160
+
161
+ // ── Hints ──────────────────────────────────────────────────────────────
162
+ const hintsLine = buildPanelLine(width, [
163
+ [' r ', C.hint],
164
+ ['regenerate ', C.dim],
165
+ [' c ', C.hint],
166
+ ['copy token', C.dim],
167
+ ]);
168
+
169
+ // Push hints at the bottom if we have room, otherwise append after QR
170
+ const remaining = height - lines.length;
171
+ if (remaining > 2) {
172
+ // Fill with empty lines to push hints toward bottom
173
+ const fillCount = Math.max(0, remaining - 2);
174
+ for (let i = 0; i < fillCount; i++) {
175
+ lines.push(createEmptyLine(width));
176
+ }
177
+ }
178
+ lines.push(hintsLine);
179
+
180
+ return lines;
181
+ }
182
+ }