@pellux/goodvibes-tui 0.27.0 → 0.29.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,37 +1,54 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import { createEmptyLine } from '../types/grid.ts';
3
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
2
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
3
6
  import {
4
7
  buildEmptyState,
5
8
  buildGuidanceLine,
6
9
  buildKeyValueLine,
10
+ buildKeyboardHints,
7
11
  buildPanelLine,
8
12
  buildPanelWorkspace,
9
13
  DEFAULT_PANEL_PALETTE,
10
- type PanelWorkspaceSection,
11
14
  } from './polish.ts';
12
15
  import {
13
16
  type EcosystemCatalogPathOptions,
17
+ installEcosystemCatalogEntry,
14
18
  listInstalledEcosystemEntries,
15
19
  loadEcosystemCatalog,
16
20
  reviewEcosystemCatalogEntry,
21
+ uninstallEcosystemCatalogEntry,
17
22
  type EcosystemCatalogEntry,
18
23
  type EcosystemEntryKind,
19
24
  } from '@/runtime/index.ts';
20
25
  import type { UiMarketplaceSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
21
26
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
22
27
 
23
- const C = {
24
- ...DEFAULT_PANEL_PALETTE,
25
- header: '#e2e8f0',
26
- headerBg: '#1f2937',
27
- } as const;
28
+ // Base chrome only — title band and text tokens come straight from
29
+ // DEFAULT_PANEL_PALETTE (WO-002).
30
+ const C = DEFAULT_PANEL_PALETTE;
31
+
32
+ type MarketplaceReview = ReturnType<typeof reviewEcosystemCatalogEntry>;
28
33
 
29
34
  type MarketplaceRow = {
30
35
  kind: EcosystemEntryKind;
31
36
  entry: EcosystemCatalogEntry;
32
37
  installed: boolean;
38
+ /**
39
+ * Computed once per refresh() (not per render()) so that render() never
40
+ * touches disk — see the class-level note on the render()/refresh() split.
41
+ */
42
+ review: MarketplaceReview;
33
43
  };
34
44
 
45
+ /** What a pending install/uninstall confirm will do once the user confirms. */
46
+ interface MarketplaceConfirmSubject {
47
+ readonly kind: EcosystemEntryKind;
48
+ readonly entryId: string;
49
+ readonly action: 'install' | 'uninstall';
50
+ }
51
+
35
52
  function statusColor(installed: boolean): string {
36
53
  return installed ? C.good : C.dim;
37
54
  }
@@ -39,24 +56,44 @@ function statusColor(installed: boolean): string {
39
56
  export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
40
57
  private rows: MarketplaceRow[] = [];
41
58
  private readonly unsub: (() => void) | null;
59
+ // I1: confirm state for install/uninstall (destructive-adjacent — mutates disk)
60
+ private confirm: ConfirmState<MarketplaceConfirmSubject> | null = null;
61
+ // Entry id whose full review detail is expanded (Enter toggles); cleared on refresh.
62
+ private expandedEntryId: string | null = null;
42
63
 
43
64
  public constructor(
44
65
  private readonly readModel?: UiReadModel<UiMarketplaceSnapshot>,
45
66
  private readonly ecosystemPaths?: EcosystemCatalogPathOptions,
46
67
  ) {
47
- super('marketplace', 'Marketplace', 'M', 'monitoring');
68
+ super('marketplace', 'Marketplace', '', 'automation-control');
69
+ this.filterEnabled = true;
70
+ this.filterLabel = 'Filter marketplace';
48
71
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
49
72
  }
50
73
 
74
+ protected override filterMatches(row: MarketplaceRow, q: string): boolean {
75
+ return row.kind.toLowerCase().includes(q)
76
+ || row.entry.name.toLowerCase().includes(q)
77
+ || (row.entry.provenance ?? 'local').toLowerCase().includes(q);
78
+ }
79
+
51
80
  public override onDestroy(): void {
52
81
  this.unsub?.();
53
82
  }
54
83
 
84
+ // Disk reload happens here (activate) and on explicit 'r' refresh — never
85
+ // inside render(), which used to reload all four catalogs every frame.
55
86
  public override onActivate(): void {
56
87
  super.onActivate();
57
88
  this.refresh();
58
89
  }
59
90
 
91
+ protected override onSelect(row: MarketplaceRow): void {
92
+ // Enter toggles the full review-detail block for the selected entry.
93
+ this.expandedEntryId = this.expandedEntryId === row.entry.id ? null : row.entry.id;
94
+ this.markDirty();
95
+ }
96
+
60
97
  // ---------------------------------------------------------------------------
61
98
  // ScrollableListPanel implementation
62
99
  // ---------------------------------------------------------------------------
@@ -71,8 +108,8 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
71
108
  return buildPanelLine(width, [
72
109
  [' ', C.label, bg],
73
110
  [row.kind.padEnd(11), C.info, bg],
74
- [row.entry.name.slice(0, 20).padEnd(20), C.value, bg],
75
- [` ${provenance.slice(0, 16).padEnd(16)}`, provenance === 'local' ? C.dim : C.info, bg],
111
+ [fitDisplay(row.entry.name, 20), C.value, bg],
112
+ [` ${fitDisplay(provenance, 16)}`, provenance === 'local' ? C.dim : C.info, bg],
76
113
  [` ${(row.installed ? 'INSTALLED' : 'CURATED').padEnd(9)} `, statusColor(row.installed), bg],
77
114
  [` ${row.entry.version ?? 'n/a'}`, C.dim, bg],
78
115
  ]);
@@ -92,22 +129,118 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
92
129
  ];
93
130
  }
