@pellux/goodvibes-tui 0.18.23 → 0.19.1

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 (80) hide show
  1. package/CHANGELOG.md +71 -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/daemon/cli.ts +54 -0
  8. package/src/input/commands/diff-runtime.ts +6 -5
  9. package/src/input/commands/guidance-runtime.ts +1 -1
  10. package/src/input/commands/health-runtime.ts +2 -2
  11. package/src/input/commands/local-setup-review.ts +1 -1
  12. package/src/input/commands/session-content.ts +1 -1
  13. package/src/input/commands/shell-core.ts +3 -2
  14. package/src/input/commands/skills-runtime.ts +2 -2
  15. package/src/input/commands/subscription-runtime.ts +4 -4
  16. package/src/input/handler.ts +8 -10
  17. package/src/input/model-picker.ts +6 -2
  18. package/src/input/panel-integration-actions.ts +2 -1
  19. package/src/input/settings-modal-types.ts +60 -0
  20. package/src/input/settings-modal.ts +83 -65
  21. package/src/main.ts +52 -0
  22. package/src/panels/agent-inspector-panel.ts +10 -9
  23. package/src/panels/agent-logs-panel.ts +26 -6
  24. package/src/panels/approval-panel.ts +1 -0
  25. package/src/panels/automation-control-panel.ts +1 -0
  26. package/src/panels/base-panel.ts +108 -3
  27. package/src/panels/communication-panel.ts +1 -0
  28. package/src/panels/context-visualizer-panel.ts +2 -0
  29. package/src/panels/control-plane-panel.ts +1 -0
  30. package/src/panels/diff-panel.ts +2 -0
  31. package/src/panels/file-explorer-panel.ts +51 -31
  32. package/src/panels/file-preview-panel.ts +57 -35
  33. package/src/panels/git-panel.ts +12 -13
  34. package/src/panels/hooks-panel.ts +3 -1
  35. package/src/panels/incident-review-panel.ts +4 -2
  36. package/src/panels/knowledge-panel.ts +75 -107
  37. package/src/panels/local-auth-panel.ts +1 -0
  38. package/src/panels/marketplace-panel.ts +51 -69
  39. package/src/panels/mcp-panel.ts +3 -1
  40. package/src/panels/memory-panel.ts +90 -158
  41. package/src/panels/ops-control-panel.ts +1 -0
  42. package/src/panels/orchestration-panel.ts +70 -51
  43. package/src/panels/panel-list-panel.ts +5 -4
  44. package/src/panels/panel-manager.ts +3 -0
  45. package/src/panels/plan-dashboard-panel.ts +2 -0
  46. package/src/panels/plugins-panel.ts +1 -0
  47. package/src/panels/polish.ts +51 -2
  48. package/src/panels/provider-accounts-panel.ts +1 -0
  49. package/src/panels/provider-health-panel.ts +6 -8
  50. package/src/panels/routes-panel.ts +3 -1
  51. package/src/panels/schedule-panel.ts +7 -6
  52. package/src/panels/scrollable-list-panel.ts +19 -2
  53. package/src/panels/security-panel.ts +17 -15
  54. package/src/panels/services-panel.ts +6 -4
  55. package/src/panels/session-browser-panel.ts +19 -18
  56. package/src/panels/settings-sync-panel.ts +3 -1
  57. package/src/panels/skills-panel.ts +114 -230
  58. package/src/panels/subscription-panel.ts +1 -0
  59. package/src/panels/system-messages-panel.ts +147 -141
  60. package/src/panels/tasks-panel.ts +1 -0
  61. package/src/panels/token-budget-panel.ts +2 -0
  62. package/src/panels/watchers-panel.ts +1 -0
  63. package/src/panels/worktree-panel.ts +1 -0
  64. package/src/panels/wrfc-panel.ts +2 -0
  65. package/src/renderer/agent-detail-modal.ts +2 -2
  66. package/src/renderer/ansi-sanitize.ts +76 -0
  67. package/src/renderer/buffer.ts +12 -1
  68. package/src/renderer/help-overlay.ts +14 -3
  69. package/src/renderer/model-picker-overlay.ts +9 -2
  70. package/src/renderer/settings-modal-helpers.ts +27 -0
  71. package/src/renderer/settings-modal.ts +18 -1
  72. package/src/renderer/status-glyphs.ts +21 -0
  73. package/src/renderer/status-token.ts +4 -8
  74. package/src/renderer/tool-call.ts +4 -3
  75. package/src/runtime/bootstrap-core.ts +1 -1
  76. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  77. package/src/runtime/bootstrap.ts +7 -8
  78. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  79. package/src/shell/ui-openers.ts +44 -3
  80. package/src/version.ts +1 -1
@@ -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],