@pellux/goodvibes-tui 1.1.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +20 -11
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/core/alert-gating.ts +67 -0
  6. package/src/core/approval-alert.ts +72 -0
  7. package/src/core/budget-breach-notifier.ts +106 -0
  8. package/src/core/conversation-rendering.ts +19 -0
  9. package/src/core/conversation.ts +18 -0
  10. package/src/core/focus-tracker.ts +41 -0
  11. package/src/core/long-task-notifier.ts +33 -6
  12. package/src/core/system-message-router.ts +37 -51
  13. package/src/core/turn-cancellation.ts +20 -0
  14. package/src/core/turn-event-wiring.ts +64 -3
  15. package/src/export/cost-utils.ts +36 -0
  16. package/src/input/command-registry.ts +38 -1
  17. package/src/input/commands/checkpoint-runtime.ts +280 -0
  18. package/src/input/commands/codebase-runtime.ts +192 -0
  19. package/src/input/commands/eval.ts +1 -1
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/image-runtime.ts +112 -0
  22. package/src/input/commands/intelligence-runtime.ts +11 -1
  23. package/src/input/commands/local-auth-runtime.ts +4 -1
  24. package/src/input/commands/local-runtime.ts +9 -3
  25. package/src/input/commands/marketplace-runtime.ts +2 -2
  26. package/src/input/commands/memory.ts +6 -1
  27. package/src/input/commands/operator-panel-runtime.ts +40 -24
  28. package/src/input/commands/planning-runtime.ts +26 -3
  29. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  30. package/src/input/commands/plugin-runtime.ts +2 -2
  31. package/src/input/commands/policy-dispatch.ts +21 -0
  32. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  33. package/src/input/commands/qrcode-runtime.ts +3 -3
  34. package/src/input/commands/recall-review.ts +35 -0
  35. package/src/input/commands/remote-runtime.ts +3 -3
  36. package/src/input/commands/runtime-services.ts +54 -13
  37. package/src/input/commands/services-runtime.ts +1 -1
  38. package/src/input/commands/session-workflow.ts +16 -1
  39. package/src/input/commands/settings-sync-runtime.ts +1 -1
  40. package/src/input/commands/shell-core.ts +15 -7
  41. package/src/input/commands/skills-runtime.ts +2 -8
  42. package/src/input/commands/subscription-runtime.ts +2 -2
  43. package/src/input/commands/test-runtime.ts +277 -0
  44. package/src/input/commands/websearch-runtime.ts +101 -0
  45. package/src/input/commands/work-plan-runtime.ts +36 -10
  46. package/src/input/commands/workstream-runtime.ts +338 -0
  47. package/src/input/commands.ts +12 -0
  48. package/src/input/config-modal-types.ts +161 -0
  49. package/src/input/config-modal.ts +377 -0
  50. package/src/input/feed-context-factory.ts +7 -8
  51. package/src/input/handler-command-route.ts +27 -17
  52. package/src/input/handler-feed-routes.ts +61 -23
  53. package/src/input/handler-feed.ts +47 -23
  54. package/src/input/handler-interactions.ts +1 -3
  55. package/src/input/handler-modal-routes.ts +65 -0
  56. package/src/input/handler-modal-stack.ts +3 -5
  57. package/src/input/handler-modal-token-routes.ts +16 -49
  58. package/src/input/handler-picker-routes.ts +11 -94
  59. package/src/input/handler-shortcuts.ts +24 -14
  60. package/src/input/handler-types.ts +2 -6
  61. package/src/input/handler-ui-state.ts +10 -22
  62. package/src/input/handler.ts +6 -28
  63. package/src/input/keybindings.ts +22 -8
  64. package/src/input/model-picker-types.ts +24 -6
  65. package/src/input/model-picker.ts +58 -0
  66. package/src/input/panel-integration-actions.ts +9 -170
  67. package/src/input/settings-modal-data.ts +95 -0
  68. package/src/input/settings-modal-mutations.ts +6 -4
  69. package/src/main.ts +24 -27
  70. package/src/panels/agent-inspector-shared.ts +2 -1
  71. package/src/panels/base-panel.ts +22 -1
  72. package/src/panels/builtin/agent.ts +21 -107
  73. package/src/panels/builtin/development.ts +15 -61
  74. package/src/panels/builtin/knowledge.ts +8 -32
  75. package/src/panels/builtin/operations.ts +84 -428
  76. package/src/panels/builtin/session.ts +21 -112
  77. package/src/panels/builtin/shared.ts +9 -43
  78. package/src/panels/builtin-modals.ts +218 -0
  79. package/src/panels/builtin-panels.ts +5 -0
  80. package/src/panels/cost-tracker-panel.ts +36 -10
  81. package/src/panels/diff-panel.ts +12 -43
  82. package/src/panels/eval-registry.ts +60 -0
  83. package/src/panels/fleet-panel.ts +800 -0
  84. package/src/panels/fleet-read-model.ts +477 -0
  85. package/src/panels/fleet-steer.ts +92 -0
  86. package/src/panels/fleet-stop.ts +125 -0
  87. package/src/panels/fleet-tabs.ts +230 -0
  88. package/src/panels/fleet-transcript.ts +356 -0
  89. package/src/panels/index.ts +7 -31
  90. package/src/panels/modals/hooks-modal.ts +187 -0
  91. package/src/panels/modals/keybindings-modal.ts +151 -0
  92. package/src/panels/modals/knowledge-modal.ts +158 -0
  93. package/src/panels/modals/local-auth-modal.ts +132 -0
  94. package/src/panels/modals/marketplace-modal.ts +218 -0
  95. package/src/panels/modals/memory-modal.ts +180 -0
  96. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  97. package/src/panels/modals/modal-theme.ts +36 -0
  98. package/src/panels/modals/pairing-modal.ts +144 -0
  99. package/src/panels/modals/planning-modal.ts +282 -0
  100. package/src/panels/modals/plugins-modal.ts +174 -0
  101. package/src/panels/modals/policy-modal.ts +256 -0
  102. package/src/panels/modals/provider-health-modal.ts +136 -0
  103. package/src/panels/modals/remote-modal.ts +115 -0
  104. package/src/panels/modals/sandbox-modal.ts +150 -0
  105. package/src/panels/modals/security-modal.ts +217 -0
  106. package/src/panels/modals/services-modal.ts +179 -0
  107. package/src/panels/modals/settings-sync-modal.ts +142 -0
  108. package/src/panels/modals/skills-modal.ts +189 -0
  109. package/src/panels/modals/subscription-modal.ts +172 -0
  110. package/src/panels/modals/work-plan-modal.ts +149 -0
  111. package/src/panels/panel-confirm-overlay.ts +72 -0
  112. package/src/panels/panel-manager.ts +108 -5
  113. package/src/panels/project-planning-answer-actions.ts +4 -2
  114. package/src/panels/skills-panel.ts +7 -51
  115. package/src/panels/types.ts +13 -0
  116. package/src/permissions/hunk-selection.ts +179 -0
  117. package/src/permissions/prompt.ts +60 -4
  118. package/src/renderer/compaction-history-modal.ts +19 -3
  119. package/src/renderer/compaction-preview.ts +13 -2
  120. package/src/renderer/compaction-quality.ts +100 -0
  121. package/src/renderer/config-modal.ts +81 -0
  122. package/src/renderer/conversation-overlays.ts +10 -17
  123. package/src/renderer/fleet-tab-strip.ts +56 -0
  124. package/src/renderer/footer-tips.ts +10 -2
  125. package/src/renderer/git-status.ts +40 -0
  126. package/src/renderer/help-overlay.ts +2 -2
  127. package/src/renderer/model-workspace.ts +44 -1
  128. package/src/renderer/panel-workspace-bar.ts +8 -2
  129. package/src/renderer/turn-injection.ts +114 -0
  130. package/src/runtime/bootstrap-command-context.ts +21 -1
  131. package/src/runtime/bootstrap-command-parts.ts +34 -7
  132. package/src/runtime/bootstrap-core.ts +14 -4
  133. package/src/runtime/bootstrap-shell.ts +29 -16
  134. package/src/runtime/bootstrap.ts +11 -17
  135. package/src/runtime/code-index-services.ts +112 -0
  136. package/src/runtime/orchestrator-core-services.ts +49 -0
  137. package/src/runtime/process-lifecycle.ts +3 -1
  138. package/src/runtime/services.ts +91 -43
  139. package/src/runtime/ui-services.ts +8 -0
  140. package/src/runtime/workstream-services.ts +195 -0
  141. package/src/shell/blocking-input.ts +22 -1
  142. package/src/shell/ui-openers.ts +129 -17
  143. package/src/utils/format-duration.ts +8 -18
  144. package/src/utils/splash-lines.ts +1 -1
  145. package/src/version.ts +1 -1
  146. package/src/panels/agent-inspector-panel.ts +0 -786
  147. package/src/panels/approval-panel.ts +0 -252
  148. package/src/panels/automation-control-panel.ts +0 -479
  149. package/src/panels/cockpit-panel.ts +0 -481
  150. package/src/panels/cockpit-read-model.ts +0 -238
  151. package/src/panels/communication-panel.ts +0 -256
  152. package/src/panels/control-plane-panel.ts +0 -470
  153. package/src/panels/debug-panel.ts +0 -615
  154. package/src/panels/docs-panel.ts +0 -384
  155. package/src/panels/eval-panel.ts +0 -627
  156. package/src/panels/file-explorer-panel.ts +0 -673
  157. package/src/panels/file-preview-panel.ts +0 -517
  158. package/src/panels/hooks-panel.ts +0 -401
  159. package/src/panels/incident-review-panel.ts +0 -406
  160. package/src/panels/intelligence-panel.ts +0 -383
  161. package/src/panels/knowledge-graph-panel.ts +0 -515
  162. package/src/panels/marketplace-panel.ts +0 -399
  163. package/src/panels/memory-panel.ts +0 -558
  164. package/src/panels/ops-control-panel.ts +0 -329
  165. package/src/panels/ops-strategy-panel.ts +0 -321
  166. package/src/panels/orchestration-panel.ts +0 -465
  167. package/src/panels/panel-list-panel.ts +0 -566
  168. package/src/panels/plan-dashboard-panel.ts +0 -707
  169. package/src/panels/policy-panel.ts +0 -517
  170. package/src/panels/project-planning-panel.ts +0 -731
  171. package/src/panels/provider-health-panel.ts +0 -678
  172. package/src/panels/provider-health-tracker.ts +0 -310
  173. package/src/panels/provider-health-views.ts +0 -567
  174. package/src/panels/qr-panel.ts +0 -280
  175. package/src/panels/remote-panel.ts +0 -534
  176. package/src/panels/routes-panel.ts +0 -241
  177. package/src/panels/sandbox-panel.ts +0 -456
  178. package/src/panels/security-panel.ts +0 -447
  179. package/src/panels/services-panel.ts +0 -329
  180. package/src/panels/session-browser-panel.ts +0 -496
  181. package/src/panels/settings-sync-panel.ts +0 -398
  182. package/src/panels/subscription-panel.ts +0 -342
  183. package/src/panels/symbol-outline-panel.ts +0 -619
  184. package/src/panels/system-messages-panel.ts +0 -364
  185. package/src/panels/tasks-panel.ts +0 -608
  186. package/src/panels/thinking-panel.ts +0 -333
  187. package/src/panels/tool-inspector-panel.ts +0 -537
  188. package/src/panels/work-plan-panel.ts +0 -540
  189. package/src/panels/worktree-panel.ts +0 -360
  190. package/src/panels/wrfc-panel.ts +0 -790
  191. package/src/renderer/agent-detail-modal.ts +0 -466
  192. package/src/renderer/live-tail-modal.ts +0 -156
  193. package/src/renderer/process-modal.ts +0 -671
  194. package/src/renderer/qr-renderer.ts +0 -120
