@pellux/goodvibes-tui 0.28.0 → 1.0.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 (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,6 +1,8 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import { createEmptyLine } from '../types/grid.ts';
2
3
  import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
4
6
  import {
5
7
  buildEmptyState,
6
8
  buildGuidanceLine,
@@ -12,27 +14,41 @@ import {
12
14
  } from './polish.ts';
13
15
  import {
14
16
  type EcosystemCatalogPathOptions,
17
+ installEcosystemCatalogEntry,
15
18
  listInstalledEcosystemEntries,
16
19
  loadEcosystemCatalog,
17
20
  reviewEcosystemCatalogEntry,
21
+ uninstallEcosystemCatalogEntry,
18
22
  type EcosystemCatalogEntry,
19
23
  type EcosystemEntryKind,
20
24
  } from '@/runtime/index.ts';
21
25
  import type { UiMarketplaceSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
22
26
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
27
 
24
- const C = {
25
- ...DEFAULT_PANEL_PALETTE,
26
- header: '#e2e8f0',
27
- headerBg: '#1f2937',
28
- } 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>;
29
33
 
30
34
  type MarketplaceRow = {
31
35
  kind: EcosystemEntryKind;
32
36
  entry: EcosystemCatalogEntry;
33
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;
34
43
  };
35
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
+
36
52
  function statusColor(installed: boolean): string {
37
53
  return installed ? C.good : C.dim;
38
54
  }
@@ -40,12 +56,16 @@ function statusColor(installed: boolean): string {
40
56
  export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
41
57
  private rows: MarketplaceRow[] = [];
42
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;
43
63
 
44
64
  public constructor(
45
65
  private readonly readModel?: UiReadModel<UiMarketplaceSnapshot>,
46
66
  private readonly ecosystemPaths?: EcosystemCatalogPathOptions,
47
67
  ) {
48
- super('marketplace', 'Marketplace', 'M', 'monitoring');
68
+ super('marketplace', 'Marketplace', '', 'automation-control');
49
69
  this.filterEnabled = true;
50
70
  this.filterLabel = 'Filter marketplace';
51
71
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
@@ -61,11 +81,19 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
61
81
  this.unsub?.();
62
82
  }
63
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.
64
86
  public override onActivate(): void {
65
87
  super.onActivate();
66
88
  this.refresh();
67
89
  }
68
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
+
69
97
  // ---------------------------------------------------------------------------
70
98
  // ScrollableListPanel implementation
71
99
  // ---------------------------------------------------------------------------
@@ -101,22 +129,118 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
101
129
  ];
102
130
  }
103
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
+
104
220
  private refresh(): void {
105
221
  if (!this.ecosystemPaths) {
106
222
  this.rows = [];
107
223
  this.clampSelection();
108
224
  return;
109
225
  }
226
+ const ecosystemPaths = this.ecosystemPaths;
110
227
  try {
111
- const installedPlugins = new Set(listInstalledEcosystemEntries('plugin', this.ecosystemPaths).map((receipt) => receipt.entry.id));
112
- const installedSkills = new Set(listInstalledEcosystemEntries('skill', this.ecosystemPaths).map((receipt) => receipt.entry.id));
113
- const installedHookPacks = new Set(listInstalledEcosystemEntries('hook-pack', this.ecosystemPaths).map((receipt) => receipt.entry.id));
114
- 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
+ }));
115
239
  const rows: MarketplaceRow[] = [
116
- ...loadEcosystemCatalog('plugin', this.ecosystemPaths).map((entry) => ({ kind: 'plugin' as const, entry, installed: installedPlugins.has(entry.id) })),
117
- ...loadEcosystemCatalog('skill', this.ecosystemPaths).map((entry) => ({ kind: 'skill' as const, entry, installed: installedSkills.has(entry.id) })),
118
- ...loadEcosystemCatalog('hook-pack', this.ecosystemPaths).map((entry) => ({ kind: 'hook-pack' as const, entry, installed: installedHookPacks.has(entry.id) })),
119
- ...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),
120
244
  ];
121
245
  this.rows = rows.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
122
246
  this.clampSelection();
@@ -130,7 +254,16 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
130
254
 
131
255
  public render(width: number, height: number): Line[] {
132
256
  this.clampSelection();
133
- 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
+ }
134
267
 
135
268
  const intro = 'Curated local-first ecosystem with provenance, compatibility, rollback history, and receipt-aware lifecycle review.';
136
269
  const installedCount = this.rows.filter((row) => row.installed).length;
