@pellux/goodvibes-tui 1.1.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -21
- package/README.md +28 -18
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +2 -2
- package/src/core/alert-gating.ts +67 -0
- package/src/core/approval-alert.ts +72 -0
- package/src/core/budget-breach-notifier.ts +106 -0
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +90 -14
- package/src/core/conversation.ts +18 -0
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/long-task-notifier.ts +33 -6
- package/src/core/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +104 -51
- package/src/core/turn-cancellation.ts +25 -0
- package/src/core/turn-event-wiring.ts +72 -3
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/export/cost-utils.ts +36 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +84 -5
- package/src/input/commands/checkpoint-runtime.ts +280 -0
- package/src/input/commands/codebase-runtime.ts +232 -0
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +10 -2
- package/src/input/commands/image-runtime.ts +112 -0
- package/src/input/commands/intelligence-runtime.ts +11 -1
- package/src/input/commands/local-auth-runtime.ts +4 -1
- package/src/input/commands/local-runtime.ts +9 -3
- package/src/input/commands/marketplace-runtime.ts +2 -2
- package/src/input/commands/memory.ts +73 -35
- package/src/input/commands/operator-panel-runtime.ts +71 -31
- package/src/input/commands/planning-runtime.ts +116 -4
- package/src/input/commands/platform-sandbox-runtime.ts +1 -6
- package/src/input/commands/plugin-runtime.ts +2 -2
- package/src/input/commands/policy-dispatch.ts +21 -0
- package/src/input/commands/provider-accounts-runtime.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +26 -6
- package/src/input/commands/recall-review.ts +35 -0
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/remote-runtime.ts +3 -3
- package/src/input/commands/runtime-services.ts +54 -13
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/session-workflow.ts +16 -1
- package/src/input/commands/settings-sync-runtime.ts +16 -4
- package/src/input/commands/shell-core.ts +25 -8
- package/src/input/commands/skills-runtime.ts +2 -8
- package/src/input/commands/subscription-runtime.ts +2 -2
- package/src/input/commands/test-runtime.ts +277 -0
- package/src/input/commands/websearch-runtime.ts +101 -0
- package/src/input/commands/work-plan-runtime.ts +36 -10
- package/src/input/commands/workstream-runtime.ts +488 -0
- package/src/input/commands.ts +12 -0
- package/src/input/config-modal-types.ts +175 -0
- package/src/input/config-modal.ts +592 -0
- package/src/input/feed-context-factory.ts +10 -8
- package/src/input/handler-command-route.ts +37 -20
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +74 -114
- package/src/input/handler-feed.ts +79 -27
- package/src/input/handler-interactions.ts +1 -3
- package/src/input/handler-modal-routes.ts +136 -6
- package/src/input/handler-modal-stack.ts +14 -7
- package/src/input/handler-modal-token-routes.ts +16 -49
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +26 -102
- package/src/input/handler-shortcuts.ts +74 -14
- package/src/input/handler-types.ts +2 -6
- package/src/input/handler-ui-state.ts +10 -22
- package/src/input/handler.ts +12 -29
- package/src/input/keybindings.ts +23 -8
- package/src/input/model-picker-types.ts +24 -6
- package/src/input/model-picker.ts +77 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-integration-actions.ts +9 -170
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +146 -2
- package/src/input/settings-modal-mutations.ts +6 -4
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +49 -51
- package/src/panels/agent-inspector-shared.ts +2 -1
- package/src/panels/base-panel.ts +22 -1
- package/src/panels/builtin/agent.ts +21 -107
- package/src/panels/builtin/development.ts +15 -61
- package/src/panels/builtin/knowledge.ts +8 -32
- package/src/panels/builtin/operations.ts +84 -428
- package/src/panels/builtin/session.ts +21 -112
- package/src/panels/builtin/shared.ts +18 -46
- package/src/panels/builtin-modals.ts +218 -0
- package/src/panels/builtin-panels.ts +5 -0
- package/src/panels/cost-tracker-panel.ts +36 -10
- package/src/panels/diff-panel.ts +12 -43
- package/src/panels/eval-registry.ts +60 -0
- package/src/panels/fleet-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +758 -0
- package/src/panels/fleet-read-model.ts +520 -0
- package/src/panels/fleet-steer.ts +125 -0
- package/src/panels/fleet-stop.ts +153 -0
- package/src/panels/fleet-tabs.ts +230 -0
- package/src/panels/fleet-transcript.ts +356 -0
- package/src/panels/index.ts +7 -31
- package/src/panels/modals/hooks-modal.ts +187 -0
- package/src/panels/modals/keybindings-modal.ts +166 -0
- package/src/panels/modals/knowledge-modal.ts +158 -0
- package/src/panels/modals/local-auth-modal.ts +132 -0
- package/src/panels/modals/marketplace-modal.ts +218 -0
- package/src/panels/modals/memory-modal.ts +180 -0
- package/src/panels/modals/modal-surface-helpers.ts +54 -0
- package/src/panels/modals/modal-theme.ts +42 -0
- package/src/panels/modals/pairing-modal.ts +163 -0
- package/src/panels/modals/planning-modal.ts +304 -0
- package/src/panels/modals/plugins-modal.ts +174 -0
- package/src/panels/modals/policy-modal.ts +256 -0
- package/src/panels/modals/provider-health-modal.ts +136 -0
- package/src/panels/modals/remote-modal.ts +115 -0
- package/src/panels/modals/sandbox-modal.ts +150 -0
- package/src/panels/modals/security-modal.ts +217 -0
- package/src/panels/modals/services-modal.ts +179 -0
- package/src/panels/modals/settings-sync-modal.ts +142 -0
- package/src/panels/modals/skills-modal.ts +189 -0
- package/src/panels/modals/subscription-modal.ts +172 -0
- package/src/panels/modals/work-plan-modal.ts +177 -0
- package/src/panels/panel-confirm-overlay.ts +72 -0
- package/src/panels/panel-manager.ts +123 -9
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -11
- package/src/panels/skills-panel.ts +7 -51
- package/src/panels/types.ts +37 -0
- package/src/permissions/hunk-selection.ts +179 -0
- package/src/permissions/prompt.ts +216 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compaction-history-modal.ts +19 -3
- package/src/renderer/compaction-preview.ts +13 -2
- package/src/renderer/compaction-quality.ts +100 -0
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +95 -0
- package/src/renderer/conversation-overlays.ts +10 -17
- package/src/renderer/fleet-tab-strip.ts +56 -0
- package/src/renderer/footer-tips.ts +15 -3
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +43 -1
- package/src/renderer/help-overlay.ts +2 -2
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +62 -4
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/panel-workspace-bar.ts +8 -2
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +133 -0
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +27 -1
- package/src/runtime/bootstrap-command-parts.ts +41 -10
- package/src/runtime/bootstrap-core.ts +47 -15
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +48 -17
- package/src/runtime/bootstrap.ts +129 -22
- package/src/runtime/code-index-services.ts +135 -0
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +26 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +59 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +98 -46
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +27 -3
- package/src/runtime/workstream-services.ts +327 -0
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +68 -4
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +171 -26
- package/src/utils/format-duration.ts +8 -18
- package/src/utils/splash-lines.ts +1 -1
- package/src/utils/terminal-width.ts +52 -0
- package/src/version.ts +1 -1
- package/src/panels/agent-inspector-panel.ts +0 -786
- package/src/panels/approval-panel.ts +0 -252
- package/src/panels/automation-control-panel.ts +0 -479
- package/src/panels/cockpit-panel.ts +0 -481
- package/src/panels/cockpit-read-model.ts +0 -238
- package/src/panels/communication-panel.ts +0 -256
- package/src/panels/control-plane-panel.ts +0 -470
- package/src/panels/debug-panel.ts +0 -615
- package/src/panels/docs-panel.ts +0 -384
- package/src/panels/eval-panel.ts +0 -627
- package/src/panels/file-explorer-panel.ts +0 -673
- package/src/panels/file-preview-panel.ts +0 -517
- package/src/panels/hooks-panel.ts +0 -401
- package/src/panels/incident-review-panel.ts +0 -406
- package/src/panels/intelligence-panel.ts +0 -383
- package/src/panels/knowledge-graph-panel.ts +0 -515
- package/src/panels/marketplace-panel.ts +0 -399
- package/src/panels/memory-panel.ts +0 -558
- package/src/panels/ops-control-panel.ts +0 -329
- package/src/panels/ops-strategy-panel.ts +0 -321
- package/src/panels/orchestration-panel.ts +0 -465
- package/src/panels/panel-list-panel.ts +0 -566
- package/src/panels/plan-dashboard-panel.ts +0 -707
- package/src/panels/policy-panel.ts +0 -517
- package/src/panels/project-planning-panel.ts +0 -731
- package/src/panels/provider-health-panel.ts +0 -678
- package/src/panels/provider-health-tracker.ts +0 -310
- package/src/panels/provider-health-views.ts +0 -567
- package/src/panels/qr-panel.ts +0 -280
- package/src/panels/remote-panel.ts +0 -534
- package/src/panels/routes-panel.ts +0 -241
- package/src/panels/sandbox-panel.ts +0 -456
- package/src/panels/security-panel.ts +0 -447
- package/src/panels/services-panel.ts +0 -329
- package/src/panels/session-browser-panel.ts +0 -496
- package/src/panels/settings-sync-panel.ts +0 -398
- package/src/panels/subscription-panel.ts +0 -342
- package/src/panels/symbol-outline-panel.ts +0 -619
- package/src/panels/system-messages-panel.ts +0 -364
- package/src/panels/tasks-panel.ts +0 -608
- package/src/panels/thinking-panel.ts +0 -333
- package/src/panels/tool-inspector-panel.ts +0 -537
- package/src/panels/work-plan-panel.ts +0 -540
- package/src/panels/worktree-panel.ts +0 -360
- package/src/panels/wrfc-panel.ts +0 -790
- package/src/renderer/agent-detail-modal.ts +0 -466
- package/src/renderer/live-tail-modal.ts +0 -156
- package/src/renderer/process-modal.ts +0 -671
- package/src/renderer/qr-renderer.ts +0 -120
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConfigModalActionContext,
|
|
3
|
+
ConfigModalRow,
|
|
4
|
+
ConfigModalSurface,
|
|
5
|
+
ConfigModalView,
|
|
6
|
+
} from '../../input/config-modal-types.ts';
|
|
7
|
+
import { MODAL_TONES } from './modal-theme.ts';
|
|
8
|
+
import { infoRow } from './modal-surface-helpers.ts';
|
|
9
|
+
import {
|
|
10
|
+
loadEcosystemCatalog,
|
|
11
|
+
listInstalledEcosystemEntries,
|
|
12
|
+
reviewEcosystemCatalogEntry,
|
|
13
|
+
type EcosystemCatalogEntry,
|
|
14
|
+
type EcosystemCatalogPathOptions,
|
|
15
|
+
type EcosystemEntryKind,
|
|
16
|
+
} from '@/runtime/index.ts';
|
|
17
|
+
import type { UiMarketplaceSnapshot, UiReadModel } from '../../runtime/ui-read-models.ts';
|
|
18
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
19
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
/** Live deps the marketplace modal captures (mirrors the panel factory). */
|
|
24
|
+
export interface MarketplaceModalDeps {
|
|
25
|
+
readonly readModel?: UiReadModel<UiMarketplaceSnapshot>;
|
|
26
|
+
readonly ecosystemPaths?: EcosystemCatalogPathOptions;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type MarketplaceReview = ReturnType<typeof reviewEcosystemCatalogEntry>;
|
|
30
|
+
|
|
31
|
+
interface MarketplaceRow {
|
|
32
|
+
readonly kind: EcosystemEntryKind;
|
|
33
|
+
readonly entry: EcosystemCatalogEntry;
|
|
34
|
+
readonly installed: boolean;
|
|
35
|
+
readonly review: MarketplaceReview;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const KINDS: readonly EcosystemEntryKind[] = ['plugin', 'skill', 'hook-pack', 'policy-pack'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Marketplace → config-modal surface (W6.1 group-B port). Local-first
|
|
42
|
+
* publish/import catalog (NOT a remote store) with provenance, compatibility,
|
|
43
|
+
* and install posture. Disk catalog loads happen in refresh() (never in
|
|
44
|
+
* buildView), mirroring the panel's explicit render()/refresh() split.
|
|
45
|
+
* Install/uninstall route to the `/marketplace` command path (charter: no
|
|
46
|
+
* destructive confirm folded into a modal).
|
|
47
|
+
*
|
|
48
|
+
* B30 (honest empty-state): the panel's original "No curated marketplace
|
|
49
|
+
* entries found yet." copy implied a curated remote catalog. It isn't one —
|
|
50
|
+
* loadEcosystemCatalog only ever reads .goodvibes/ecosystem/<kind>s.json under
|
|
51
|
+
* the project/home, populated solely by `/marketplace publish` and
|
|
52
|
+
* `/marketplace bundle import`. The empty-state copy below (locked byte-for-byte
|
|
53
|
+
* in the golden) names that reality. Selection-blind port: the panel's
|
|
54
|
+
* selected-entry compatibility/risk/state detail is folded into each row label.
|
|
55
|
+
*/
|
|
56
|
+
class MarketplaceModalSurface implements ConfigModalSurface {
|
|
57
|
+
readonly name = 'marketplace-modal';
|
|
58
|
+
readonly title = 'Marketplace';
|
|
59
|
+
private rows: MarketplaceRow[] = [];
|
|
60
|
+
private loadError: string | null = null;
|
|
61
|
+
private requestRender: () => void = () => {};
|
|
62
|
+
private unsub: (() => void) | null = null;
|
|
63
|
+
|
|
64
|
+
constructor(private readonly deps: MarketplaceModalDeps) {}
|
|
65
|
+
|
|
66
|
+
readonly actions = [
|
|
67
|
+
{ key: 'i', id: 'install', label: 'install', enabledFor: (row: ConfigModalRow | null) => this.canInstall(row) },
|
|
68
|
+
{ key: 'u', id: 'uninstall', label: 'uninstall', enabledFor: (row: ConfigModalRow | null) => this.canUninstall(row) },
|
|
69
|
+
{ key: 'r', id: 'refresh', label: 'refresh' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
onOpen(requestRender: () => void): void {
|
|
73
|
+
this.requestRender = requestRender;
|
|
74
|
+
this.refresh();
|
|
75
|
+
if (this.deps.readModel && !this.unsub) this.unsub = this.deps.readModel.subscribe(() => this.requestRender());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
onClose(): void {
|
|
79
|
+
this.unsub?.();
|
|
80
|
+
this.unsub = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private refresh(): void {
|
|
84
|
+
const paths = this.deps.ecosystemPaths;
|
|
85
|
+
if (!paths) { this.rows = []; this.loadError = null; return; }
|
|
86
|
+
try {
|
|
87
|
+
const built: MarketplaceRow[] = [];
|
|
88
|
+
for (const kind of KINDS) {
|
|
89
|
+
const installed = new Set(listInstalledEcosystemEntries(kind, paths).map((receipt) => receipt.entry.id));
|
|
90
|
+
for (const entry of loadEcosystemCatalog(kind, paths)) {
|
|
91
|
+
built.push({ kind, entry, installed: installed.has(entry.id), review: reviewEcosystemCatalogEntry(entry, paths) });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this.rows = built.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
|
|
95
|
+
this.loadError = null;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
this.rows = [];
|
|
98
|
+
this.loadError = `Catalog load failed: ${summarizeError(e)}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private entryFor(row: ConfigModalRow | null): MarketplaceRow | undefined {
|
|
103
|
+
if (!row) return undefined;
|
|
104
|
+
return this.rows.find((r) => `${r.kind}:${r.entry.id}` === row.id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private canInstall(row: ConfigModalRow | null): boolean {
|
|
108
|
+
const entry = this.entryFor(row);
|
|
109
|
+
return Boolean(entry && !entry.installed);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private canUninstall(row: ConfigModalRow | null): boolean {
|
|
113
|
+
const entry = this.entryFor(row);
|
|
114
|
+
return Boolean(entry && entry.installed);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
buildView(): ConfigModalView {
|
|
118
|
+
const snapshot = this.deps.readModel?.getSnapshot();
|
|
119
|
+
const rows: ConfigModalRow[] = [];
|
|
120
|
+
|
|
121
|
+
// Honest empty / degraded state (B30). loadError → degraded banner.
|
|
122
|
+
if (this.loadError) {
|
|
123
|
+
return { title: 'Marketplace', degraded: this.loadError, tabs: [{ id: 'catalog', label: 'Catalog', rows: [] }] };
|
|
124
|
+
}
|
|
125
|
+
if (this.rows.length === 0) {
|
|
126
|
+
if (!this.deps.ecosystemPaths) {
|
|
127
|
+
rows.push(infoRow('empty:unwired', 'Marketplace catalog roots aren’t wired into this session, so there’s nothing to read yet.'));
|
|
128
|
+
} else {
|
|
129
|
+
rows.push(infoRow('empty:0', 'This is your local plugin, skill, hook-pack, and policy-pack catalog — not a remote store.'));
|
|
130
|
+
rows.push(infoRow('empty:1', 'It’s empty because nothing has been published or imported into this workspace yet. Entries appear here once you publish a local component or import a bundle.'));
|
|
131
|
+
}
|
|
132
|
+
rows.push(infoRow('empty:title', 'Populate it', { bold: true }));
|
|
133
|
+
rows.push(infoRow('empty:publish', '/marketplace publish <kind> <path> — publish local plugins/skills into the catalog', { dim: true }));
|
|
134
|
+
rows.push(infoRow('empty:import', '/marketplace bundle import <path> — import a catalog bundle from disk', { dim: true }));
|
|
135
|
+
rows.push(infoRow('empty:review', '/marketplace catalog review — inspect the current local catalog posture', { dim: true }));
|
|
136
|
+
return {
|
|
137
|
+
title: 'Marketplace',
|
|
138
|
+
tabs: [{ id: 'catalog', label: 'Catalog', rows, emptyText: '' }],
|
|
139
|
+
hints: ['local publish/import catalog'],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Posture summary header.
|
|
144
|
+
const installedCount = this.rows.filter((r) => r.installed).length;
|
|
145
|
+
const count = (k: EcosystemEntryKind): number => this.rows.filter((r) => r.kind === k).length;
|
|
146
|
+
const header = [
|
|
147
|
+
`catalog ${this.rows.length} installed ${installedCount} plugins ${count('plugin')} skills ${count('skill')} hooks ${count('hook-pack')} policies ${count('policy-pack')}`,
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
const startupIssues = snapshot?.startupIssues ?? [];
|
|
151
|
+
for (const [i, issue] of startupIssues.slice(0, 3).entries()) {
|
|
152
|
+
rows.push(infoRow(`issue:${i}`, `⚠ ${issue}`, { fg: MODAL_TONES.warn }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Catalog list — selection-blind: fold provenance/compat/risk/state into the label.
|
|
156
|
+
for (const row of this.rows) {
|
|
157
|
+
const review = row.review;
|
|
158
|
+
const provenance = row.entry.provenance ?? 'local';
|
|
159
|
+
rows.push({
|
|
160
|
+
id: `${row.kind}:${row.entry.id}`,
|
|
161
|
+
label: `${row.kind.padEnd(11)} ${row.entry.name.padEnd(22)} ${provenance.padEnd(14)} ${row.installed ? 'INSTALLED' : 'local '} ${row.entry.version ?? 'n/a'} · compat ${review.compatibility.status} risk ${review.riskLevel}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Recommendations (displayed with their command; digit-jump dropped).
|
|
166
|
+
const recommendations = snapshot?.recommendations ?? [];
|
|
167
|
+
if (recommendations.length > 0) {
|
|
168
|
+
rows.push(infoRow('rec:title', 'Recommended', { bold: true }));
|
|
169
|
+
for (const [i, rec] of recommendations.slice(0, 3).entries()) {
|
|
170
|
+
rows.push(infoRow(`rec:${i}`, `${rec.title} — ${rec.command}`, { dim: true }));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
title: 'Marketplace',
|
|
176
|
+
tabs: [{
|
|
177
|
+
id: 'catalog',
|
|
178
|
+
label: 'Catalog',
|
|
179
|
+
header,
|
|
180
|
+
rows,
|
|
181
|
+
hints: ['local publish/import catalog'],
|
|
182
|
+
}],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
onAction(id: string, ctx: ConfigModalActionContext): void {
|
|
187
|
+
if (id === 'refresh') { this.refresh(); ctx.setStatus('Refreshing catalog…'); return; }
|
|
188
|
+
const entry = this.entryFor(ctx.row);
|
|
189
|
+
if (!entry) return;
|
|
190
|
+
if (id === 'install' && !entry.installed) {
|
|
191
|
+
void ctx.executeCommand?.('marketplace', ['install', entry.kind, entry.entry.id]);
|
|
192
|
+
ctx.setStatus(`Dispatched /marketplace install ${entry.kind} ${entry.entry.id} (see transcript).`);
|
|
193
|
+
} else if (id === 'uninstall' && entry.installed) {
|
|
194
|
+
void ctx.executeCommand?.('marketplace', ['uninstall', entry.kind, entry.entry.id]);
|
|
195
|
+
ctx.setStatus(`Dispatched /marketplace uninstall ${entry.kind} ${entry.entry.id} (see transcript).`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createMarketplaceModalSurface(deps: MarketplaceModalDeps): ConfigModalSurface {
|
|
201
|
+
return new MarketplaceModalSurface(deps);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Deterministic golden fixture: catalog roots wired at a fresh tmp path that is
|
|
206
|
+
* removed immediately (loadEcosystemCatalog/listInstalledEcosystemEntries guard
|
|
207
|
+
* missing paths with existsSync → [], so refresh() finds nothing and renders the
|
|
208
|
+
* B30 honest empty-state copy). The random tmp path never appears in the
|
|
209
|
+
* rendered lines (the empty-state copy is static), so the golden is byte-stable.
|
|
210
|
+
*/
|
|
211
|
+
export function marketplaceModalGoldenSurface(): ConfigModalSurface {
|
|
212
|
+
const root = mkdtempSync(join(tmpdir(), 'gv-marketplace-golden-'));
|
|
213
|
+
const surface = createMarketplaceModalSurface({
|
|
214
|
+
ecosystemPaths: { cwd: root, homeDir: root, projectCatalogRoot: join(root, 'ecosystem') },
|
|
215
|
+
});
|
|
216
|
+
rmSync(root, { recursive: true, force: true });
|
|
217
|
+
return surface;
|
|
218
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { infoRow } from './modal-surface-helpers.ts';
|
|
2
|
+
import type {
|
|
3
|
+
ConfigModalActionContext,
|
|
4
|
+
ConfigModalRow,
|
|
5
|
+
ConfigModalSurface,
|
|
6
|
+
ConfigModalTab,
|
|
7
|
+
ConfigModalView,
|
|
8
|
+
} from '../../input/config-modal-types.ts';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Memory → config-modal surface (W6.1 group-B port). Two tabs: 'All Records'
|
|
12
|
+
// (the full record list) and 'Review Queue' (the operator review queue) — the
|
|
13
|
+
// same split the panel toggled with Tab, now real host tabs. Review-state
|
|
14
|
+
// mutations (reviewed/stale/contradicted/fresh) and record delete route to the
|
|
15
|
+
// existing `/recall review` / `/recall remove` command path (charter: no
|
|
16
|
+
// destructive mutation direct-called from a modal). Selection-blind port: the
|
|
17
|
+
// panel's selected-record scope/class/tags/provenance detail is folded into
|
|
18
|
+
// each row label. `memoryRegistry` absent → the retired MemoryPanel's
|
|
19
|
+
// "not configured" copy renders as an honest degraded state.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Minimal read shape of a `MemoryRecord` this modal renders. */
|
|
23
|
+
interface MemoryRecordLike {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly scope: string;
|
|
26
|
+
readonly cls: string;
|
|
27
|
+
readonly summary: string;
|
|
28
|
+
readonly detail?: string | undefined;
|
|
29
|
+
readonly tags: readonly string[];
|
|
30
|
+
readonly reviewState: string;
|
|
31
|
+
readonly confidence: number;
|
|
32
|
+
readonly staleReason?: string | undefined;
|
|
33
|
+
readonly reviewedAt?: number | undefined;
|
|
34
|
+
readonly reviewedBy?: string | undefined;
|
|
35
|
+
readonly createdAt: number;
|
|
36
|
+
readonly provenance: readonly { readonly kind: string; readonly ref: string }[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MemoryModalDeps {
|
|
40
|
+
readonly memoryRegistry?: {
|
|
41
|
+
search(filter?: { limit?: number }): readonly MemoryRecordLike[];
|
|
42
|
+
reviewQueue(limit?: number): readonly MemoryRecordLike[];
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Mirrors registerKnowledgePanels's withUnconfiguredFallback copy for the
|
|
47
|
+
// retired MemoryPanel (src/panels/builtin/knowledge.ts) verbatim.
|
|
48
|
+
const NOT_CONFIGURED_TITLE = 'Memory registry not configured for this session.';
|
|
49
|
+
const NOT_CONFIGURED_BODY = 'This runtime was not wired with a project memory registry at bootstrap, so no memory data is available.';
|
|
50
|
+
|
|
51
|
+
function fmtTime(ts: number): string {
|
|
52
|
+
return new Date(ts).toISOString().slice(0, 16).replace('T', ' ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class MemoryModalSurface implements ConfigModalSurface {
|
|
56
|
+
readonly name = 'memory-modal';
|
|
57
|
+
readonly title = 'Memory';
|
|
58
|
+
private allRecords: MemoryRecordLike[] = [];
|
|
59
|
+
private reviewRecords: MemoryRecordLike[] = [];
|
|
60
|
+
|
|
61
|
+
constructor(private readonly deps: MemoryModalDeps) {}
|
|
62
|
+
|
|
63
|
+
private readonly reviewGate = (row: ConfigModalRow | null, tabId: string): boolean => tabId === 'review' && row !== null;
|
|
64
|
+
|
|
65
|
+
readonly actions = [
|
|
66
|
+
{ key: 'enter', id: 'markReviewed', label: 'reviewed', enabledFor: this.reviewGate },
|
|
67
|
+
{ key: 's', id: 'markStale', label: 'stale', enabledFor: this.reviewGate },
|
|
68
|
+
{ key: 'c', id: 'markContradicted', label: 'contradicted', enabledFor: this.reviewGate },
|
|
69
|
+
{ key: 'f', id: 'markFresh', label: 'fresh', enabledFor: this.reviewGate },
|
|
70
|
+
{ key: 'd', id: 'remove', label: 'delete', enabledFor: (row: ConfigModalRow | null) => row !== null },
|
|
71
|
+
{ key: 'r', id: 'refresh', label: 'refresh' },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
onOpen(): void { this.refresh(); }
|
|
75
|
+
|
|
76
|
+
private refresh(): void {
|
|
77
|
+
if (!this.deps.memoryRegistry) { this.allRecords = []; this.reviewRecords = []; return; }
|
|
78
|
+
this.allRecords = [...this.deps.memoryRegistry.search({ limit: 100 })];
|
|
79
|
+
this.reviewRecords = [...this.deps.memoryRegistry.reviewQueue(24)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private recordFrom(id: string): MemoryRecordLike | undefined {
|
|
83
|
+
return this.allRecords.find((r) => r.id === id) ?? this.reviewRecords.find((r) => r.id === id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private allTab(): ConfigModalTab {
|
|
87
|
+
const rows: ConfigModalRow[] = this.allRecords.map((record) => ({
|
|
88
|
+
id: record.id,
|
|
89
|
+
label: `[${record.scope.slice(0, 1).toUpperCase()}/${record.cls.slice(0, 3).toUpperCase()}] ${record.id.slice(-8)} ${fmtTime(record.createdAt)} ${record.summary}${record.tags.length > 0 ? ` #${record.tags.join(' #')}` : ''}`,
|
|
90
|
+
}));
|
|
91
|
+
return {
|
|
92
|
+
id: 'all',
|
|
93
|
+
label: 'All Records',
|
|
94
|
+
header: [`records ${this.allRecords.length} review queue ${this.reviewRecords.length}`],
|
|
95
|
+
rows,
|
|
96
|
+
emptyText: 'No memory records.',
|
|
97
|
+
hints: ['d delete'],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private reviewTab(): ConfigModalTab {
|
|
102
|
+
const rows: ConfigModalRow[] = this.reviewRecords.map((record) => ({
|
|
103
|
+
id: record.id,
|
|
104
|
+
label: `${record.reviewState.padEnd(13)} ${String(record.confidence).padStart(3)}% ${record.summary}${record.staleReason ? ` (stale: ${record.staleReason})` : ''}`,
|
|
105
|
+
}));
|
|
106
|
+
return {
|
|
107
|
+
id: 'review',
|
|
108
|
+
label: 'Review Queue',
|
|
109
|
+
header: [`records ${this.allRecords.length} review queue ${this.reviewRecords.length}`],
|
|
110
|
+
rows,
|
|
111
|
+
emptyText: 'No records in the review queue.',
|
|
112
|
+
hints: ['enter reviewed', 's stale', 'c contradicted', 'f fresh', 'd delete'],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
buildView(): ConfigModalView {
|
|
117
|
+
if (!this.deps.memoryRegistry) {
|
|
118
|
+
return {
|
|
119
|
+
title: 'Memory',
|
|
120
|
+
degraded: `${NOT_CONFIGURED_TITLE} ${NOT_CONFIGURED_BODY}`,
|
|
121
|
+
tabs: [{ id: 'all', label: 'All Records', rows: [] }],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { title: 'Memory', tabs: [this.allTab(), this.reviewTab()] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
onAction(id: string, ctx: ConfigModalActionContext): void {
|
|
128
|
+
if (id === 'refresh') { this.refresh(); ctx.setStatus('Reloaded memory records.'); return; }
|
|
129
|
+
const record = ctx.row ? this.recordFrom(ctx.row.id) : undefined;
|
|
130
|
+
if (!record) return;
|
|
131
|
+
const review = (state: string, confidence: number, reason?: string): void => {
|
|
132
|
+
const args = ['review', record.id, state, '--confidence', String(confidence), '--by', 'operator'];
|
|
133
|
+
if (reason) args.push('--reason', reason);
|
|
134
|
+
void ctx.executeCommand?.('recall', args);
|
|
135
|
+
ctx.setStatus(`Dispatched /recall review ${record.id} ${state}.`);
|
|
136
|
+
};
|
|
137
|
+
switch (id) {
|
|
138
|
+
case 'markReviewed': review('reviewed', Math.max(record.confidence, 85)); break;
|
|
139
|
+
case 'markStale': review('stale', Math.min(record.confidence, 40), 'marked stale from the memory panel'); break;
|
|
140
|
+
case 'markContradicted': review('contradicted', 0, 'marked contradicted from the memory panel'); break;
|
|
141
|
+
case 'markFresh': review('fresh', Math.max(record.confidence, 60)); break;
|
|
142
|
+
case 'remove': void ctx.executeCommand?.('recall', ['remove', record.id]); ctx.setStatus(`Dispatched /recall remove ${record.id}.`); break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function createMemoryModalSurface(deps: MemoryModalDeps): ConfigModalSurface {
|
|
148
|
+
return new MemoryModalSurface(deps);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Deterministic golden fixture: fixed memory records with frozen createdAt
|
|
153
|
+
* timestamps — no live registry, no wall-clock, no random ids.
|
|
154
|
+
*/
|
|
155
|
+
export function memoryModalGoldenSurface(): ConfigModalSurface {
|
|
156
|
+
const FIXED_CREATED_AT = 1735689600000; // 2025-01-01T00:00:00.000Z
|
|
157
|
+
const records: readonly MemoryRecordLike[] = [
|
|
158
|
+
{
|
|
159
|
+
id: 'mem-0000001a', scope: 'project', cls: 'decision',
|
|
160
|
+
summary: 'Wave-6 batches panel retirements behind modal builders.',
|
|
161
|
+
detail: 'Applies to KNOWLEDGE, MEMORY, WORK-PLAN in WO-B.',
|
|
162
|
+
tags: ['wave-6', 'modals'], reviewState: 'reviewed', confidence: 90,
|
|
163
|
+
reviewedAt: FIXED_CREATED_AT + 3600000, reviewedBy: 'operator', createdAt: FIXED_CREATED_AT,
|
|
164
|
+
provenance: [{ kind: 'session', ref: 'session-fixed-1' }],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'mem-0000002b', scope: 'session', cls: 'risk',
|
|
168
|
+
summary: 'Modal review actions must not call mutation APIs directly.',
|
|
169
|
+
tags: ['charter'], reviewState: 'stale', confidence: 35,
|
|
170
|
+
staleReason: 'needs re-verification against the charter doc',
|
|
171
|
+
createdAt: FIXED_CREATED_AT + 86400000, provenance: [],
|
|
172
|
+
},
|
|
173
|
+
];
|
|
174
|
+
return createMemoryModalSurface({
|
|
175
|
+
memoryRegistry: {
|
|
176
|
+
search: () => records,
|
|
177
|
+
reviewQueue: () => records.filter((record) => record.reviewState !== 'reviewed'),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { DEFAULT_PANEL_PALETTE } from '../polish.ts';
|
|
2
|
+
import type { ModalSectionStyle } from '../../renderer/modal-factory.ts';
|
|
3
|
+
import type { ConfigModalRow } from '../../input/config-modal-types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared formatting helpers for W6.1 config-modal surfaces. Row styling reuses
|
|
7
|
+
* DEFAULT_PANEL_PALETTE (the retired panels' palette) so a migrated modal reads
|
|
8
|
+
* as the same surface it replaced — the goldens stay panel-consistent.
|
|
9
|
+
*/
|
|
10
|
+
export const PALETTE = DEFAULT_PANEL_PALETTE;
|
|
11
|
+
|
|
12
|
+
export type Tone = 'good' | 'bad' | 'warn' | 'info' | 'dim' | 'value';
|
|
13
|
+
|
|
14
|
+
/** Map a semantic tone to a ModalSectionStyle (foreground only). */
|
|
15
|
+
export function toneStyle(tone: Tone): ModalSectionStyle {
|
|
16
|
+
return { fg: PALETTE[tone] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A single-glyph status dot matching the provider-console convention
|
|
20
|
+
* (● online · ◐ warming · ✕ error · ○ idle). */
|
|
21
|
+
export function statusGlyph(tone: Tone): string {
|
|
22
|
+
switch (tone) {
|
|
23
|
+
case 'good': return '●';
|
|
24
|
+
case 'warn': return '◐';
|
|
25
|
+
case 'bad': return '✕';
|
|
26
|
+
default: return '○';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fixed-width left-justify by display columns (plain padEnd is fine for the
|
|
31
|
+
* ASCII/latin labels these surfaces use; wide glyphs are the status dot only,
|
|
32
|
+
* which we place at a fixed column). */
|
|
33
|
+
export function pad(text: string, width: number): string {
|
|
34
|
+
return text.length >= width ? text : text + ' '.repeat(width - text.length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build a `label value` posture cell for the header line. */
|
|
38
|
+
export function kv(label: string, value: string | number): string {
|
|
39
|
+
return `${label} ${value}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Join posture cells into one header line with a consistent separator. */
|
|
43
|
+
export function postureLine(cells: string[]): string {
|
|
44
|
+
return cells.join(' ');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A stable non-selectable informational row (section title, empty-state copy,
|
|
49
|
+
* or a content line the surface renders but the cursor never lands on). `id`
|
|
50
|
+
* must be unique within its tab so the host's live-value overlay keys off it.
|
|
51
|
+
*/
|
|
52
|
+
export function infoRow(id: string, label: string, style?: ModalSectionStyle): ConfigModalRow {
|
|
53
|
+
return { id, label, selectable: false, ...(style ? { style } : {}) };
|
|
54
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { activeUiTones, registerThemeRefresh } from '../../renderer/theme.ts';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Semantic tone tokens for the W6.1 group-B config modals.
|
|
5
|
+
//
|
|
6
|
+
// Derived entirely from the chrome token set so the builders carry NO raw hex
|
|
7
|
+
// color literals (architecture: no-raw-hex-literal-growth). Rebuilt IN PLACE
|
|
8
|
+
// on theme flips via registerThemeRefresh — the group-B surfaces read
|
|
9
|
+
// MODAL_TONES by reference at render time, so a dark-pinned module const here
|
|
10
|
+
// left them rendering dark accents on light chrome (batch refutation
|
|
11
|
+
// finding 2). Mirrors the polish-core/overlay-box/modal-factory pattern.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
function buildModalTones() {
|
|
15
|
+
const T = activeUiTones();
|
|
16
|
+
return {
|
|
17
|
+
/** Informational / accent (blue). */
|
|
18
|
+
info: T.state.info,
|
|
19
|
+
/** Positive / healthy (green). */
|
|
20
|
+
good: T.state.good,
|
|
21
|
+
/** Caution (amber). */
|
|
22
|
+
warn: T.state.warn,
|
|
23
|
+
/** Error / danger (red). */
|
|
24
|
+
bad: T.state.bad,
|
|
25
|
+
/** Reasoning / secondary accent (purple). */
|
|
26
|
+
reasoning: T.state.reasoning,
|
|
27
|
+
/** Muted secondary text. */
|
|
28
|
+
muted: T.fg.muted,
|
|
29
|
+
/** Dim tertiary text. */
|
|
30
|
+
dim: T.fg.dim,
|
|
31
|
+
/** Primary text. */
|
|
32
|
+
primary: T.fg.primary,
|
|
33
|
+
/** QR module foreground — high-contrast, mode-appropriate. */
|
|
34
|
+
qrDark: T.fg.inverse,
|
|
35
|
+
/** QR field background — high-contrast, mode-appropriate. */
|
|
36
|
+
qrLight: T.fg.primary,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const MODAL_TONES: Readonly<ReturnType<typeof buildModalTones>> = buildModalTones();
|
|
41
|
+
|
|
42
|
+
registerThemeRefresh(() => Object.assign(MODAL_TONES as Record<string, string>, buildModalTones()));
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { MODAL_TONES } from './modal-theme.ts';
|
|
2
|
+
import { infoRow } from './modal-surface-helpers.ts';
|
|
3
|
+
import type {
|
|
4
|
+
ConfigModalActionContext,
|
|
5
|
+
ConfigModalRow,
|
|
6
|
+
ConfigModalSurface,
|
|
7
|
+
ConfigModalView,
|
|
8
|
+
} from '../../input/config-modal-types.ts';
|
|
9
|
+
import { encodeConnectionPayload, generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// QR Code → 'pairing' config-modal surface (W6.1 group-B port). Connection
|
|
13
|
+
// URL/token/username(/password) plus a scannable QR block — read/navigate only.
|
|
14
|
+
// Token regeneration is a destructive mutation (it invalidates any live
|
|
15
|
+
// companion session) that the panel gated behind an in-panel confirm; per
|
|
16
|
+
// charter that confirm is never folded into a modal, so regenerate routes to a
|
|
17
|
+
// command instead. `getConnectionInfo` is a lazy thunk (loaded once on first
|
|
18
|
+
// buildView, cached) so a missing daemon home degrades honestly rather than
|
|
19
|
+
// throwing at modal-registration time.
|
|
20
|
+
//
|
|
21
|
+
// DEBT-3: the regenerate action now dispatches the real `/qrcode regenerate`
|
|
22
|
+
// verb (which rotates the shared operator-token store) and, once it resolves,
|
|
23
|
+
// RE-PULLS the lazy connection-info thunk so the modal shows the rotated token +
|
|
24
|
+
// QR in place without being re-opened. `confirm: true` keeps the two-press
|
|
25
|
+
// guard so a stray keystroke never rotates a live companion's token.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
export interface PairingModalConnectionInfo {
|
|
29
|
+
readonly url: string;
|
|
30
|
+
readonly token: string;
|
|
31
|
+
readonly username: string;
|
|
32
|
+
readonly password?: string;
|
|
33
|
+
readonly version?: string;
|
|
34
|
+
readonly surface?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface PairingModalControlPlaneSnapshot { readonly activeClientIds: readonly string[]; }
|
|
37
|
+
export interface PairingModalReadModel<T> { getSnapshot(): T; }
|
|
38
|
+
|
|
39
|
+
export interface PairingModalDeps {
|
|
40
|
+
/** Lazy connection-info provider — returns null when the daemon/companion
|
|
41
|
+
* token cannot be resolved (honest degraded state instead of a throw). */
|
|
42
|
+
readonly getConnectionInfo: () => PairingModalConnectionInfo | null;
|
|
43
|
+
readonly controlPlaneReadModel?: PairingModalReadModel<PairingModalControlPlaneSnapshot>;
|
|
44
|
+
readonly copyToClipboard?: (text: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fixed-width placeholder — deliberately NOT derived from the real secret's
|
|
48
|
+
// length (mirrors QrPanel.SECRET_MASK), so masking doesn't leak length.
|
|
49
|
+
const SECRET_MASK = '••••••••••••';
|
|
50
|
+
|
|
51
|
+
class PairingModalSurface implements ConfigModalSurface {
|
|
52
|
+
readonly name = 'pairing-modal';
|
|
53
|
+
readonly title = 'Companion Pairing';
|
|
54
|
+
private info: PairingModalConnectionInfo | null | undefined = undefined;
|
|
55
|
+
private revealed = false;
|
|
56
|
+
private requestRender: () => void = () => {};
|
|
57
|
+
|
|
58
|
+
constructor(private readonly deps: PairingModalDeps) {}
|
|
59
|
+
|
|
60
|
+
readonly actions = [
|
|
61
|
+
{ key: 'v', id: 'toggleReveal', label: 'reveal token' },
|
|
62
|
+
{ key: 'c', id: 'copyToken', label: 'copy token', enabledFor: () => Boolean(this.deps.copyToClipboard) },
|
|
63
|
+
{ key: 'r', id: 'regenerate', label: 'regenerate token', confirm: true },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
onOpen(requestRender: () => void): void { this.requestRender = requestRender; }
|
|
67
|
+
|
|
68
|
+
private ensureInfo(): PairingModalConnectionInfo | null {
|
|
69
|
+
if (this.info === undefined) {
|
|
70
|
+
try { this.info = this.deps.getConnectionInfo(); } catch { this.info = null; }
|
|
71
|
+
}
|
|
72
|
+
return this.info;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
buildView(): ConfigModalView {
|
|
76
|
+
const info = this.ensureInfo();
|
|
77
|
+
if (!info) {
|
|
78
|
+
return { title: 'Companion Pairing', degraded: 'Companion pairing is unavailable — the daemon connection info could not be resolved for this session.', tabs: [{ id: 'pairing', label: 'Pairing', rows: [] }] };
|
|
79
|
+
}
|
|
80
|
+
const { url, token, username, password } = info;
|
|
81
|
+
const displayToken = this.revealed ? token : SECRET_MASK;
|
|
82
|
+
const displayPassword = password !== undefined ? (this.revealed ? password : SECRET_MASK) : undefined;
|
|
83
|
+
|
|
84
|
+
const rows: ConfigModalRow[] = [];
|
|
85
|
+
rows.push(infoRow('intro', 'Scan with the GoodVibes companion app to pair this session.', { dim: true }));
|
|
86
|
+
rows.push(infoRow('url', `URL ${url}`));
|
|
87
|
+
rows.push(infoRow('token', `Token ${displayToken}`, { fg: MODAL_TONES.reasoning }));
|
|
88
|
+
rows.push(infoRow('username', `Username ${username}`));
|
|
89
|
+
if (displayPassword !== undefined) rows.push(infoRow('password', `Password ${displayPassword}`));
|
|
90
|
+
if (this.deps.controlPlaneReadModel) {
|
|
91
|
+
const connected = this.deps.controlPlaneReadModel.getSnapshot().activeClientIds.length;
|
|
92
|
+
rows.push(infoRow('companions', `Companions connected: ${connected}`, connected > 0 ? { fg: MODAL_TONES.good } : { dim: true }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ASCII QR block — same payload shape as QrPanel.render(); the multi-line
|
|
96
|
+
// preformatted string is split into one non-selectable row per block row.
|
|
97
|
+
const payload = encodeConnectionPayload({
|
|
98
|
+
url, token, username,
|
|
99
|
+
...(password !== undefined ? { password } : {}),
|
|
100
|
+
version: info.version ?? '0.0.0',
|
|
101
|
+
surface: info.surface ?? 'tui',
|
|
102
|
+
});
|
|
103
|
+
const qr = renderQrToString(generateQrMatrix(payload));
|
|
104
|
+
qr.split('\n').forEach((line, i) => rows.push(infoRow(`qr:${i}`, line, { fg: MODAL_TONES.qrDark, bg: MODAL_TONES.qrLight })));
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
title: 'Companion Pairing',
|
|
108
|
+
tabs: [{ id: 'pairing', label: 'Pairing', rows, hints: [this.revealed ? 'v hide token' : 'v reveal token'] }],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onAction(id: string, ctx: ConfigModalActionContext): void {
|
|
113
|
+
const info = this.ensureInfo();
|
|
114
|
+
if (id === 'toggleReveal') { this.revealed = !this.revealed; this.requestRender(); return; }
|
|
115
|
+
if (id === 'copyToken') {
|
|
116
|
+
if (!this.deps.copyToClipboard || !info) { ctx.print('Clipboard not available.'); return; }
|
|
117
|
+
this.deps.copyToClipboard(info.token);
|
|
118
|
+
ctx.print('Token copied to clipboard.');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (id === 'regenerate') {
|
|
122
|
+
const dispatched = ctx.executeCommand?.('qrcode', ['regenerate']);
|
|
123
|
+
if (!dispatched) {
|
|
124
|
+
ctx.setStatus('Token rotation is unavailable in this runtime.');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
ctx.setStatus('Rotating pairing token…');
|
|
128
|
+
void dispatched
|
|
129
|
+
.then((ok) => {
|
|
130
|
+
if (ok === false) { ctx.setStatus('Token rotation did not complete.'); return; }
|
|
131
|
+
// Re-pull the lazy connection-info thunk: /qrcode regenerate rewrote
|
|
132
|
+
// the shared token store, so a fresh getConnectionInfo() reflects the
|
|
133
|
+
// rotated token + QR. ensureInfo caches on first build — clear it.
|
|
134
|
+
this.info = undefined;
|
|
135
|
+
this.revealed = false;
|
|
136
|
+
this.ensureInfo();
|
|
137
|
+
this.requestRender();
|
|
138
|
+
ctx.setStatus('Pairing token rotated — new token and QR loaded.');
|
|
139
|
+
})
|
|
140
|
+
.catch(() => { ctx.setStatus('Token rotation failed.'); });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createPairingModalSurface(deps: PairingModalDeps): ConfigModalSurface {
|
|
146
|
+
return new PairingModalSurface(deps);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Deterministic golden fixture: a frozen connectionInfo literal (no real
|
|
151
|
+
* token/QR generation call — encodeConnectionPayload/generateQrMatrix are pure
|
|
152
|
+
* functions of this literal) and a frozen control-plane snapshot.
|
|
153
|
+
*/
|
|
154
|
+
export function pairingModalGoldenSurface(): ConfigModalSurface {
|
|
155
|
+
const connectionInfo: PairingModalConnectionInfo = {
|
|
156
|
+
url: 'http://192.168.1.50:3141', token: 'golden-token-0123456789abcdef', username: 'golden-user', password: 'golden-pass', version: '0.0.0', surface: 'tui',
|
|
157
|
+
};
|
|
158
|
+
return createPairingModalSurface({
|
|
159
|
+
getConnectionInfo: () => connectionInfo,
|
|
160
|
+
controlPlaneReadModel: { getSnapshot: () => ({ activeClientIds: ['golden-client-1'] }) },
|
|
161
|
+
copyToClipboard: () => {},
|
|
162
|
+
});
|
|
163
|
+
}
|