94
131
 
132
+ public override handleInput(key: string): boolean {
133
+ if (this.lastError !== null) this.clearError();
134
+
135
+ const confirmResult = handleConfirmInput(this.confirm, key);
136
+ if (confirmResult === 'confirmed') {
137
+ const subject = this.confirm!.subject;
138
+ this.confirm = null;
139
+ this._applyConfirmedAction(subject);
140
+ this.markDirty();
141
+ return true;
142
+ }
143
+ if (confirmResult === 'cancelled') {
144
+ this.confirm = null;
145
+ this.markDirty();
146
+ return true;
147
+ }
148
+ if (confirmResult === 'absorbed') return true;
149
+
150
+ if (!this.filterActive) {
151
+ if (key === 'r') {
152
+ this.refresh();
153
+ this.markDirty();
154
+ return true;
155
+ }
156
+
157
+ // selectedIndex is relative to the filtered view, so the acted-on row
158
+ // must come from getVisibleItems() — this.rows would desync under '/'.
159
+ const selectedRow = this.getSelectedItem();
160
+ if (key === 'i' && selectedRow && !selectedRow.installed && this.ecosystemPaths) {
161
+ this.confirm = {
162
+ subject: { kind: selectedRow.kind, entryId: selectedRow.entry.id, action: 'install' },
163
+ label: `${selectedRow.kind} ${selectedRow.entry.name}`,
164
+ verb: 'Install',
165
+ };
166
+ this.markDirty();
167
+ return true;
168
+ }
169
+ if (key === 'u' && selectedRow && selectedRow.installed && this.ecosystemPaths) {
170
+ this.confirm = {
171
+ subject: { kind: selectedRow.kind, entryId: selectedRow.entry.id, action: 'uninstall' },
172
+ label: `${selectedRow.kind} ${selectedRow.entry.name}`,
173
+ verb: 'Uninstall',
174
+ };
175
+ this.markDirty();
176
+ return true;
177
+ }
178
+
179
+ // Recommendations become actionable jumps: pressing the digit shown next
180
+ // to a recommendation row jumps the selection to (and expands) that
181
+ // catalog entry, rather than only printing a static suggestion.
182
+ if (/^[1-9]$/.test(key)) {
183
+ const recommendations = this.readModel?.getSnapshot()?.recommendations ?? [];
184
+ const recommendation = recommendations[Number(key) - 1];
185
+ if (recommendation) {
186
+ // Search the visible (possibly filtered) list: selectedIndex is
187
+ // visible-relative, so a this.rows index would land on the wrong row.
188
+ const targetIndex = this.getVisibleItems().findIndex((row) => row.kind === recommendation.kind && row.entry.id === recommendation.entry.id);
189
+ if (targetIndex >= 0) {
190
+ this.selectedIndex = targetIndex;
191
+ this.expandedEntryId = recommendation.entry.id;
192
+ this.markDirty();
193
+ return true;
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ return super.handleInput(key);
200
+ }
201
+
202
+ private _applyConfirmedAction(subject: MarketplaceConfirmSubject): void {
203
+ if (!this.ecosystemPaths) return;
204
+ const verb = subject.action === 'install' ? 'Install' : 'Uninstall';
205
+ try {
206
+ const result = subject.action === 'install'
207
+ ? installEcosystemCatalogEntry(subject.kind, subject.entryId, this.ecosystemPaths)
208
+ : uninstallEcosystemCatalogEntry(subject.kind, subject.entryId, this.ecosystemPaths);
209
+ if (!result.ok) {
210
+ this.setError(`${verb} failed: ${result.error}`);
211
+ return;
212
+ }
213
+ this.clearError();
214
+ this.refresh();
215
+ } catch (e) {
216
+ this.setError(`${verb} failed: ${summarizeError(e)}`);
217
+ }
218
+ }
219
+
95
220
  private refresh(): void {
96
221
  if (!this.ecosystemPaths) {
97
222
  this.rows = [];
98
223
  this.clampSelection();
99
224
  return;
100
225
  }
226
+ const ecosystemPaths = this.ecosystemPaths;
101
227
  try {
102
- const installedPlugins = new Set(listInstalledEcosystemEntries('plugin', this.ecosystemPaths).map((receipt) => receipt.entry.id));
103
- const installedSkills = new Set(listInstalledEcosystemEntries('skill', this.ecosystemPaths).map((receipt) => receipt.entry.id));
104
- const installedHookPacks = new Set(listInstalledEcosystemEntries('hook-pack', this.ecosystemPaths).map((receipt) => receipt.entry.id));
105
- const installedPolicyPacks = new Set(listInstalledEcosystemEntries('policy-pack', this.ecosystemPaths).map((receipt) => receipt.entry.id));
228
+ const installedPlugins = new Set(listInstalledEcosystemEntries('plugin', ecosystemPaths).map((receipt) => receipt.entry.id));
229
+ const installedSkills = new Set(listInstalledEcosystemEntries('skill', ecosystemPaths).map((receipt) => receipt.entry.id));
230
+ const installedHookPacks = new Set(listInstalledEcosystemEntries('hook-pack', ecosystemPaths).map((receipt) => receipt.entry.id));
231
+ const installedPolicyPacks = new Set(listInstalledEcosystemEntries('policy-pack', ecosystemPaths).map((receipt) => receipt.entry.id));
232
+ const build = (kind: EcosystemEntryKind, installed: Set<string>): MarketplaceRow[] =>
233
+ loadEcosystemCatalog(kind, ecosystemPaths).map((entry) => ({
234
+ kind,
235
+ entry,
236
+ installed: installed.has(entry.id),
237
+ review: reviewEcosystemCatalogEntry(entry, ecosystemPaths),
238
+ }));
106
239
  const rows: MarketplaceRow[] = [
107
- ...loadEcosystemCatalog('plugin', this.ecosystemPaths).map((entry) => ({ kind: 'plugin' as const, entry, installed: installedPlugins.has(entry.id) })),
108
- ...loadEcosystemCatalog('skill', this.ecosystemPaths).map((entry) => ({ kind: 'skill' as const, entry, installed: installedSkills.has(entry.id) })),
109
- ...loadEcosystemCatalog('hook-pack', this.ecosystemPaths).map((entry) => ({ kind: 'hook-pack' as const, entry, installed: installedHookPacks.has(entry.id) })),
110
- ...loadEcosystemCatalog('policy-pack', this.ecosystemPaths).map((entry) => ({ kind: 'policy-pack' as const, entry, installed: installedPolicyPacks.has(entry.id) })),
240
+ ...build('plugin', installedPlugins),
241
+ ...build('skill', installedSkills),
242
+ ...build('hook-pack', installedHookPacks),
243
+ ...build('policy-pack', installedPolicyPacks),
111
244
  ];
112
245
  this.rows = rows.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
113
246
  this.clampSelection();
@@ -121,7 +254,16 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
121
254
 
122
255
  public render(width: number, height: number): Line[] {
123
256
  this.clampSelection();
124
- this.refresh();
257
+
258
+ if (this.confirm) {
259
+ const lines = buildPanelWorkspace(width, height, {
260
+ title: 'Marketplace Control Room',
261
+ sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
262
+ palette: C,
263
+ });
264
+ while (lines.length < height) lines.push(createEmptyLine(width));
265
+ return lines.slice(0, height);
266
+ }
125
267
 
126
268
  const intro = 'Curated local-first ecosystem with provenance, compatibility, rollback history, and receipt-aware lifecycle review.';
127
269
  const installedCount = this.rows.filter((row) => row.installed).length;
@@ -161,44 +303,88 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
161
303
  { label: 'hooks', value: String(this.rows.filter((row) => row.kind === 'hook-pack').length), valueColor: C.info },
162
304
  { label: 'policies', value: String(this.rows.filter((row) => row.kind === 'policy-pack').length), valueColor: C.info },
163
305
  ], C),
164
- buildGuidanceLine(width, '/marketplace open', 'browse curated entries and inspect compatibility, provenance, and receipts', C),
165
306
  ];
166
307
 
308
+ // Recommendations become actionable jumps: each row is numbered and the
309
+ // matching digit key jumps the main selection straight to that entry
310
+ // (see handleInput) instead of only printing a static suggestion.
167
311
  const recommendationLines = recommendations.length > 0
168
- ? recommendations.slice(0, 4).map((recommendation) => buildPanelLine(width, [
169
- [' ', C.label],
170
- [`${recommendation.kind} ${recommendation.entry.id}`.slice(0, 28).padEnd(28), C.info],
171
- [` ${recommendation.title}`.slice(0, Math.max(0, width - 31)), C.dim],
312
+ ? recommendations.slice(0, 4).map((recommendation, index) => buildPanelLine(width, [
313
+ [` [${index + 1}] `, C.info],
314
+ [fitDisplay(`${recommendation.kind} ${recommendation.entry.id}`, 26), C.info],
315
+ [truncateDisplay(` ${recommendation.title}`, Math.max(0, width - 33)), C.dim],
172
316
  ]))
173
317
  : [buildPanelLine(width, [[' No contextual marketplace recommendations right now.', C.dim]])];
174
318
 
175
319
  const startupIssueLines = startupIssues.length > 0
176
- ? startupIssues.slice(0, 4).map((issue) => buildPanelLine(width, [[' ', C.label], [issue.slice(0, Math.max(0, width - 2)), C.warn]]))
320
+ ? startupIssues.slice(0, 4).map((issue) => buildPanelLine(width, [[' ', C.label], [truncateDisplay(issue, Math.max(0, width - 2)), C.warn]]))
177
321
  : [buildPanelLine(width, [[' No startup or lifecycle issues are currently pushing marketplace repair recommendations.', C.dim]])];
178
322
 
179
- const selectedRow = this.rows[this.selectedIndex];
323
+ const selectedRow = this.getSelectedItem();
180
324
  const selectedLines: Line[] = [];
181
325
  if (selectedRow) {
182
- const review = reviewEcosystemCatalogEntry(selectedRow.entry, this.ecosystemPaths!);
326
+ // review is computed once in refresh() (not here) so render() never
327
+ // touches disk.
328
+ const review = selectedRow.review;
183
329
  selectedLines.push(buildPanelLine(width, [
184
330
  [' Provenance: ', C.label],
185
- [(selectedRow.entry.provenance ?? '(none)').slice(0, Math.max(0, width - 15)), selectedRow.entry.provenance ? C.info : C.dim],
331
+ [truncateDisplay(selectedRow.entry.provenance ?? '(none)', Math.max(0, width - 15)), selectedRow.entry.provenance ? C.info : C.dim],
186
332
  ]));
187
333
  selectedLines.push(buildPanelLine(width, [
188
334
  [' Source: ', C.label],
189
- [selectedRow.entry.source.slice(0, Math.max(0, width - 11)), C.value],
335
+ [truncateDisplay(selectedRow.entry.source, Math.max(0, width - 11)), C.value],
190
336
  ]));
191
337
  selectedLines.push(buildKeyValueLine(width, [
192
338
  { label: 'Compatibility', value: review.compatibility.status, valueColor: review.compatibility.status === 'supported' ? C.good : C.warn },
193
339
  { label: 'Risk', value: review.riskLevel, valueColor: review.riskLevel === 'low' ? C.good : C.warn },
194
340
  { label: 'State', value: selectedRow.installed ? 'installed' : 'curated', valueColor: statusColor(selectedRow.installed) },
195
341
  ], C));
196
- selectedLines.push(buildGuidanceLine(width, '/marketplace review <id>', 'inspect full compatibility and receipt detail for the selected entry', C));
342
+
343
+ if (this.expandedEntryId === selectedRow.entry.id) {
344
+ // Enter = full review detail: every field reviewEcosystemCatalogEntry
345
+ // returns, not just the compact compatibility/risk/state summary above.
346
+ selectedLines.push(buildPanelLine(width, [
347
+ [' Source path: ', C.label],
348
+ [truncateDisplay(review.sourcePath, Math.max(0, width - 16)), review.sourceExists ? C.good : C.warn],
349
+ ]));
350
+ selectedLines.push(buildKeyValueLine(width, [
351
+ { label: 'Source kind', value: review.sourceKind, valueColor: C.info },
352
+ { label: 'Source exists', value: review.sourceExists ? 'yes' : 'no', valueColor: review.sourceExists ? C.good : C.warn },
353
+ { label: 'Recommended scope', value: review.recommendedScope, valueColor: C.info },
354
+ ], C));
355
+ selectedLines.push(buildPanelLine(width, [
356
+ [' Runtime fit: ', C.label],
357
+ [review.runtimeFit.status, review.runtimeFit.status === 'supported' ? C.good : C.warn],
358
+ [review.runtimeFit.reasons.length > 0 ? ` (${review.runtimeFit.reasons.join('; ')})` : '', C.dim],
359
+ ]));
360
+ if (review.compatibility.reasons.length > 0) {
361
+ selectedLines.push(buildPanelLine(width, [
362
+ [' Compatibility notes: ', C.label],
363
+ [truncateDisplay(review.compatibility.reasons.join('; '), Math.max(0, width - 24)), C.warn],
364
+ ]));
365
+ }
366
+ } else {
367
+ selectedLines.push(buildGuidanceLine(width, 'Enter', 'expand full compatibility, receipt, and runtime-fit detail for the selected entry', C));
368
+ }
197
369
  }
198
370
 
199
- const postureSection: PanelWorkspaceSection = { title: 'Marketplace posture', lines: postureLines };
200
- const startupIssuesSection: PanelWorkspaceSection = { title: 'Startup Issues', lines: startupIssueLines };
201
- const recommendationsSection: PanelWorkspaceSection = { title: 'Recommendations', lines: recommendationLines };
371
+ // Context-aware hints: filter mode vs. browse mode (install/uninstall only
372
+ // make sense given the selected entry's current install state).
373
+ const hints = this.filterActive
374
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
375
+ : [
376
+ { keys: 'Up/Down', label: 'move' },
377
+ { keys: 'Enter', label: 'detail' },
378
+ ...(selectedRow && !selectedRow.installed ? [{ keys: 'i', label: 'install' }] : []),
379
+ ...(selectedRow && selectedRow.installed ? [{ keys: 'u', label: 'uninstall' }] : []),
380
+ ...(recommendations.length > 0 ? [{ keys: '1-9', label: 'jump to recommendation' }] : []),
381
+ { keys: 'r', label: 'refresh' },
382
+ { keys: '/', label: 'filter' },
383
+ ];
384
+
385
+ const footer: Line[] = selectedLines.length > 0 && height >= 20
386
+ ? [...selectedLines, buildKeyboardHints(width, hints, C)]
387
+ : [buildKeyboardHints(width, hints, C)];
202
388
 
203
389
  return this.renderList(width, height, {
204
390
  title: 'Marketplace Control Room',
@@ -207,7 +393,7 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
207
393
  ...startupIssueLines,
208
394
  ...recommendationLines,
209
395
  ],
210
- footer: selectedLines.length > 0 && height >= 20 ? selectedLines : [],
396
+ footer,
211
397
  });
212
398
  }
213
399
  }