@@ -0,0 +1,151 @@
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
+ import { KeybindingsManager } from '../../input/keybindings.ts';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Docs + Shortcuts → 'keybindings' config-modal surface (W6.1 group-B port).
13
+ // A MERGE of the retired DocsPanel (tools/models/shortcuts) and the
14
+ // shortcuts-overlay reference. Three tabs: 'Tools', 'Models', and 'Shortcuts'
15
+ // (the categorized keyboard reference — driven live off keybindingsManager so
16
+ // user overrides show up — followed by an exhaustive 'All Bindings (live)'
17
+ // table). Enter opens the tool-inspector successor ('fleet') or switches the
18
+ // active model, both via the command path (fleet is a panel; /model is a
19
+ // settings mutation). Selection-blind port: the panel's selected tool/model
20
+ // detail is folded into each row label. The panel's live '/' filter is dropped
21
+ // (the host has no query state).
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface KeybindingsModalTool {
25
+ readonly definition: {
26
+ readonly name: string;
27
+ readonly description?: string;
28
+ readonly sideEffects?: readonly string[];
29
+ readonly concurrency?: string;
30
+ readonly supportsProgress?: boolean;
31
+ readonly supportsStreamingOutput?: boolean;
32
+ };
33
+ }
34
+ export interface KeybindingsModalToolRegistry { list(): readonly KeybindingsModalTool[]; }
35
+ export interface KeybindingsModalModel { readonly id: string; readonly provider: string; readonly registryKey: string; readonly displayName: string; readonly contextWindow: number; readonly selectable: boolean; }
36
+ export interface KeybindingsModalProviderRegistry { listModels(): readonly KeybindingsModalModel[]; getCurrentModel?(): { readonly registryKey: string } | undefined; }
37
+
38
+ export interface KeybindingsModalDeps {
39
+ readonly toolRegistry?: KeybindingsModalToolRegistry;
40
+ readonly providerRegistry?: KeybindingsModalProviderRegistry;
41
+ readonly keybindingsManager?: KeybindingsManager;
42
+ }
43
+
44
+ class KeybindingsModalSurface implements ConfigModalSurface {
45
+ readonly name = 'keybindings-modal';
46
+ readonly title = 'Keybindings';
47
+
48
+ constructor(private readonly deps: KeybindingsModalDeps) {}
49
+
50
+ readonly actions = [
51
+ { key: 'enter', id: 'activate', label: 'activate', enabledFor: (row: ConfigModalRow | null, tabId: string) => (tabId === 'tools') || (tabId === 'models' && row !== null) },
52
+ { key: 'r', id: 'refresh', label: 'refresh' },
53
+ ];
54
+
55
+ private sortedModels(): readonly KeybindingsModalModel[] {
56
+ const models = this.deps.providerRegistry?.listModels() ?? [];
57
+ return [...models].sort((a, b) => a.provider.localeCompare(b.provider) || a.displayName.localeCompare(b.displayName));
58
+ }
59
+
60
+ private toolsTab(): ConfigModalTab {
61
+ if (!this.deps.toolRegistry) return { id: 'tools', label: 'Tools', rows: [infoRow('tools:none', 'Tool registry not wired into this session.', { dim: true })], emptyText: 'Tool registry not wired into this session.' };
62
+ const tools = this.deps.toolRegistry.list();
63
+ const rows: ConfigModalRow[] = tools.map((tool) => {
64
+ const d = tool.definition;
65
+ const meta: string[] = [];
66
+ if (d.sideEffects?.length) meta.push(`effects: ${d.sideEffects.join(', ')}`);
67
+ if (d.concurrency) meta.push(`concurrency: ${d.concurrency}`);
68
+ if (d.supportsProgress) meta.push('progress');
69
+ if (d.supportsStreamingOutput) meta.push('streaming');
70
+ const detail = [d.description, meta.join(' | ') || null].filter((s): s is string => Boolean(s)).join(' · ');
71
+ return { id: `tool:${d.name}`, label: detail ? `${d.name.padEnd(22)} ${detail}` : d.name };
72
+ });
73
+ return { id: 'tools', label: 'Tools', rows, emptyText: 'No tools registered.', hints: ['enter open in fleet'] };
74
+ }
75
+
76
+ private modelsTab(): ConfigModalTab {
77
+ if (!this.deps.providerRegistry) return { id: 'models', label: 'Models', rows: [infoRow('models:none', 'Provider registry not wired into this session.', { dim: true })], emptyText: 'Provider registry not wired into this session.' };
78
+ const activeKey = this.deps.providerRegistry.getCurrentModel?.()?.registryKey;
79
+ const rows: ConfigModalRow[] = this.sortedModels().map((model) => {
80
+ const ctxK = model.contextWindow > 0 ? `${(model.contextWindow / 1000).toFixed(0)}k` : '?';
81
+ const isActive = model.registryKey === activeKey;
82
+ return { id: `model:${model.registryKey}`, label: `${model.provider.padEnd(14)} ${model.displayName.padEnd(24)} ctx:${ctxK}${isActive ? ' ACTIVE' : ''} ${model.id}${model.selectable ? '' : ' (not selectable)'}` };
83
+ });
84
+ return { id: 'models', label: 'Models', rows, emptyText: 'No models registered.', hints: ['enter set active model'] };
85
+ }
86
+
87
+ private shortcutsTab(): ConfigModalTab {
88
+ const km = this.deps.keybindingsManager;
89
+ if (!km) return { id: 'shortcuts', label: 'Shortcuts', rows: [infoRow('sc:none', 'Keybindings manager not wired into this session.', { dim: true })], emptyText: 'Keybindings manager not wired into this session.' };
90
+ const kb = (action: Parameters<KeybindingsManager['getComboLabel']>[0]) => km.getComboLabel(action);
91
+ const rows: ConfigModalRow[] = [];
92
+ let n = 0;
93
+ const category = (title: string, entries: ReadonlyArray<readonly [string, string]>): void => {
94
+ rows.push(infoRow(`sc:t:${n++}`, title));
95
+ for (const [key, desc] of entries) rows.push(infoRow(`sc:${n++}`, `${key.padEnd(20)} ${desc}`));
96
+ };
97
+ category('Navigation', [['Up / Down', 'Scroll / history recall'], ['PageUp / PageDn', 'Scroll by full page'], ['Home / End', 'Jump to start / end of line'], [kb('search'), 'Search conversation'], ['n / N (search)', 'Next / previous match'], ['Mouse wheel', 'Scroll conversation or hovered panel']]);
98
+ category('Editing', [['Enter', 'Submit message'], ['Shift+Enter', 'Insert newline'], ['@', 'Open file picker'], ['/', 'Slash command mode'], [kb('paste'), 'Paste (image priority)'], [`${kb('undo')} / ${kb('redo')}`, 'Undo / redo'], [kb('clear-prompt'), 'Clear prompt'], [kb('delete-word'), 'Delete word backward'], [kb('kill-line'), 'Kill to end of line']]);
99
+ category('Actions', [['Tab', 'Collapse/expand block'], [kb('bookmark'), 'Bookmark block'], [kb('block-copy'), 'Copy block to clipboard'], [kb('block-save'), 'Save block to file'], [kb('copy-selection'), 'Copy selection'], ['F2', 'Process monitor'], ['?', 'Help overlay'], [`${kb('clear-cancel')} x2`, 'Exit']]);
100
+ category('Panels', [['Tab', 'Swap focus between input and panel workspace'], [kb('panel-picker'), 'Open / focus / hide panel workspace'], [kb('panel-tab-next'), 'Next workspace panel tab'], [kb('panel-tab-prev'), 'Previous workspace panel tab'], [`${kb('panel-tab-1')}...${kb('panel-tab-9')}`, 'Jump to workspace tab 1-9'], [kb('panel-focus-toggle'), 'Swap focus between top / bottom pane'], [kb('panel-close'), 'Close active panel'], [kb('panel-close-all'), 'Close all panels'], [kb('panel-ops'), 'Open the Ops Control panel']]);
101
+ category('In-Panel Controls', [['j / k', 'Move selection down / up'], ['g / G', 'Jump to top / bottom'], ['/', 'Filter the list']]);
102
+ const allBindings = km.getAll().map((entry) => [entry.combos.length > 0 ? entry.combos.map((combo) => km.formatCombo(combo)).join(', ') : '(unbound)', entry.description] as const);
103
+ category('All Bindings (live)', allBindings);
104
+ return { id: 'shortcuts', label: 'Shortcuts', rows, emptyText: 'No shortcuts recorded.' };
105
+ }
106
+
107
+ buildView(): ConfigModalView {
108
+ return { title: 'Keybindings', tabs: [this.toolsTab(), this.modelsTab(), this.shortcutsTab()] };
109
+ }
110
+
111
+ onAction(id: string, ctx: ConfigModalActionContext): void {
112
+ if (id === 'refresh') { ctx.setStatus('Docs & shortcuts are read live.'); ctx.requestRender(); return; }
113
+ if (id !== 'activate') return;
114
+ if (ctx.tabId === 'tools') { void ctx.executeCommand?.('panel', ['open', 'fleet']); ctx.setStatus('Opened the tool inspector (fleet).'); return; }
115
+ if (ctx.tabId === 'models') {
116
+ const key = ctx.row?.id.startsWith('model:') ? ctx.row.id.slice('model:'.length) : null;
117
+ if (!key) return;
118
+ const model = this.sortedModels().find((m) => m.registryKey === key);
119
+ if (!model || !model.selectable) { ctx.setStatus('This model is not selectable.'); return; }
120
+ void ctx.executeCommand?.('model', [model.registryKey]);
121
+ ctx.setStatus(`Dispatched /model ${model.registryKey}.`);
122
+ }
123
+ }
124
+ }
125
+
126
+ export function createKeybindingsModalSurface(deps: KeybindingsModalDeps): ConfigModalSurface {
127
+ return new KeybindingsModalSurface(deps);
128
+ }
129
+
130
+ /**
131
+ * Deterministic golden fixture. KeybindingsManager is pointed at a nonexistent
132
+ * config path so it always resolves to DEFAULT_KEYBINDINGS. Tool and model
133
+ * fixtures are frozen literals — no disk, no live registry.
134
+ */
135
+ export function keybindingsModalGoldenSurface(): ConfigModalSurface {
136
+ const keybindingsManager = new KeybindingsManager({ configPath: '/nonexistent/golden-keybindings.json' });
137
+ const toolRegistry: KeybindingsModalToolRegistry = {
138
+ list: () => [
139
+ { definition: { name: 'read_file', description: 'Read a file from disk.', sideEffects: ['read_fs'], concurrency: 'parallel', supportsProgress: false, supportsStreamingOutput: false } },
140
+ { definition: { name: 'run_shell', description: 'Execute a shell command.', sideEffects: ['exec'], concurrency: 'serial', supportsProgress: true, supportsStreamingOutput: true } },
141
+ ],
142
+ };
143
+ const providerRegistry: KeybindingsModalProviderRegistry = {
144
+ listModels: () => [
145
+ { id: 'golden-a', provider: 'golden-provider', registryKey: 'golden-provider:golden-a', displayName: 'Golden Model A', contextWindow: 128000, selectable: true },
146
+ { id: 'golden-b', provider: 'golden-provider', registryKey: 'golden-provider:golden-b', displayName: 'Golden Model B', contextWindow: 32000, selectable: false },
147
+ ],
148
+ getCurrentModel: () => ({ registryKey: 'golden-provider:golden-a' }),
149
+ };
150
+ return createKeybindingsModalSurface({ toolRegistry, providerRegistry, keybindingsManager });
151
+ }
@@ -0,0 +1,158 @@
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
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Knowledge (graph) → config-modal surface (W6.1 group-B port). Two tabs:
13
+ // 'Browse' (sources/nodes/issues + schedules) and 'Review' (the open-issue
14
+ // queue) — the panel's browse/review toggle, now host tabs. Issue review
15
+ // mutations (accept/reject/resolve/reopen) route to the `/knowledge
16
+ // review-issue` command. 'm' cross-opens the memory surface. Selection-blind
17
+ // port: the panel's selected-row id/detail is folded into each row label.
18
+ // refresh() is the only place this module touches the SDK.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface KnowledgeNodeLike { readonly id: string; readonly kind: string; readonly title: string; readonly summary?: string | undefined; }
22
+ interface KnowledgeSourceLike { readonly id: string; readonly sourceType: string; readonly status: string; readonly title?: string | undefined; readonly canonicalUri?: string | undefined; readonly sourceUri?: string | undefined; readonly summary?: string | undefined; }
23
+ interface KnowledgeIssueLike { readonly id: string; readonly severity: 'info' | 'warning' | 'error'; readonly code: string; readonly message: string; readonly status: 'open' | 'resolved'; readonly sourceId?: string | undefined; readonly nodeId?: string | undefined; }
24
+ interface KnowledgeScheduleLike { readonly id: string; readonly label: string; readonly enabled: boolean; }
25
+
26
+ export interface KnowledgeModalDeps {
27
+ readonly knowledgeApi: {
28
+ readonly graph: {
29
+ readonly nodes: { list(limit?: number): readonly KnowledgeNodeLike[] };
30
+ readonly issues: { list(limit?: number): readonly KnowledgeIssueLike[] };
31
+ };
32
+ readonly sources: { list(limit?: number): readonly KnowledgeSourceLike[] };
33
+ readonly jobs: { schedules: { list(limit?: number): readonly KnowledgeScheduleLike[] } };
34
+ };
35
+ }
36
+
37
+ type BrowseKind = 'node' | 'source' | 'issue';
38
+ interface BrowseRow { readonly kind: BrowseKind; readonly id: string; readonly title: string; readonly tag: string; readonly detail: string; readonly extra: string; }
39
+
40
+ const LIST_LIMIT = 60;
41
+
42
+ function cleanInline(value: string | undefined): string { return (value ?? '').replace(/\s+/g, ' ').trim(); }
43
+
44
+ function nodeRow(node: KnowledgeNodeLike): BrowseRow {
45
+ return { kind: 'node', id: node.id, title: cleanInline(node.title) || 'untitled', tag: `node/${node.kind}`, detail: cleanInline(node.summary), extra: '' };
46
+ }
47
+ function sourceRow(source: KnowledgeSourceLike): BrowseRow {
48
+ return { kind: 'source', id: source.id, title: cleanInline(source.title) || cleanInline(source.canonicalUri) || cleanInline(source.sourceUri) || source.id, tag: `source/${source.status}`, detail: cleanInline(source.summary), extra: `type ${source.sourceType}` };
49
+ }
50
+ function issueRow(issue: KnowledgeIssueLike): BrowseRow {
51
+ const refs = [issue.sourceId ? `source ${issue.sourceId}` : null, issue.nodeId ? `node ${issue.nodeId}` : null].filter((ref): ref is string => ref !== null);
52
+ return { kind: 'issue', id: issue.id, title: issue.code, tag: `issue/${issue.severity}`, detail: cleanInline(issue.message), extra: refs.join(' ') };
53
+ }
54
+
55
+ class KnowledgeModalSurface implements ConfigModalSurface {
56
+ readonly name = 'knowledge-modal';
57
+ readonly title = 'Knowledge';
58
+ private browseRows: BrowseRow[] = [];
59
+ private reviewRows: BrowseRow[] = [];
60
+ private sourceCount = 0;
61
+ private nodeCount = 0;
62
+ private issueCount = 0;
63
+ private schedules: readonly KnowledgeScheduleLike[] = [];
64
+ private loadError: string | null = null;
65
+
66
+ constructor(private readonly deps: KnowledgeModalDeps) {}
67
+
68
+ private readonly issueGate = (row: ConfigModalRow | null, tabId: string): boolean => tabId === 'review' && (row?.id.startsWith('issue:') ?? false);
69
+
70
+ readonly actions = [
71
+ { key: 'a', id: 'accept', label: 'accept', enabledFor: this.issueGate },
72
+ { key: 'x', id: 'reject', label: 'reject', enabledFor: this.issueGate },
73
+ { key: 's', id: 'resolve', label: 'resolve', enabledFor: this.issueGate },
74
+ { key: 'o', id: 'reopen', label: 'reopen', enabledFor: this.issueGate },
75
+ { key: 'm', id: 'openMemory', label: 'memory' },
76
+ { key: 'r', id: 'refresh', label: 'refresh' },
77
+ ];
78
+
79
+ onOpen(): void { this.refresh(); }
80
+
81
+ private refresh(): void {
82
+ try {
83
+ const nodes = this.deps.knowledgeApi.graph.nodes.list(LIST_LIMIT);
84
+ const sources = this.deps.knowledgeApi.sources.list(LIST_LIMIT);
85
+ const issues = this.deps.knowledgeApi.graph.issues.list(LIST_LIMIT);
86
+ this.sourceCount = sources.length;
87
+ this.nodeCount = nodes.length;
88
+ this.issueCount = issues.length;
89
+ this.browseRows = [...sources.map(sourceRow), ...nodes.map(nodeRow), ...issues.map(issueRow)];
90
+ this.reviewRows = issues.filter((issue) => issue.status === 'open').map(issueRow);
91
+ this.schedules = this.deps.knowledgeApi.jobs.schedules.list(20);
92
+ this.loadError = null;
93
+ } catch (e) {
94
+ this.browseRows = []; this.reviewRows = []; this.schedules = [];
95
+ this.loadError = `Knowledge graph load failed: ${summarizeError(e)}`;
96
+ }
97
+ }
98
+
99
+ private rowFor(row: BrowseRow): ConfigModalRow {
100
+ return { id: `${row.kind}:${row.id}`, label: `${row.tag.padEnd(16)} ${row.title.padEnd(28)} ${row.detail}${row.extra ? ` · ${row.extra}` : ''}` };
101
+ }
102
+
103
+ private browseTab(): ConfigModalTab {
104
+ const readySources = this.sourceCount > 0 && this.nodeCount > 0;
105
+ const header = [
106
+ `sources ${this.sourceCount} nodes ${this.nodeCount} issues ${this.issueCount} schedules ${this.schedules.length}`,
107
+ readySources ? 'retrieval ready (sources + nodes indexed)' : 'retrieval not ready (needs sources and nodes)',
108
+ ];
109
+ const rows: ConfigModalRow[] = this.browseRows.map((row) => this.rowFor(row));
110
+ if (this.schedules.length > 0) {
111
+ rows.push(infoRow('sched:title', 'Schedules'));
112
+ this.schedules.slice(0, 4).forEach((s, i) => rows.push(infoRow(`sched:${i}`, `${s.enabled ? 'on ' : 'off'} ${s.label}`, s.enabled ? undefined : { dim: true })));
113
+ }
114
+ return { id: 'browse', label: 'Browse', header, rows, emptyText: 'No ingested knowledge yet.', hints: ['m memory'] };
115
+ }
116
+
117
+ private reviewTab(): ConfigModalTab {
118
+ return { id: 'review', label: 'Review', rows: this.reviewRows.map((row) => this.rowFor(row)), emptyText: 'No issues waiting for review.', hints: ['a accept', 'x reject', 's resolve', 'o reopen'] };
119
+ }
120
+
121
+ buildView(): ConfigModalView {
122
+ if (this.loadError) return { title: 'Knowledge', degraded: this.loadError, tabs: [{ id: 'browse', label: 'Browse', rows: [] }] };
123
+ return { title: 'Knowledge', tabs: [this.browseTab(), this.reviewTab()] };
124
+ }
125
+
126
+ onAction(id: string, ctx: ConfigModalActionContext): void {
127
+ if (id === 'refresh') { this.refresh(); ctx.setStatus('Reloaded knowledge graph.'); return; }
128
+ if (id === 'openMemory') { ctx.openModal?.('memory-modal'); return; }
129
+ const action = id === 'accept' ? 'accept' : id === 'reject' ? 'reject' : id === 'resolve' ? 'resolve' : id === 'reopen' ? 'reopen' : null;
130
+ if (!action) return;
131
+ const issueId = ctx.row?.id.startsWith('issue:') ? ctx.row.id.slice('issue:'.length) : null;
132
+ if (!issueId) return;
133
+ void ctx.executeCommand?.('knowledge', ['review-issue', issueId, action, '--reviewer', 'tui']);
134
+ ctx.setStatus(`Dispatched /knowledge review-issue ${issueId} ${action}.`);
135
+ }
136
+ }
137
+
138
+ export function createKnowledgeModalSurface(deps: KnowledgeModalDeps): ConfigModalSurface {
139
+ return new KnowledgeModalSurface(deps);
140
+ }
141
+
142
+ /**
143
+ * Deterministic golden fixture: fixed node/source/issue/schedule records —
144
+ * no live SDK calls, no random ids, no wall-clock reads.
145
+ */
146
+ export function knowledgeModalGoldenSurface(): ConfigModalSurface {
147
+ const nodes: readonly KnowledgeNodeLike[] = [{ id: 'node-1', kind: 'topic', title: 'Release cadence', summary: 'Batched internal releases, no per-change tags.' }];
148
+ const sources: readonly KnowledgeSourceLike[] = [{ id: 'source-1', sourceType: 'repo', status: 'indexed', title: 'goodvibes-tui', canonicalUri: 'repo://goodvibes-tui', summary: 'Primary TUI repository.' }];
149
+ const issues: readonly KnowledgeIssueLike[] = [{ id: 'issue-1', severity: 'warning', code: 'stale-source', message: 'Source has not been recrawled in 30 days.', status: 'open', sourceId: 'source-1' }];
150
+ const schedules: readonly KnowledgeScheduleLike[] = [{ id: 'sched-1', label: 'Nightly reindex', enabled: true }];
151
+ return createKnowledgeModalSurface({
152
+ knowledgeApi: {
153
+ graph: { nodes: { list: () => nodes }, issues: { list: () => issues } },
154
+ sources: { list: () => sources },
155
+ jobs: { schedules: { list: () => schedules } },
156
+ },
157
+ });
158
+ }
@@ -0,0 +1,132 @@
1
+ import type { ConfigModalAction, ConfigModalActionContext, ConfigModalRow, ConfigModalSurface, ConfigModalView } from '../../input/config-modal-types.ts';
2
+ import type { LocalAuthInspectionQuery } from '../../runtime/ui-service-queries.ts';
3
+ import type { UserAuthManager } from '@pellux/goodvibes-sdk/platform/security';
4
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
5
+ import { postureLine, kv } from './modal-surface-helpers.ts';
6
+
7
+ /** Mutation surface the surface needs to gate p/d/b on — a subset of UserAuthManager,
8
+ * mirroring LocalAuthPanel's own `hasLocalAuthMutations` guard. */
9
+ type LocalAuthMutations = Pick<UserAuthManager, 'addUser' | 'deleteUser' | 'rotatePassword' | 'clearBootstrapCredentialFile'>;
10
+
11
+ function hasLocalAuthMutations(value: LocalAuthInspectionQuery): value is LocalAuthInspectionQuery & LocalAuthMutations {
12
+ const candidate = value as Partial<LocalAuthMutations>;
13
+ return typeof candidate.addUser === 'function'
14
+ && typeof candidate.deleteUser === 'function'
15
+ && typeof candidate.rotatePassword === 'function'
16
+ && typeof candidate.clearBootstrapCredentialFile === 'function';
17
+ }
18
+
19
+ function formatRoles(roles: readonly string[]): string {
20
+ return roles.length > 0 ? roles.join(', ') : '(none)';
21
+ }
22
+
23
+ function usernameOf(row: ConfigModalRow | null): string | null {
24
+ return row?.id.startsWith('user:') ? row.id.slice('user:'.length) : null;
25
+ }
26
+
27
+ /**
28
+ * Local-auth config-modal surface (migrated from the `local-auth` panel).
29
+ * `inspect()` is synchronous, so buildView reads it live every tick. All
30
+ * mutations (add-user, rotate-password, delete-user, clear-bootstrap-file)
31
+ * dispatch through the existing `/local-auth` command rather than touching
32
+ * the auth manager directly — the command opens masked password entry for
33
+ * add-user/rotate-password when no password argument is supplied, which is
34
+ * exactly how the retired panel kept plaintext out of history and the
35
+ * transcript. This surface never renders, accepts, or holds a password.
36
+ */
37
+ class LocalAuthModalSurface implements ConfigModalSurface {
38
+ readonly name = 'local-auth-modal';
39
+ readonly title = 'Local Auth';
40
+ /** Cached from the last buildView() — read by the 'b' action's enabledFor,
41
+ * set fresh every tick (buildView always runs before enabledFor is
42
+ * consulted; see settings-sync-modal's `hasStaged` for the same idiom). */
43
+ private bootstrapPresent = false;
44
+
45
+ constructor(private readonly authManager: LocalAuthInspectionQuery) {}
46
+
47
+ readonly actions: ConfigModalAction[] = [
48
+ { key: 'a', id: 'add-user', label: 'add user' },
49
+ { key: 'p', id: 'rotate-pw', label: 'rotate password', enabledFor: (row) => usernameOf(row) !== null },
50
+ { key: 'd', id: 'delete', label: 'delete user', confirm: true, enabledFor: (row) => usernameOf(row) !== null },
51
+ { key: 'b', id: 'clear-bootstrap', label: 'clear bootstrap', enabledFor: () => this.bootstrapPresent },
52
+ ];
53
+
54
+ buildView(): ConfigModalView {
55
+ let mutationsUnavailable = false;
56
+ try {
57
+ mutationsUnavailable = !hasLocalAuthMutations(this.authManager);
58
+ const snapshot = this.authManager.inspect();
59
+ this.bootstrapPresent = snapshot.bootstrapCredentialPresent;
60
+
61
+ const header = [postureLine([
62
+ kv('users', snapshot.userCount),
63
+ kv('sessions', snapshot.sessionCount),
64
+ kv('bootstrap', snapshot.bootstrapCredentialPresent ? 'present' : 'cleared'),
65
+ ])];
66
+
67
+ const rows: ConfigModalRow[] = snapshot.users.map((user) => ({
68
+ id: `user:${user.username}`,
69
+ label: `${user.username} ${formatRoles(user.roles)}`,
70
+ }));
71
+
72
+ return {
73
+ title: 'Local Auth',
74
+ degraded: mutationsUnavailable ? 'Local auth mutations are not available in this session.' : undefined,
75
+ tabs: [{
76
+ id: 'users',
77
+ label: 'Users',
78
+ header,
79
+ rows,
80
+ emptyText: 'No local auth users configured.',
81
+ hints: ['a add user'],
82
+ }],
83
+ };
84
+ } catch (error) {
85
+ this.bootstrapPresent = false;
86
+ return {
87
+ title: 'Local Auth',
88
+ degraded: `Local auth inspection failed: ${summarizeError(error)}`,
89
+ tabs: [{ id: 'users', label: 'Users', rows: [], emptyText: 'Local auth data unavailable.' }],
90
+ };
91
+ }
92
+ }
93
+
94
+ onAction(id: string, ctx: ConfigModalActionContext): void {
95
+ switch (id) {
96
+ // add-user / rotate-password require the masked password-entry sub-mode,
97
+ // which renders on the LocalAuthPanel and cannot draw or capture input
98
+ // underneath this fullscreen modal. Point the operator at the command
99
+ // (which opens masked entry) rather than dispatching it into a hidden
100
+ // surface — the secure flow stays a keystroke away, just not from here.
101
+ case 'add-user':
102
+ ctx.print('To add a user securely, run /local-auth add-user <username> (opens masked password entry).');
103
+ ctx.setStatus('Run /local-auth add-user <username> for masked entry.');
104
+ break;
105
+ case 'rotate-pw': {
106
+ const username = usernameOf(ctx.row);
107
+ if (!username) return;
108
+ ctx.print(`To rotate securely, run /local-auth rotate-password ${username} (opens masked password entry).`);
109
+ ctx.setStatus(`Run /local-auth rotate-password ${username} for masked entry.`);
110
+ break;
111
+ }
112
+ case 'delete': {
113
+ const username = usernameOf(ctx.row);
114
+ if (!username) return;
115
+ void ctx.executeCommand?.('local-auth', ['delete-user', username]);
116
+ ctx.setStatus(`Deleting ${username}…`);
117
+ break;
118
+ }
119
+ case 'clear-bootstrap':
120
+ void ctx.executeCommand?.('local-auth', ['clear-bootstrap-file']);
121
+ ctx.setStatus('Clearing bootstrap credential file…');
122
+ break;
123
+ default:
124
+ return;
125
+ }
126
+ ctx.requestRender();
127
+ }
128
+ }
129
+
130
+ export function createLocalAuthModalSurface(authManager: LocalAuthInspectionQuery): ConfigModalSurface {
131
+ return new LocalAuthModalSurface(authManager);
132
+ }
@@ -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
+ }