@@ -170,14 +303,16 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
170
303
  { label: 'hooks', value: String(this.rows.filter((row) => row.kind === 'hook-pack').length), valueColor: C.info },
171
304
  { label: 'policies', value: String(this.rows.filter((row) => row.kind === 'policy-pack').length), valueColor: C.info },
172
305
  ], C),
173
- buildGuidanceLine(width, '/marketplace open', 'browse curated entries and inspect compatibility, provenance, and receipts', C),
174
306
  ];
175
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.
176
311
  const recommendationLines = recommendations.length > 0
177
- ? recommendations.slice(0, 4).map((recommendation) => buildPanelLine(width, [
178
- [' ', C.label],
179
- [fitDisplay(`${recommendation.kind} ${recommendation.entry.id}`, 28), C.info],
180
- [truncateDisplay(` ${recommendation.title}`, 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],
181
316
  ]))
182
317
  : [buildPanelLine(width, [[' No contextual marketplace recommendations right now.', C.dim]])];
183
318
 
@@ -185,10 +320,12 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
185
320
  ? startupIssues.slice(0, 4).map((issue) => buildPanelLine(width, [[' ', C.label], [truncateDisplay(issue, Math.max(0, width - 2)), C.warn]]))
186
321
  : [buildPanelLine(width, [[' No startup or lifecycle issues are currently pushing marketplace repair recommendations.', C.dim]])];
187
322
 
188
- const selectedRow = this.rows[this.selectedIndex];
323
+ const selectedRow = this.getSelectedItem();
189
324
  const selectedLines: Line[] = [];
190
325
  if (selectedRow) {
191
- 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;
192
329
  selectedLines.push(buildPanelLine(width, [
193
330
  [' Provenance: ', C.label],
194
331
  [truncateDisplay(selectedRow.entry.provenance ?? '(none)', Math.max(0, width - 15)), selectedRow.entry.provenance ? C.info : C.dim],
@@ -202,16 +339,46 @@ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
202
339
  { label: 'Risk', value: review.riskLevel, valueColor: review.riskLevel === 'low' ? C.good : C.warn },
203
340
  { label: 'State', value: selectedRow.installed ? 'installed' : 'curated', valueColor: statusColor(selectedRow.installed) },
204
341
  ], C));
205
- 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
+ }
206
369
  }
207
370
 
208
- // Context-aware hints: filter mode vs. browse mode (install only makes sense
209
- // when a curated, not-yet-installed entry is selected).
371
+ // Context-aware hints: filter mode vs. browse mode (install/uninstall only
372
+ // make sense given the selected entry's current install state).
210
373
  const hints = this.filterActive
211
374
  ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
212
375
  : [
213
376
  { keys: 'Up/Down', label: 'move' },
214
- ...(selectedRow && !selectedRow.installed ? [{ keys: '/marketplace install', label: 'add' }] : []),
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' },
215
382
  { keys: '/', label: 'filter' },
216
383
  ];
217
384
 
@@ -13,31 +13,26 @@
13
13
  import type { Line } from '../types/grid.ts';
14
14
  import type { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
15
15
  import type { MemoryClass, MemoryRecord, MemoryReviewState } from '@pellux/goodvibes-sdk/platform/state';
16
- import { ScrollableListPanel, SearchableListPanel } from './scrollable-list-panel.ts';
16
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
17
17
  import { truncateDisplay } from '../utils/terminal-width.ts';
18
18
  import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
19
19
  import {
20
20
  buildBodyText,
21
- buildGuidanceLine,
22
21
  buildKeyValueLine,
23
22
  buildPanelLine,
24
23
  buildPanelWorkspace,
25
24
  extendPalette,
26
25
  DEFAULT_PANEL_PALETTE,
27
26
  } from './polish.ts';
28
- import {
29
- getPanelSearchFocusTransition,
30
- isPanelSearchCancel,
31
- } from './search-focus.ts';
32
27
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
33
28
 
34
29
  // ---------------------------------------------------------------------------
35
30
  // Colour palette
36
31
  // ---------------------------------------------------------------------------
37
32
 
33
+ // Domain accents only — one hue per memory class (base chrome: header/
34
+ // headerBg/selectBg come from DEFAULT_PANEL_PALETTE).
38
35
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
39
- header: '#94a3b8',
40
- headerBg: '#1e293b',
41
36
  decision: '#38bdf8',
42
37
  constraint: '#f97316',
43
38
  incident: '#ef4444',
@@ -47,9 +42,6 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
47
42
  runbook: '#eab308',
48
43
  architecture: '#60a5fa',
49
44
  ownership: '#14b8a6',
50
- selected: '#1e3a5f',
51
- searchBg: '#0f172a',
52
- searchFg: '#e2e8f0',
53
45
  });
