@pellux/goodvibes-tui 0.18.23 → 0.19.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 (76) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +8 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/shell-core.ts +3 -2
  13. package/src/input/commands/skills-runtime.ts +2 -2
  14. package/src/input/commands/subscription-runtime.ts +4 -4
  15. package/src/input/handler.ts +8 -10
  16. package/src/input/panel-integration-actions.ts +2 -1
  17. package/src/input/settings-modal-types.ts +60 -0
  18. package/src/input/settings-modal.ts +83 -65
  19. package/src/panels/agent-inspector-panel.ts +10 -9
  20. package/src/panels/agent-logs-panel.ts +26 -6
  21. package/src/panels/approval-panel.ts +1 -0
  22. package/src/panels/automation-control-panel.ts +1 -0
  23. package/src/panels/base-panel.ts +108 -3
  24. package/src/panels/communication-panel.ts +1 -0
  25. package/src/panels/context-visualizer-panel.ts +2 -0
  26. package/src/panels/control-plane-panel.ts +1 -0
  27. package/src/panels/diff-panel.ts +2 -0
  28. package/src/panels/file-explorer-panel.ts +51 -31
  29. package/src/panels/file-preview-panel.ts +57 -35
  30. package/src/panels/git-panel.ts +12 -13
  31. package/src/panels/hooks-panel.ts +3 -1
  32. package/src/panels/incident-review-panel.ts +4 -2
  33. package/src/panels/knowledge-panel.ts +75 -107
  34. package/src/panels/local-auth-panel.ts +1 -0
  35. package/src/panels/marketplace-panel.ts +51 -69
  36. package/src/panels/mcp-panel.ts +3 -1
  37. package/src/panels/memory-panel.ts +90 -158
  38. package/src/panels/ops-control-panel.ts +1 -0
  39. package/src/panels/orchestration-panel.ts +70 -51
  40. package/src/panels/panel-list-panel.ts +5 -4
  41. package/src/panels/panel-manager.ts +3 -0
  42. package/src/panels/plan-dashboard-panel.ts +2 -0
  43. package/src/panels/plugins-panel.ts +1 -0
  44. package/src/panels/polish.ts +51 -2
  45. package/src/panels/provider-accounts-panel.ts +1 -0
  46. package/src/panels/provider-health-panel.ts +6 -8
  47. package/src/panels/routes-panel.ts +3 -1
  48. package/src/panels/schedule-panel.ts +7 -6
  49. package/src/panels/scrollable-list-panel.ts +19 -2
  50. package/src/panels/security-panel.ts +17 -15
  51. package/src/panels/services-panel.ts +6 -4
  52. package/src/panels/session-browser-panel.ts +19 -18
  53. package/src/panels/settings-sync-panel.ts +3 -1
  54. package/src/panels/skills-panel.ts +114 -230
  55. package/src/panels/subscription-panel.ts +1 -0
  56. package/src/panels/system-messages-panel.ts +147 -141
  57. package/src/panels/tasks-panel.ts +1 -0
  58. package/src/panels/token-budget-panel.ts +2 -0
  59. package/src/panels/watchers-panel.ts +1 -0
  60. package/src/panels/worktree-panel.ts +1 -0
  61. package/src/panels/wrfc-panel.ts +2 -0
  62. package/src/renderer/agent-detail-modal.ts +2 -2
  63. package/src/renderer/ansi-sanitize.ts +76 -0
  64. package/src/renderer/buffer.ts +12 -1
  65. package/src/renderer/help-overlay.ts +14 -3
  66. package/src/renderer/settings-modal-helpers.ts +27 -0
  67. package/src/renderer/settings-modal.ts +18 -1
  68. package/src/renderer/status-glyphs.ts +21 -0
  69. package/src/renderer/status-token.ts +4 -8
  70. package/src/renderer/tool-call.ts +4 -3
  71. package/src/runtime/bootstrap-core.ts +1 -1
  72. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  73. package/src/runtime/bootstrap.ts +7 -8
  74. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  75. package/src/shell/ui-openers.ts +1 -1
  76. package/src/version.ts +1 -1
