@pellux/goodvibes-tui 0.18.13 → 0.18.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +3 -2
- package/src/daemon/cli.ts +82 -6
- package/src/input/command-registry.ts +2 -0
- package/src/input/commands/control-room-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +1 -1
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/platform-access-runtime.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +20 -0
- package/src/input/commands/subscription-runtime.ts +1 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed.ts +6 -0
- package/src/input/handler-modal-routes.ts +19 -2
- package/src/input/handler-modal-token-routes.ts +3 -0
- package/src/input/handler-picker-routes.ts +4 -2
- package/src/input/model-picker.ts +11 -0
- package/src/input/settings-modal.ts +31 -3
- package/src/panels/agent-logs-panel.ts +23 -24
- package/src/panels/builtin/session.ts +66 -0
- package/src/panels/builtin/shared.ts +1 -1
- package/src/panels/provider-account-snapshot.ts +1 -1
- package/src/panels/provider-accounts-panel.ts +23 -27
- package/src/panels/qr-panel.ts +182 -0
- package/src/panels/scrollable-list-panel.ts +407 -0
- package/src/panels/services-panel.ts +1 -1
- package/src/panels/subscription-panel.ts +1 -1
- package/src/panels/worktree-panel.ts +20 -19
- package/src/renderer/qr-renderer.ts +117 -0
- package/src/renderer/settings-modal-helpers.ts +122 -0
- package/src/renderer/settings-modal.ts +147 -111
- package/src/runtime/bootstrap-command-context.ts +1 -1
- package/src/runtime/bootstrap-command-parts.ts +31 -15
- package/src/runtime/bootstrap.ts +6 -1
- package/src/runtime/diagnostics/panels/index.ts +5 -5
- package/src/runtime/services.ts +1 -1
- package/src/runtime/store/domains/domain-read-matrix.ts +0 -2
- package/src/runtime/ui-events.ts +1 -46
- package/src/runtime/ui-read-model-helpers.ts +1 -32
- package/src/runtime/ui-read-models-observability-maintenance.ts +1 -81
- package/src/runtime/ui-read-models-observability-options.ts +1 -5
- package/src/runtime/ui-read-models-observability-remote.ts +1 -73
- package/src/runtime/ui-read-models-observability-security.ts +1 -172
- package/src/runtime/ui-read-models-observability-system.ts +1 -217
- package/src/runtime/ui-read-models-observability.ts +1 -59
- package/src/runtime/ui-service-queries.ts +1 -114
- package/src/version.ts +1 -1
- package/src/config/service-registry.ts +0 -1
- package/src/config/subscription-providers.ts +0 -1
- package/src/runtime/diagnostics/actions.ts +0 -776
- package/src/runtime/diagnostics/index.ts +0 -99
- package/src/runtime/diagnostics/panels/agents.ts +0 -252
- package/src/runtime/diagnostics/panels/events.ts +0 -188
- package/src/runtime/diagnostics/panels/health.ts +0 -242
- package/src/runtime/diagnostics/panels/tasks.ts +0 -251
- package/src/runtime/diagnostics/panels/tool-calls.ts +0 -267
- package/src/runtime/diagnostics/provider.ts +0 -262
- package/src/runtime/store/domains/conversation.ts +0 -1
- package/src/runtime/store/domains/permissions.ts +0 -1
- package/src/runtime/store/helpers/reducers/conversation.ts +0 -1
- package/src/runtime/store/helpers/reducers/lifecycle.ts +0 -1
- package/src/runtime/store/helpers/reducers/shared.ts +0 -60
- package/src/runtime/store/helpers/reducers/sync.ts +0 -555
- 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 {
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
218
|
+
scrollOffset: this.scrollStart,
|
|
221
219
|
minRows: 8,
|
|
222
220
|
},
|
|
223
221
|
});
|
|
224
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
451
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredEntries.length - 1));
|
|
453
452
|
}
|
|
454
453
|
|
|
455
454
|
// ── Private: rendering helpers ─────────────────────────────────────────────
|
|
@@ -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 '
|
|
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 '
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
182
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|