54
46
 
55
47
  // ---------------------------------------------------------------------------
@@ -104,14 +96,13 @@ function formatConfidence(confidence: number): string {
104
96
  // MemoryPanel
105
97
  // ---------------------------------------------------------------------------
106
98
 
107
- export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
99
+ export class MemoryPanel extends ScrollableListPanel<MemoryRecord> {
108
100
  private readonly registry: MemoryRegistry;
109
101
  private filterMode: FilterMode = 'all';
110
- private filterFocused = false;
111
102
  private unsubscribe?: () => void;
112
103
 
113
- // Review-mode confirm state (for destructive stale/contradict actions)
114
- private confirm: ConfirmState<{ id: string; action: 'stale' | 'contradicted' }> | null = null;
104
+ // Confirm state (for destructive stale/contradict/delete actions)
105
+ private confirm: ConfirmState<{ id: string; action: 'stale' | 'contradicted' | 'delete' }> | null = null;
115
106
 
116
107
  // Cached records for review-mode (reviewQueue-first, same as former KnowledgePanel)
117
108
  private reviewRecords: MemoryRecord[] = [];
@@ -119,6 +110,10 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
119
110
  constructor(registry: MemoryRegistry) {
120
111
  super('memory', 'Memory', 'M', 'agent');
121
112
  this.registry = registry;
113
+ // WO-153: converged modal '/' filter — only active in 'all' mode; review
114
+ // mode uses r/s/c/f/d single-letter actions instead of text filtering.
115
+ this.filterEnabled = true;
116
+ this.filterLabel = 'Search';
122
117
  }
123
118
 
124
119
  // ---------------------------------------------------------------------------
@@ -127,13 +122,11 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
127
122
 
128
123
  override onActivate(): void {
129
124
  super.onActivate();
130
- this.searchQuery = '';
131
- this.invalidateFilter();
132
- this.filterFocused = false;
125
+ this.filterQuery = '';
126
+ this.filterActive = false;
133
127
  this.confirm = null;
134
128
  this.refreshReviewRecords();
135
129
  this.unsubscribe = this.registry.subscribe(() => {
136
- this.invalidateFilter();
137
130
  this.refreshReviewRecords();
138
131
  this.markDirty();
139
132
  });
@@ -149,16 +142,11 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
149
142
  }
150
143
 
151
144
  // ---------------------------------------------------------------------------
152
- // SearchableListPanel implementation (used in 'all' filter mode)
145
+ // ScrollableListPanel implementation (filter is only enabled in 'all' mode)
153
146
  // ---------------------------------------------------------------------------
154
147
 
155
- protected getAllItems(): readonly MemoryRecord[] {
156
- return this.registry.search({ limit: 100 });
157
- }
158
-
159
- protected matchesSearch(record: MemoryRecord, query: string): boolean {
160
- const q = query.trim().toLowerCase();
161
- if (!q) return true;
148
+ /** `q` arrives already trimmed + lower-cased from ScrollableListPanel.getVisibleItems(). */
149
+ protected override filterMatches(record: MemoryRecord, q: string): boolean {
162
150
  const haystack = [
163
151
  record.summary,
164
152
  record.detail ?? '',
@@ -181,7 +169,7 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
181
169
  ]);
182
170
  }
183
171
  // All-mode row: scope/class + id + time + summary (matches former MemoryPanel row)
184
- const bg = selected ? C.selected : undefined;
172
+ const bg = selected ? C.selectBg : undefined;
185
173
  return buildPanelLine(width, [
186
174
  [' ', C.label, bg],
187
175
  [`[${record.scope.slice(0, 1).toUpperCase()}/${record.cls.slice(0, 3).toUpperCase()}] `, classColor(record.cls), bg],
@@ -197,9 +185,9 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
197
185
 
198
186
  protected override getEmptyStateMessage() {
199
187
  if (this.filterMode === 'review') return 'No records in the review queue.';
200
- return this.searchQuery
201
- ? ` No records matching "${this.searchQuery}"`
202
- : ' No memory records. Use /recall add <class> <summary> to create one.';
188
+ // The filter-narrowed-to-empty case is handled by ScrollableListPanel's
189
+ // own 'No matches for "..."' message (renderList) before this is reached.
190
+ return ' No memory records. Use /recall add <class> <summary> to create one.';
203
191
  }
204
192
 
205
193
  protected override getEmptyStateActions() {
@@ -215,14 +203,15 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
215
203
  // ---------------------------------------------------------------------------
216
204
 
217
205
  /**
218
- * Override getItems() so that ScrollableListPanel infrastructure (renderList,
219
- * clampSelection, navigation bounds) all operate on reviewRecords in review
220
- * mode preventing the render/action index desync where rendered list index N
221
- * did not correspond to reviewRecords[N] when the two lists differ.
206
+ * getItems() so that ScrollableListPanel infrastructure (renderList,
207
+ * clampSelection, navigation bounds, and — in 'all' mode the modal '/'
208
+ * filter) all operate on the right data set per mode preventing the
209
+ * render/action index desync where rendered list index N did not
210
+ * correspond to reviewRecords[N] when the two lists differ.
222
211
  */
223
- protected override getItems(): readonly MemoryRecord[] {
212
+ protected getItems(): readonly MemoryRecord[] {
224
213
  if (this.filterMode === 'review') return this.reviewRecords;
225
- return super.getItems();
214
+ return this.registry.search({ limit: 100 });
226
215
  }
227
216
 
228
217
  private refreshReviewRecords(): void {
@@ -230,11 +219,19 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
230
219
  this.clampSelection();
231
220
  }
232
221
 
222
+ /** Count of records beyond the 100-record fetch cap in getItems(). */
223
+ private getHiddenCount(): number {
224
+ const total = this.registry.search({}).length;
225
+ return Math.max(0, total - 100);
226
+ }
227
+
233
228
  private cycleFilter(): void {
234
229
  const modes: FilterMode[] = ['all', 'review'];
235
230
  const next = modes[(modes.indexOf(this.filterMode) + 1) % modes.length];
236
231
  this.filterMode = next;
237
- this.invalidateFilter();
232
+ // The modal '/' filter only applies in 'all' mode — review mode uses
233
+ // r/s/c/f/d single-letter actions instead of text filtering.
234
+ this.filterEnabled = next === 'all';
238
235
  this.refreshReviewRecords();
239
236
  this.markDirty();
240
237
  }
@@ -250,6 +247,16 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
250
247
  if (result === 'confirmed') {
251
248
  const { id, action } = this.confirm.subject;
252
249
  this.confirm = null;
250
+ if (action === 'delete') {
251
+ try {
252
+ this.registry.delete(id);
253
+ } catch (e) {
254
+ this.setError(`Delete failed: ${summarizeError(e)}`);
255
+ }
256
+ this.refreshReviewRecords();
257
+ this.markDirty();
258
+ return true;
259
+ }
253
260
  const record = this.reviewRecords.find((r) => r.id === id);
254
261
  if (record) {
255
262
  try {
@@ -292,7 +299,9 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
292
299
 
293
300
  // Review-mode specific actions (r/s/c/f)
294
301
  if (this.filterMode === 'review') {
295
- const selected = this.reviewRecords[this.selectedIndex];
302
+ // In review mode getItems() returns reviewRecords and filterEnabled is
303
+ // false, so getSelectedItem() resolves the highlighted review record.
304
+ const selected = this.getSelectedItem();
296
305
 
297
306
  if (key === 'enter' || key === 'return' || key === 'r') {
298
307
  if (!selected) return false;
@@ -328,46 +337,35 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
328
337
  this.markDirty();
329
338
  return true;
330
339
  }
331
- }
332
-
333
- // All-mode: search filter focus
334
- if (this.filterMode === 'all') {
335
- if (this.filterFocused) {
336
- const items = this.getItems();
337
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
338
- if (transition === 'focus-list') {
339
- this.filterFocused = false;
340
- this.markDirty();
341
- return true;
342
- }
343
- if (isPanelSearchCancel(key)) {
344
- this.filterFocused = false;
345
- return super.handleInput(key);
346
- }
347
- return super.handleInput(key);
340
+ if (key === 'd') {
341
+ if (!selected) return false;
342
+ this.confirm = { subject: { id: selected.id, action: 'delete' }, label: truncateDisplay(selected.summary, 40) };
343
+ this.markDirty();
344
+ return true;
348
345
  }
346
+ }
349
347
 
350
- const items = this.getItems();
351
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
352
- if (transition === 'focus-search') {
353
- this.filterFocused = true;
348
+ // All-mode: 'r' reload and 'd' delete are single-letter actions that
349
+ // coexist with the modal '/' filter they only fire outside filter
350
+ // mode, so they remain typeable into the filter query while it's active.
351
+ if (this.filterMode === 'all' && !this.filterActive) {
352
+ if (key === 'r') {
354
353
  this.markDirty();
355
354
  return true;
356
355
  }
357
356
 
358
- if (key === 'r') {
359
- this.invalidateFilter();
357
+ if (key === 'd') {
358
+ const selected = this.getSelectedItem();
359
+ if (!selected) return false;
360
+ this.confirm = { subject: { id: selected.id, action: 'delete' }, label: truncateDisplay(selected.summary, 40) };
360
361
  this.markDirty();
361
362
  return true;
362
363
  }
363
364
  }
364
365
 
365
- // In review mode, navigation keys (j/k/up/down/etc.) must bypass
366
- // SearchableListPanel's printable-character interception, which would
367
- // otherwise swallow single-char keys like 'j' and 'k' as search input.
368
- if (this.filterMode === 'review') {
369
- return ScrollableListPanel.prototype.handleInput.call(this, key);
370
- }
366
+ // Navigation + (in 'all' mode) the modal '/' filter: delegate to
367
+ // ScrollableListPanel. filterEnabled is false in review mode, so '/' is
368
+ // inert there and single-char keys like 'j'/'k' navigate normally.
371
369
  return super.handleInput(key);
372
370
  }
373
371
 
@@ -403,13 +401,16 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
403
401
  }
404
402
 
405
403
  private renderAllMode(width: number, height: number, filterToggleLine: Line): Line[] {
406
- const records = this.getItems();
404
+ // getVisibleItems() applies the modal '/' filter (getItems() is the
405
+ // unfiltered 100-cap fetch); the filter input line itself is
406
+ // auto-injected by renderList() below (filterEnabled=true in 'all' mode).
407
+ const records = this.getVisibleItems();
407
408
  const byClass = new Map<MemoryClass, number>();
408
409
  for (const record of records) {
409
410
  byClass.set(record.cls, (byClass.get(record.cls) ?? 0) + 1);
410
411
  }
411
412
 
412
- const filterInputLine = this.buildFilterInputLine(width, 'Search', this.filterFocused);
413
+ const hiddenCount = this.getHiddenCount();
413
414
 
414
415
  const summaryLines: Line[] = [
415
416
  buildKeyValueLine(width, [
@@ -420,11 +421,12 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
420
421
  { label: 'runbooks', value: String(byClass.get('runbook') ?? 0), valueColor: C.runbook },
421
422
  ], C),
422
423
  filterToggleLine,
423
- filterInputLine,
424
- buildGuidanceLine(width, '/recall review', 'review durable knowledge and queue posture from the command surface', C),
424
+ ...(hiddenCount > 0
425
+ ? [buildPanelLine(width, [[` +${hiddenCount} hidden (100-record cap)`, C.dim]])]
426
+ : []),
425
427
  ];
426
428
 
427
- const selected = records[this.selectedIndex];
429
+ const selected = this.getSelectedItem();
428
430
  const selectedLines: Line[] = [];
429
431
  if (selected) {
430
432
  selectedLines.push(buildKeyValueLine(width, [
@@ -450,7 +452,7 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
450
452
  header: summaryLines,
451
453
  footer: [
452
454
  ...selectedLines,
453
- buildPanelLine(width, [[' / search j/k or Up/Down move r reload Tab: Review Queue Esc clear search', C.dim]]),
455
+ buildPanelLine(width, [[' / search j/k or Up/Down move r reload d delete Tab: Review Queue Esc clear search', C.dim]]),
454
456
  ],
455
457
  });
456
458
  }
@@ -500,10 +502,9 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
500
502
  [' team ', C.label], [String(byScope.get('team') ?? 0), C.good],
501
503
  ]),
502
504
  filterToggleLine,
503
- buildGuidanceLine(width, '/recall review', 'work the stale and contradicted queue from the command surface', C),
504
505
  ];
505
506
 
506
- const selectedRecord = this.reviewRecords[this.selectedIndex];
507
+ const selectedRecord = this.getSelectedItem();
507
508
  const selectedLines: Line[] = [];
508
509
  if (selectedRecord) {
509
510
  selectedLines.push(buildPanelLine(width, [[' Selected', C.label]]));
@@ -550,7 +551,7 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
550
551
  header: [...classLines, ...reviewLines],
551
552
  footer: [
552
553
  ...(selectedLines.length > 0 ? selectedLines : []),
553
- buildPanelLine(width, [[' Tab: All Records Up/Down move r/Enter reviewed s stale c contradicted f fresh', C.dim]]),
554
+ buildPanelLine(width, [[' Tab: All Records Up/Down move r/Enter reviewed s stale c contradicted f fresh d delete', C.dim]]),
554
555
  ],
555
556
  });
556
557
  }