@@ -103,7 +103,7 @@ export class GitPanel extends BasePanel {
103
103
  /** Scroll offset for both main view and diff view. */
104
104
  private scrollOffset = 0;
105
105
 
106
- private refreshTimer: ReturnType<typeof setInterval> | null = null;
106
+ private refreshTimerId: ReturnType<typeof setInterval> | null = null;
107
107
  private loading = true;
108
108
  private error: string | null = null;
109
109
 
@@ -119,20 +119,21 @@ export class GitPanel extends BasePanel {
119
119
  override onActivate(): void {
120
120
  super.onActivate();
121
121
  void this.refresh();
122
- this.refreshTimer = setInterval(() => {
122
+ this.refreshTimerId = this.registerTimer(setInterval(() => {
123
123
  void this.refresh();
124
- }, 5_000);
124
+ }, 5_000));
125
125
  }
126
126
 
127
127
  override onDeactivate(): void {
128
- if (this.refreshTimer !== null) {
129
- clearInterval(this.refreshTimer);
130
- this.refreshTimer = null;
128
+ if (this.refreshTimerId !== null) {
129
+ this.clearTimer(this.refreshTimerId);
130
+ this.refreshTimerId = null;
131
131
  }
132
132
  }
133
133
 
134
134
  override onDestroy(): void {
135
135
  this.onDeactivate();
136
+ super.onDestroy();
136
137
  }
137
138
 
138
139
  // ---------------------------------------------------------------------------
@@ -326,18 +327,16 @@ export class GitPanel extends BasePanel {
326
327
  const item = this.items[this.selectedIndex];
327
328
  if (!item || item.kind !== 'file') return;
328
329
 
329
- // I3: show base-class spinner while awaiting diff
330
- this.startLoading('Loading diff...');
331
- this.markDirty();
330
+ // I3: withLoading guarantees spinner is cleared even if diffFile throws
332
331
  try {
333
- const git = new GitService(this.workingDirectory);
334
- const raw = await git.diffFile(item.entry.path, item.entry.staged);
335
- this.stopLoading();
332
+ const raw = await this.withLoading('Loading diff…', async () => {
333
+ const git = new GitService(this.workingDirectory);
334
+ return git.diffFile(item.entry.path, item.entry.staged);
335
+ });
336
336
  this.expandedDiff = raw ? raw.split('\n') : ['(no diff available)'];
337
337
  this.scrollOffset = 0;
338
338
  this.markDirty();
339
339
  } catch (err) {
340
- this.stopLoading();
341
340
  this.expandedDiff = [`Error: ${summarizeError(err)}`];
342
341
  this.scrollOffset = 0;
343
342
  this.markDirty();
@@ -10,6 +10,7 @@ import type { HookWorkbench } from '@pellux/goodvibes-sdk/platform/hooks/workben
10
10
  import { truncateDisplay } from '../utils/terminal-width.ts';
11
11
  import {
12
12
  buildPanelLine,
13
+ buildStatusPill,
13
14
  DEFAULT_PANEL_PALETTE,
14
15
  } from './polish.ts';
15
16
 
@@ -66,6 +67,7 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
66
67
  dataSource: HooksPanelDataSource = createDefaultDataSource(hookDispatcher, hookWorkbench, hookActivityTracker),
67
68
  ) {
68
69
  super('hooks', 'Hooks', 'H', 'monitoring');
70
+ this.showSelectionGutter = true; // I5: non-color selection affordance
69
71
  this.dataSource = dataSource;
70
72
  }
71
73
 
@@ -88,7 +90,7 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
88
90
  [' ', C.label, bg],
89
91
  [truncateDisplay(entry.hook.name ?? '(unnamed)', 20).padEnd(20), C.value, bg],
90
92
  [` ${truncateDisplay(entry.pattern, 28).padEnd(28)}`, C.info, bg],
91
- [` ${(entry.hook.enabled === false ? 'DISABLED' : 'ENABLED').padEnd(8)}`, entry.hook.enabled === false ? C.warn : C.ok, bg],
93
+ ...buildStatusPill(entry.hook.enabled === false ? 'warn' : 'good', ` ${(entry.hook.enabled === false ? 'DISABLED' : 'ENABLED').padEnd(8)}`, { bg }),
92
94
  [` ${entry.hook.type}`, C.dim, bg],
93
95
  ]);
94
96
  }
@@ -8,6 +8,7 @@ import {
8
8
  buildKeyValueLine,
9
9
  buildPanelLine,
10
10
  buildPanelWorkspace,
11
+ buildStatusPill,
11
12
  DEFAULT_PANEL_PALETTE,
12
13
  type PanelPalette,
13
14
  } from './polish.ts';
@@ -40,6 +41,7 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
40
41
 
41
42
  public constructor(registry?: ForensicsRegistry) {
42
43
  super('incident', 'Incident Review', 'N', 'monitoring');
44
+ this.showSelectionGutter = true; // I5: non-color selection affordance
43
45
  this.registry = registry;
44
46
  this.unsub = registry ? registry.subscribe(() => this.markDirty()) : null;
45
47
  }
@@ -137,7 +139,7 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
137
139
  if (bundle.evidence.slowPhases.length > 0) {
138
140
  footerLines.push(buildPanelLine(width, [
139
141
  [' Slow phases: ', C.label],
140
- [bundle.evidence.slowPhases.join(', ').slice(0, Math.max(0, width - 15)), C.warn],
142
+ ...buildStatusPill('warn', bundle.evidence.slowPhases.join(', ').slice(0, Math.max(0, width - 15))),
141
143
  ]));
142
144
  }
143
145
  const rootCause = selected.causalChain.find((entry) => entry.isRootCause);
@@ -166,7 +168,7 @@ export class IncidentReviewPanel extends ScrollableListPanel<FailureReport> {
166
168
  : `Replay link: ${mismatch.kind}${mismatch.ownerDomain ? `/${mismatch.ownerDomain}` : ''} - ${mismatch.description}`;
167
169
  footerLines.push(buildPanelLine(width, [
168
170
  [' ', C.label],
169
- [replayDetail.slice(0, Math.max(0, width - 2)), C.bad],
171
+ ...buildStatusPill('bad', replayDetail.slice(0, Math.max(0, width - 2))),
170
172
  ]));
171
173
  } else {
172
174
  const ownerBreakdown = Object.entries(bundle.replay.mismatchBreakdown.byOwnerDomain)
@@ -1,5 +1,5 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { BasePanel } from './base-panel.ts';
2
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
3
  import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
4
4
  import type { MemoryClass, MemoryRecord, MemoryRegistry, MemoryReviewState } from '@pellux/goodvibes-sdk/platform/state/memory-store';
5
5
  import {
@@ -9,9 +9,7 @@ import {
9
9
  buildKeyValueLine,
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
- resolveScrollablePanelSection,
13
12
  DEFAULT_PANEL_PALETTE,
14
- type PanelWorkspaceSection,
15
13
  } from './polish.ts';
16
14
 
17
15
  function summarize(records: MemoryRecord[], cls: MemoryClass): MemoryRecord[] {
@@ -42,11 +40,9 @@ function formatConfidence(confidence: number): string {
42
40
  return `${confidence.toString().padStart(3, ' ')}%`;
43
41
  }
44
42
 
45
- export class KnowledgePanel extends BasePanel {
43
+ export class KnowledgePanel extends ScrollableListPanel<MemoryRecord> {
46
44
  private readonly registry: MemoryRegistry;
47
45
  private unsubscribe?: () => void;
48
- private selectedIndex = 0;
49
- private scrollOffset = 0;
50
46
  private records: MemoryRecord[] = [];
51
47
  // I1: confirm for destructive review-state mutations
52
48
  private confirm: ConfirmState<{ id: string; action: 'stale' | 'contradicted' }> | null = null;
@@ -74,6 +70,38 @@ export class KnowledgePanel extends BasePanel {
74
70
  this.unsubscribe = undefined;
75
71
  }
76
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // ScrollableListPanel implementation
75
+ // ---------------------------------------------------------------------------
76
+
77
+ protected getItems(): readonly MemoryRecord[] {
78
+ return this.records;
79
+ }
80
+
81
+ protected renderItem(record: MemoryRecord, index: number, selected: boolean, width: number): Line {
82
+ const bg = selected ? C.selectBg : undefined;
83
+ return buildPanelLine(width, [
84
+ [' ', C.label, bg],
85
+ [record.reviewState.padEnd(13), reviewStateColor(record.reviewState), bg],
86
+ [` ${formatConfidence(record.confidence)} `, C.value, bg],
87
+ [record.summary.slice(0, Math.max(0, width - 26)), C.value, bg],
88
+ ]);
89
+ }
90
+
91
+ protected override getPalette() { return C; }
92
+ protected override getEmptyStateMessage() { return 'No durable project knowledge'; }
93
+ protected override getEmptyStateActions() {
94
+ return [
95
+ { command: '/recall add fact <summary>', summary: 'capture a durable fact directly' },
96
+ { command: '/recall capture incident latest', summary: 'promote the latest incident into project memory' },
97
+ { command: '/recall capture policy', summary: 'store the current policy posture as durable evidence' },
98
+ ];
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Input
103
+ // ---------------------------------------------------------------------------
104
+
77
105
  public handleInput(key: string): boolean {
78
106
  // I1: y/n confirm for stale/contradict
79
107
  if (this.confirm) {
@@ -116,25 +144,13 @@ export class KnowledgePanel extends BasePanel {
116
144
  if (result === 'absorbed') return true;
117
145
  }
118
146
 
119
- // I2: auto-clear error on next keypress
120
- if (this.lastError) this.clearError();
121
-
122
- if (this.records.length === 0) return false;
123
- if (key === 'ArrowUp' || key === 'k') {
124
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
125
- this.markDirty();
126
- return true;
127
- }
128
- if (key === 'ArrowDown' || key === 'j') {
129
- this.selectedIndex = Math.min(this.records.length - 1, this.selectedIndex + 1);
130
- this.markDirty();
131
- return true;
132
- }
147
+ // I2: auto-clear error on next keypress (inherited via super.handleInput)
148
+ if (this.records.length === 0) return super.handleInput(key);
133
149
 
134
150
  const selected = this.records[this.selectedIndex];
135
- if (!selected) return false;
136
151
 
137
- if (key === 'Enter' || key === 'r') {
152
+ if (key === 'Enter' || key === 'return' || key === 'r') {
153
+ if (!selected) return false;
138
154
  this.registry.review(selected.id, {
139
155
  state: 'reviewed',
140
156
  confidence: Math.max(selected.confidence, 85),
@@ -145,18 +161,21 @@ export class KnowledgePanel extends BasePanel {
145
161
  return true;
146
162
  }
147
163
  if (key === 's') {
164
+ if (!selected) return false;
148
165
  // I1: prompt confirm before marking stale
149
166
  this.confirm = { subject: { id: selected.id, action: 'stale' }, label: selected.summary.slice(0, 40) };
150
167
  this.markDirty();
151
168
  return true;
152
169
  }
153
170
  if (key === 'c') {
171
+ if (!selected) return false;
154
172
  // I1: prompt confirm before marking contradicted
155
173
  this.confirm = { subject: { id: selected.id, action: 'contradicted' }, label: selected.summary.slice(0, 40) };
156
174
  this.markDirty();
157
175
  return true;
158
176
  }
159
177
  if (key === 'f') {
178
+ if (!selected) return false;
160
179
  this.registry.review(selected.id, {
161
180
  state: 'fresh',
162
181
  confidence: Math.max(selected.confidence, 60),
@@ -167,17 +186,20 @@ export class KnowledgePanel extends BasePanel {
167
186
  return true;
168
187
  }
169
188
 
170
- return false;
189
+ // Normalize arrow keys to base class format
190
+ if (key === 'ArrowUp') return super.handleInput('up');
191
+ if (key === 'ArrowDown') return super.handleInput('down');
192
+ return super.handleInput(key);
171
193
  }
172
194
 
173
195
  private refresh(): void {
174
196
  const queue = this.registry.reviewQueue(24);
175
197
  this.records = queue.length > 0 ? queue : this.registry.search({ limit: 24 });
176
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.records.length - 1));
198
+ this.clampSelection();
177
199
  }
178
200
 
179
201
  public render(width: number, height: number): Line[] {
180
- this.needsRender = false;
202
+ this.clampSelection();
181
203
 
182
204
  // I1: show confirm dialog in place of normal content
183
205
  if (this.confirm) {
@@ -196,26 +218,9 @@ export class KnowledgePanel extends BasePanel {
196
218
  const records = this.registry.search({ limit: 200 });
197
219
 
198
220
  if (records.length === 0) {
199
- return buildPanelWorkspace(width, height, {
221
+ return this.renderList(width, height, {
200
222
  title: 'Knowledge Control Room',
201
- intro,
202
- sections: [{
203
- lines: buildEmptyState(
204
- width,
205
- ' No durable project knowledge has been recorded yet.',
206
- 'The knowledge system is empty. It becomes useful once session, incident, task, and operator evidence are promoted into durable records.',
207
- [
208
- { command: '/recall add fact <summary>', summary: 'capture a durable fact directly' },
209
- { command: '/recall capture incident latest', summary: 'promote the latest incident into project memory' },
210
- { command: '/recall capture policy', summary: 'store the current policy posture as durable evidence' },
211
- ],
212
- C,
213
- ),
214
- }],
215
- footerLines: [
216
- buildPanelLine(width, [[' Review keys: Up/Down move r/Enter review s stale c contradicted f fresh', C.dim]]),
217
- ],
218
- palette: C,
223
+ footer: [buildPanelLine(width, [[' Review keys: Up/Down move r/Enter review s stale c contradicted f fresh', C.dim]])],
219
224
  });
220
225
  }
221
226
 
@@ -252,7 +257,7 @@ export class KnowledgePanel extends BasePanel {
252
257
  [' fresh ', C.label], [String(byReview.get('fresh') ?? 0), C.info],
253
258
  [' stale ', C.label], [String(byReview.get('stale') ?? 0), C.warn],
254
259
  [' contradicted ', C.label], [String(byReview.get('contradicted') ?? 0), C.bad],
255
- [' review queue ', C.label], [String(queue.length), queue.length > 0 ? C.warn : C.good],
260
+ [' Review Queue ', C.label], [String(queue.length), queue.length > 0 ? C.warn : C.good],
256
261
  ]),
257
262
  buildPanelLine(width, [
258
263
  [' session ', C.label], [String(byScope.get('session') ?? 0), C.info],
@@ -285,93 +290,56 @@ export class KnowledgePanel extends BasePanel {
285
290
  }
286
291
  }
287
292
 
288
- const selected = this.records[this.selectedIndex];
293
+ const selectedRecord = this.records[this.selectedIndex];
289
294
  const selectedLines: Line[] = [];
290
- if (selected) {
295
+ if (selectedRecord) {
296
+ selectedLines.push(buildPanelLine(width, [[' Selected', C.label]]));
291
297
  selectedLines.push(buildKeyValueLine(width, [
292
- { label: 'Class', value: selected.cls, valueColor: C.value },
293
- { label: 'Scope', value: selected.scope, valueColor: C.info },
294
- { label: 'Review', value: selected.reviewState, valueColor: reviewStateColor(selected.reviewState) },
295
- { label: 'Confidence', value: formatConfidence(selected.confidence), valueColor: C.value },
298
+ { label: 'Class', value: selectedRecord.cls, valueColor: C.value },
299
+ { label: 'Scope', value: selectedRecord.scope, valueColor: C.info },
300
+ { label: 'Review', value: selectedRecord.reviewState, valueColor: reviewStateColor(selectedRecord.reviewState) },
301
+ { label: 'Confidence', value: formatConfidence(selectedRecord.confidence), valueColor: C.value },
296
302
  ], C));
297
- selectedLines.push(...buildBodyText(width, `Summary: ${selected.summary}`, C, C.value));
298
- if (selected.detail) selectedLines.push(...buildBodyText(width, `Detail: ${selected.detail}`, C, C.dim));
299
- if (selected.provenance.length) {
303
+ selectedLines.push(...buildBodyText(width, `Summary: ${selectedRecord.summary}`, C, C.value));
304
+ if (selectedRecord.detail) selectedLines.push(...buildBodyText(width, `Detail: ${selectedRecord.detail}`, C, C.dim));
305
+ if (selectedRecord.provenance.length) {
300
306
  selectedLines.push(...buildBodyText(
301
307
  width,
302
- `Provenance: ${selected.provenance.map((p) => `${p.kind}:${p.ref}`).join(', ')}`,
308
+ `Provenance: ${selectedRecord.provenance.map((p) => `${p.kind}:${p.ref}`).join(', ')}`,
303
309
  C,
304
310
  C.dim,
305
311
  ));
306
312
  }
307
- if (selected.staleReason) {
313
+ if (selectedRecord.staleReason) {
308
314
  selectedLines.push(...buildBodyText(
309
315
  width,
310
- `Stale reason: ${selected.staleReason}`,
316
+ `Stale reason: ${selectedRecord.staleReason}`,
311
317
  C,
312
- selected.reviewState === 'contradicted' ? C.bad : C.warn,
318
+ selectedRecord.reviewState === 'contradicted' ? C.bad : C.warn,
313
319
  ));
314
320
  }
315
- if (selected.reviewedAt) {
321
+ if (selectedRecord.reviewedAt) {
316
322
  selectedLines.push(buildPanelLine(width, [
317
323
  [' Reviewed: ', C.label],
318
- [new Date(selected.reviewedAt).toLocaleString(), C.dim],
324
+ [new Date(selectedRecord.reviewedAt).toLocaleString(), C.dim],
319
325
  ]));
320
- if (selected.reviewedBy) {
326
+ if (selectedRecord.reviewedBy) {
321
327
  selectedLines.push(buildPanelLine(width, [
322
328
  [' Reviewer: ', C.label],
323
- [selected.reviewedBy, C.dim],
329
+ [selectedRecord.reviewedBy, C.dim],
324
330
  ]));
325
331
  }
326
332
  }
327
333
  }
328
334
 
329
- const footerLines = [
330
- buildPanelLine(width, [[' Up/Down move r/Enter reviewed s stale c contradicted f fresh', C.dim]]),
331
- ];
332
- const classesSection: PanelWorkspaceSection = { title: 'Classes', lines: classLines };
333
- const reviewStateSection: PanelWorkspaceSection = { title: 'Review State', lines: reviewLines };
334
- const selectedSection: PanelWorkspaceSection = selectedLines.length > 0 ? { title: 'Selected', lines: selectedLines } : { title: 'Selected', lines: [] };
335
- const recentSection: PanelWorkspaceSection = { title: 'Recent Risks / Runbooks / Architecture Notes', lines: recentSummaryLines };
336
- const queueSection = resolveScrollablePanelSection(width, height, {
337
- intro,
338
- footerLines,
339
- palette: C,
340
- beforeSections: [classesSection, reviewStateSection],
341
- section: {
342
- title: 'Review Queue',
343
- scrollableLines: this.records.map((record, globalIndex) => {
344
- const bg = globalIndex === this.selectedIndex ? C.selectBg : undefined;
345
- return buildPanelLine(width, [
346
- [' ', C.label, bg],
347
- [record.reviewState.padEnd(13), reviewStateColor(record.reviewState), bg],
348
- [` ${formatConfidence(record.confidence)} `, C.value, bg],
349
- [record.summary.slice(0, Math.max(0, width - 26)), C.value, bg],
350
- ]);
351
- }),
352
- selectedIndex: this.selectedIndex,
353
- scrollOffset: this.scrollOffset,
354
- minRows: 4,
355
- appendWindowSummary: { dimColor: C.dim },
356
- },
357
- afterSections: selectedLines.length > 0 ? [selectedSection, recentSection] : [recentSection],
358
- });
359
- this.scrollOffset = queueSection.scrollOffset;
360
-
361
- const sections: PanelWorkspaceSection[] = [
362
- classesSection,
363
- reviewStateSection,
364
- queueSection.section,
365
- ];
366
- if (selectedLines.length > 0) sections.push(selectedSection);
367
- sections.push(recentSection);
368
-
369
- return buildPanelWorkspace(width, height, {
335
+ return this.renderList(width, height, {
370
336
  title: 'Knowledge Control Room',
371
- intro,
372
- sections,
373
- footerLines,
374
- palette: C,
337
+ header: [...classLines, ...reviewLines],
338
+ footer: [
339
+ ...(selectedLines.length > 0 ? selectedLines : []),
340
+ ...recentSummaryLines,
341
+ buildPanelLine(width, [[' Up/Down move r/Enter reviewed s stale c contradicted f fresh', C.dim]]),
342
+ ],
375
343
  });
376
344
  }
377
345
  }
@@ -33,6 +33,7 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
33
33
 
34
34
  public constructor(authManager: LocalAuthInspectionQuery) {
35
35
  super('local-auth', 'Local Auth', 'U', 'monitoring');
36
+ this.showSelectionGutter = true; // I5: non-color selection affordance
36
37
  this.authManager = authManager;
37
38
  }
38
39
 
@@ -1,12 +1,11 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { BasePanel } from './base-panel.ts';
2
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
3
  import {
4
4
  buildEmptyState,
5
5
  buildGuidanceLine,
6
6
  buildKeyValueLine,
7
7
  buildPanelLine,
8
8
  buildPanelWorkspace,
9
- resolveScrollablePanelSection,
10
9
  DEFAULT_PANEL_PALETTE,
11
10
  type PanelWorkspaceSection,
12
11
  } from './polish.ts';
@@ -36,10 +35,8 @@ function statusColor(installed: boolean): string {
36
35
  return installed ? C.good : C.dim;
37
36
  }
38
37
 
39
- export class MarketplacePanel extends BasePanel {
38
+ export class MarketplacePanel extends ScrollableListPanel<MarketplaceRow> {
40
39
  private rows: MarketplaceRow[] = [];
41
- private selectedIndex = 0;
42
- private scrollOffset = 0;
43
40
  private readonly unsub: (() => void) | null;
44
41
 
45
42
  public constructor(
@@ -59,26 +56,45 @@ export class MarketplacePanel extends BasePanel {
59
56
  this.refresh();
60
57
  }
61
58
 
62
- public handleInput(key: string): boolean {
63
- if (this.rows.length === 0) return false;
64
- if (key === 'ArrowUp' || key === 'k') {
65
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
66
- this.markDirty();
67
- return true;
68
- }
69
- if (key === 'ArrowDown' || key === 'j') {
70
- this.selectedIndex = Math.min(this.rows.length - 1, this.selectedIndex + 1);
71
- this.markDirty();
72
- return true;
73
- }
74
- return false;
59
+ // ---------------------------------------------------------------------------
60
+ // ScrollableListPanel implementation
61
+ // ---------------------------------------------------------------------------
62
+
63
+ protected getItems(): readonly MarketplaceRow[] {
64
+ return this.rows;
65
+ }
66
+
67
+ protected renderItem(row: MarketplaceRow, index: number, selected: boolean, width: number): Line {
68
+ const bg = selected ? C.selectBg : undefined;
69
+ const provenance = row.entry.provenance ?? 'local';
70
+ return buildPanelLine(width, [
71
+ [' ', C.label, bg],
72
+ [row.kind.padEnd(11), C.info, bg],
73
+ [row.entry.name.slice(0, 20).padEnd(20), C.value, bg],
74
+ [` ${provenance.slice(0, 16).padEnd(16)}`, provenance === 'local' ? C.dim : C.info, bg],
75
+ [` ${(row.installed ? 'INSTALLED' : 'CURATED').padEnd(9)} `, statusColor(row.installed), bg],
76
+ [` ${row.entry.version ?? 'n/a'}`, C.dim, bg],
77
+ ]);
78
+ }
79
+
80
+ protected override getPalette() { return C; }
81
+ protected override getEmptyStateMessage() {
82
+ return this.ecosystemPaths
83
+ ? ' No curated marketplace entries found yet.'
84
+ : ' Marketplace catalog paths are not wired into this panel yet.';
85
+ }
86
+ protected override getEmptyStateActions() {
87
+ return [
88
+ { command: '/marketplace bundle import <path>', summary: 'import a curated marketplace bundle' },
89
+ { command: '/marketplace catalog review', summary: 'inspect the current local catalog posture' },
90
+ { command: '/marketplace publish <kind> <path>', summary: 'publish local ecosystem entries back into the curated catalog' },
91
+ ];
75
92
  }
76
93
 
77
94
  private refresh(): void {
78
95
  if (!this.ecosystemPaths) {
79
96
  this.rows = [];
80
- this.selectedIndex = 0;
81
- this.scrollOffset = 0;
97
+ this.clampSelection();
82
98
  return;
83
99
  }
84
100
  try {
@@ -93,7 +109,7 @@ export class MarketplacePanel extends BasePanel {
93
109
  ...loadEcosystemCatalog('policy-pack', this.ecosystemPaths).map((entry) => ({ kind: 'policy-pack' as const, entry, installed: installedPolicyPacks.has(entry.id) })),
94
110
  ];
95
111
  this.rows = rows.sort((a, b) => a.entry.name.localeCompare(b.entry.name));
96
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
112
+ this.clampSelection();
97
113
  // I2: clear any previous catalog load error on successful refresh
98
114
  this.clearError();
99
115
  } catch (e) {
@@ -103,7 +119,7 @@ export class MarketplacePanel extends BasePanel {
103
119
  }
104
120
 
105
121
  public render(width: number, height: number): Line[] {
106
- this.needsRender = false;
122
+ this.clampSelection();
107
123
  this.refresh();
108
124
 
109
125
  const intro = 'Curated local-first ecosystem with provenance, compatibility, rollback history, and receipt-aware lifecycle review.';
@@ -159,22 +175,22 @@ export class MarketplacePanel extends BasePanel {
159
175
  ? startupIssues.slice(0, 4).map((issue) => buildPanelLine(width, [[' ', C.label], [issue.slice(0, Math.max(0, width - 2)), C.warn]]))
160
176
  : [buildPanelLine(width, [[' No startup or lifecycle issues are currently pushing marketplace repair recommendations.', C.dim]])];
161
177
 
162
- const selected = this.rows[this.selectedIndex];
178
+ const selectedRow = this.rows[this.selectedIndex];
163
179
  const selectedLines: Line[] = [];
164
- if (selected) {
165
- const review = reviewEcosystemCatalogEntry(selected.entry, this.ecosystemPaths!);
180
+ if (selectedRow) {
181
+ const review = reviewEcosystemCatalogEntry(selectedRow.entry, this.ecosystemPaths!);
166
182
  selectedLines.push(buildPanelLine(width, [
167
183
  [' Provenance: ', C.label],
168
- [(selected.entry.provenance ?? '(none)').slice(0, Math.max(0, width - 15)), selected.entry.provenance ? C.info : C.dim],
184
+ [(selectedRow.entry.provenance ?? '(none)').slice(0, Math.max(0, width - 15)), selectedRow.entry.provenance ? C.info : C.dim],
169
185
  ]));
170
186
  selectedLines.push(buildPanelLine(width, [
171
187
  [' Source: ', C.label],
172
- [selected.entry.source.slice(0, Math.max(0, width - 11)), C.value],
188
+ [selectedRow.entry.source.slice(0, Math.max(0, width - 11)), C.value],
173
189
  ]));
174
190
  selectedLines.push(buildKeyValueLine(width, [
175
191
  { label: 'Compatibility', value: review.compatibility.status, valueColor: review.compatibility.status === 'compatible' ? C.good : C.warn },
176
192
  { label: 'Risk', value: review.riskLevel, valueColor: review.riskLevel === 'low' ? C.good : C.warn },
177
- { label: 'State', value: selected.installed ? 'installed' : 'curated', valueColor: statusColor(selected.installed) },
193
+ { label: 'State', value: selectedRow.installed ? 'installed' : 'curated', valueColor: statusColor(selectedRow.installed) },
178
194
  ], C));
179
195
  selectedLines.push(buildGuidanceLine(width, '/marketplace review <id>', 'inspect full compatibility and receipt detail for the selected entry', C));
180
196
  }
@@ -182,49 +198,15 @@ export class MarketplacePanel extends BasePanel {
182
198
  const postureSection: PanelWorkspaceSection = { title: 'Marketplace posture', lines: postureLines };
183
199
  const startupIssuesSection: PanelWorkspaceSection = { title: 'Startup Issues', lines: startupIssueLines };
184
200
  const recommendationsSection: PanelWorkspaceSection = { title: 'Recommendations', lines: recommendationLines };
185
- const selectedSection: PanelWorkspaceSection = { title: 'Selected', lines: selectedLines };
186
- const catalogSection = resolveScrollablePanelSection(width, height, {
187
- intro,
188
- palette: C,
189
- beforeSections: [postureSection, startupIssuesSection, recommendationsSection],
190
- section: {
191
- title: 'Catalog',
192
- scrollableLines: this.rows.map((row, globalIndex) => {
193
- const bg = globalIndex === this.selectedIndex ? C.selectBg : undefined;
194
- const provenance = row.entry.provenance ?? 'local';
195
- return buildPanelLine(width, [
196
- [' ', C.label, bg],
197
- [row.kind.padEnd(11), C.info, bg],
198
- [row.entry.name.slice(0, 20).padEnd(20), C.value, bg],
199
- [` ${provenance.slice(0, 16).padEnd(16)}`, provenance === 'local' ? C.dim : C.info, bg],
200
- [` ${(row.installed ? 'INSTALLED' : 'CURATED').padEnd(9)} `, statusColor(row.installed), bg],
201
- [` ${row.entry.version ?? 'n/a'}`, C.dim, bg],
202
- ]);
203
- }),
204
- selectedIndex: this.selectedIndex,
205
- scrollOffset: this.scrollOffset,
206
- minRows: 4,
207
- appendWindowSummary: { dimColor: C.dim },
208
- },
209
- afterSections: selectedLines.length > 0 && height >= 20 ? [selectedSection] : [],
210
- });
211
- this.scrollOffset = catalogSection.scrollOffset;
212
-
213
- const sections: PanelWorkspaceSection[] = [
214
- postureSection,
215
- startupIssuesSection,
216
- recommendationsSection,
217
- catalogSection.section,
218
- ];
219
- if (selectedLines.length > 0 && height >= 20) {
220
- sections.push(selectedSection);
221
- }
222
201
 
223
- return buildPanelWorkspace(width, height, {
202
+ return this.renderList(width, height, {
224
203
  title: 'Marketplace Control Room',
225
- intro,
226
- sections,
227
- palette: C,
204
+ header: [
205
+ ...postureLines,
206
+ ...startupIssueLines,
207
+ ...recommendationLines,
208
+ ],
209
+ footer: selectedLines.length > 0 && height >= 20 ? selectedLines : [],
228
210
  });
229
211
  }
230
212
  }
@@ -9,6 +9,7 @@ import {
9
9
  buildGuidanceLine,
10
10
  buildKeyValueLine,
11
11
  buildPanelLine,
12
+ buildStatusPill,
12
13
  DEFAULT_PANEL_PALETTE,
13
14
  } from './polish.ts';
14
15
 
@@ -62,6 +63,7 @@ export class McpPanel extends ScrollableListPanel<McpServerSecurityEntry> {
62
63
 
63
64
  public constructor(registry: McpRegistry) {
64
65
  super('mcp', 'MCP', 'Z', 'monitoring');
66
+ this.showSelectionGutter = true; // I5: non-color selection affordance
65
67
  this.registry = registry;
66
68
  }
67
69
 
@@ -83,7 +85,7 @@ export class McpPanel extends ScrollableListPanel<McpServerSecurityEntry> {
83
85
  return buildPanelLine(width, [
84
86
  [' ', C.label, bg],
85
87
  [entry.name.padEnd(20), C.value, bg],
86
- [` ${(entry.connected ? 'CONNECTED' : 'DISCONNECTED').padEnd(13)}`, entry.connected ? C.ok : C.error, bg],
88
+ ...buildStatusPill(entry.connected ? 'good' : 'bad', ` ${(entry.connected ? 'CONNECTED' : 'DISCONNECTED').padEnd(13)}`, { bg }),
87
89
  [` ${entry.trustMode.padEnd(12)}`, modeColor(entry.trustMode), bg],
88
90
  [` ${entry.role.padEnd(10)}`, C.info, bg],
89
91
  [` ${entry.schemaFreshness}`, freshnessColor(entry.schemaFreshness), bg],