@pellux/goodvibes-tui 0.18.20 → 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 (83) hide show
  1. package/CHANGELOG.md +154 -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 +22 -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/session.ts +0 -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/feed-context-factory.ts +236 -0
  17. package/src/input/handler-feed.ts +44 -6
  18. package/src/input/handler-shortcuts.ts +138 -125
  19. package/src/input/handler.ts +119 -119
  20. package/src/input/keybindings.ts +30 -0
  21. package/src/input/panel-integration-actions.ts +2 -1
  22. package/src/input/settings-modal-types.ts +60 -0
  23. package/src/input/settings-modal.ts +83 -65
  24. package/src/panels/agent-inspector-panel.ts +10 -9
  25. package/src/panels/agent-logs-panel.ts +26 -6
  26. package/src/panels/approval-panel.ts +55 -82
  27. package/src/panels/automation-control-panel.ts +120 -161
  28. package/src/panels/base-panel.ts +108 -3
  29. package/src/panels/communication-panel.ts +69 -107
  30. package/src/panels/context-visualizer-panel.ts +2 -0
  31. package/src/panels/control-plane-panel.ts +117 -172
  32. package/src/panels/diff-panel.ts +2 -0
  33. package/src/panels/file-explorer-panel.ts +51 -31
  34. package/src/panels/file-preview-panel.ts +57 -35
  35. package/src/panels/git-panel.ts +12 -13
  36. package/src/panels/hooks-panel.ts +103 -138
  37. package/src/panels/incident-review-panel.ts +59 -109
  38. package/src/panels/knowledge-panel.ts +75 -107
  39. package/src/panels/local-auth-panel.ts +77 -93
  40. package/src/panels/marketplace-panel.ts +51 -69
  41. package/src/panels/mcp-panel.ts +110 -155
  42. package/src/panels/memory-panel.ts +90 -158
  43. package/src/panels/ops-control-panel.ts +51 -85
  44. package/src/panels/orchestration-panel.ts +70 -51
  45. package/src/panels/panel-list-panel.ts +5 -4
  46. package/src/panels/panel-manager.ts +25 -2
  47. package/src/panels/plan-dashboard-panel.ts +2 -0
  48. package/src/panels/plugins-panel.ts +37 -60
  49. package/src/panels/polish.ts +51 -2
  50. package/src/panels/provider-accounts-panel.ts +1 -0
  51. package/src/panels/provider-health-panel.ts +6 -8
  52. package/src/panels/routes-panel.ts +91 -141
  53. package/src/panels/schedule-panel.ts +7 -6
  54. package/src/panels/scrollable-list-panel.ts +64 -16
  55. package/src/panels/security-panel.ts +118 -152
  56. package/src/panels/services-panel.ts +63 -105
  57. package/src/panels/session-browser-panel.ts +19 -18
  58. package/src/panels/settings-sync-panel.ts +79 -123
  59. package/src/panels/skills-panel.ts +114 -230
  60. package/src/panels/subscription-panel.ts +64 -86
  61. package/src/panels/system-messages-panel.ts +147 -141
  62. package/src/panels/tasks-panel.ts +130 -179
  63. package/src/panels/token-budget-panel.ts +2 -0
  64. package/src/panels/watchers-panel.ts +89 -137
  65. package/src/panels/worktree-panel.ts +1 -0
  66. package/src/panels/wrfc-panel.ts +2 -0
  67. package/src/renderer/agent-detail-modal.ts +2 -2
  68. package/src/renderer/ansi-sanitize.ts +76 -0
  69. package/src/renderer/buffer.ts +23 -1
  70. package/src/renderer/diff.ts +8 -0
  71. package/src/renderer/help-overlay.ts +48 -28
  72. package/src/renderer/markdown.ts +3 -145
  73. package/src/renderer/settings-modal-helpers.ts +27 -0
  74. package/src/renderer/settings-modal.ts +18 -1
  75. package/src/renderer/status-glyphs.ts +21 -0
  76. package/src/renderer/status-token.ts +4 -8
  77. package/src/renderer/tool-call.ts +4 -3
  78. package/src/runtime/bootstrap-core.ts +1 -1
  79. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  80. package/src/runtime/bootstrap.ts +7 -8
  81. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  82. package/src/shell/ui-openers.ts +1 -1
  83. package/src/version.ts +1 -1
@@ -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
  }
@@ -1,18 +1,16 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
2
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
3
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp/registry';
5
4
  import type { McpDecisionRecord } from '@pellux/goodvibes-sdk/platform/runtime/mcp/types';
5
+
6
+ type McpServerSecurityEntry = ReturnType<McpRegistry['listServerSecurity']>[number];
6
7
  import { truncateDisplay } from '../utils/terminal-width.ts';
7
8
  import {
8
- buildEmptyState,
9
9
  buildGuidanceLine,
10
10
  buildKeyValueLine,
11
11
  buildPanelLine,
12
- buildPanelWorkspace,
12
+ buildStatusPill,
13
13
  DEFAULT_PANEL_PALETTE,
14
- resolvePrimaryScrollableSection,
15
- type PanelWorkspaceSection,
16
14
  } from './polish.ts';
17
15
 
18
16
  const C = {
@@ -60,93 +58,102 @@ function decisionColor(decision: McpDecisionRecord): string {
60
58
  return decision.incoherent ? C.warn : C.ok;
61
59
  }
62
60
 
63
- export class McpPanel extends BasePanel {
61
+ export class McpPanel extends ScrollableListPanel<McpServerSecurityEntry> {
64
62
  private readonly registry: McpRegistry;
65
- private selectedIndex = 0;
66
- private scrollOffset = 0;
67
63
 
68
64
  public constructor(registry: McpRegistry) {
69
65
  super('mcp', 'MCP', 'Z', 'monitoring');
66
+ this.showSelectionGutter = true; // I5: non-color selection affordance
70
67
  this.registry = registry;
71
68
  }
72
69
 
70
+ protected override getPalette() { return C; }
71
+ protected override getEmptyStateMessage() { return ' No MCP servers configured or connected.'; }
72
+ protected override getEmptyStateActions() {
73
+ return [
74
+ { command: '/mcp', summary: 'list server state and security posture' },
75
+ { command: '/settings', summary: 'open the MCP settings category for trust and scope controls' },
76
+ ];
77
+ }
78
+
79
+ protected getItems(): readonly McpServerSecurityEntry[] {
80
+ return this.registry.listServerSecurity();
81
+ }
82
+
83
+ protected renderItem(entry: McpServerSecurityEntry, index: number, selected: boolean, width: number): Line {
84
+ const bg = selected ? C.selectBg : undefined;
85
+ return buildPanelLine(width, [
86
+ [' ', C.label, bg],
87
+ [entry.name.padEnd(20), C.value, bg],
88
+ ...buildStatusPill(entry.connected ? 'good' : 'bad', ` ${(entry.connected ? 'CONNECTED' : 'DISCONNECTED').padEnd(13)}`, { bg }),
89
+ [` ${entry.trustMode.padEnd(12)}`, modeColor(entry.trustMode), bg],
90
+ [` ${entry.role.padEnd(10)}`, C.info, bg],
91
+ [` ${entry.schemaFreshness}`, freshnessColor(entry.schemaFreshness), bg],
92
+ ]);
93
+ }
94
+
73
95
  public handleInput(key: string): boolean {
74
- const entries = this.registry.listServerSecurity();
75
96
  if (key === 'r') {
76
97
  this.markDirty();
77
98
  return true;
78
99
  }
79
- if (entries.length === 0) return false;
80
- if (key === 'up' || key === 'k') {
81
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
82
- this.markDirty();
83
- return true;
84
- }
85
- if (key === 'down' || key === 'j') {
86
- this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
87
- this.markDirty();
88
- return true;
89
- }
90
- return false;
100
+ return super.handleInput(key);
91
101
  }
92
102
 
93
103
  public render(width: number, height: number): Line[] {
94
- this.needsRender = false;
95
- const intro = 'Trust, quarantine, scope, and recent security decisions for configured MCP servers.';
104
+ this.clampSelection();
96
105
  const entries = this.registry.listServerSecurity();
106
+ const intro = 'Trust, quarantine, scope, and recent security decisions for configured MCP servers.';
97
107
 
98
- if (entries.length === 0) {
99
- const workspace = buildPanelWorkspace(width, height, {
100
- title: 'MCP Control Room',
101
- intro,
102
- sections: [{
103
- lines: buildEmptyState(
104
- width,
105
- ' No MCP servers configured or connected.',
106
- 'Add MCP servers, inspect trust posture, and review risk-scoped policies here once the registry is populated.',
107
- [
108
- { command: '/mcp', summary: 'list server state and security posture' },
109
- { command: '/settings', summary: 'open the MCP settings category for trust and scope controls' },
110
- ],
111
- C,
112
- ),
113
- }],
114
- palette: C,
115
- });
116
- while (workspace.length < height) workspace.push(createEmptyLine(width));
117
- return workspace;
118
- }
119
-
120
- this.selectedIndex = Math.min(this.selectedIndex, entries.length - 1);
121
- const selected = entries[this.selectedIndex]!;
122
- const sandboxBinding = this.registry.listServerSandboxBindings().find((entry) => entry.name === selected.name);
123
- const connected = entries.filter((entry) => entry.connected).length;
124
- const quarantined = entries.filter((entry) => entry.schemaFreshness === 'quarantined').length;
108
+ const connected = entries.filter((e) => e.connected).length;
109
+ const quarantined = entries.filter((e) => e.schemaFreshness === 'quarantined').length;
125
110
  const disconnected = entries.length - connected;
126
- const staleSchemas = entries.filter((entry) => entry.schemaFreshness !== 'fresh').length;
127
- const detailLines: Line[] = [
128
- buildPanelLine(width, [
111
+ const staleSchemas = entries.filter((e) => e.schemaFreshness !== 'fresh').length;
112
+
113
+ const headerLines: Line[] = [
114
+ buildPanelLine(width, [[' MCP posture', C.label]]),
115
+ buildKeyValueLine(width, [
116
+ { label: 'servers', value: String(entries.length), valueColor: C.value },
117
+ { label: 'connected', value: String(connected), valueColor: connected > 0 ? C.ok : C.dim },
118
+ { label: 'disconnected', value: String(disconnected), valueColor: disconnected > 0 ? C.warn : C.dim },
119
+ { label: 'stale schema', value: String(staleSchemas), valueColor: staleSchemas > 0 ? C.warn : C.dim },
120
+ { label: 'quarantined', value: String(quarantined), valueColor: quarantined > 0 ? C.error : C.dim },
121
+ ], C),
122
+ buildGuidanceLine(width, '/mcp review', 'inspect trust, freshness, and quarantine posture for configured servers', C),
123
+ buildGuidanceLine(width, '/mcp repair', 'review reconnect, auth, import, and startup remediation guidance', C),
124
+ ];
125
+
126
+ const selected = entries[this.selectedIndex];
127
+ const detailLines: Line[] = [];
128
+ const repairLines: Line[] = [];
129
+
130
+ if (selected) {
131
+ const sandboxBinding = this.registry.listServerSandboxBindings().find((e) => e.name === selected.name);
132
+ const decisions = this.registry.listRecentSecurityDecisions?.(24) ?? [];
133
+ const selectedDecision = decisions.find((d) => d.serverName === selected.name);
134
+
135
+ detailLines.push(buildPanelLine(width, [
129
136
  [' Server: ', C.label],
130
137
  [selected.name, C.value],
131
138
  [' Trust: ', C.label],
132
139
  [selected.trustMode, modeColor(selected.trustMode)],
133
140
  [' Role: ', C.label],
134
141
  [selected.role, C.info],
135
- ]),
136
- buildPanelLine(width, [
142
+ ]));
143
+ detailLines.push(buildPanelLine(width, [
137
144
  [' Schema: ', C.label],
138
145
  [selected.schemaFreshness, freshnessColor(selected.schemaFreshness)],
139
146
  [' Approved by: ', C.label],
140
147
  [truncateDisplay(selected.quarantineApprovedBy ?? 'n/a', Math.max(0, width - 31)), selected.quarantineApprovedBy ? C.info : C.dim],
141
- ]),
142
- buildPanelLine(width, [
148
+ ]));
149
+ detailLines.push(buildPanelLine(width, [
143
150
  [' Scope: ', C.label],
144
151
  [truncateDisplay(
145
152
  `paths ${selected.allowedPaths.length > 0 ? selected.allowedPaths.join(', ') : 'unbounded'} hosts ${selected.allowedHosts.length > 0 ? selected.allowedHosts.join(', ') : 'unbounded'}`,
146
153
  Math.max(0, width - 10),
147
154
  ), (selected.allowedPaths.length > 0 || selected.allowedHosts.length > 0) ? C.value : C.dim],
148
- ]),
149
- buildPanelLine(width, [
155
+ ]));
156
+ detailLines.push(buildPanelLine(width, [
150
157
  [' Sandbox: ', C.label],
151
158
  [truncateDisplay(
152
159
  sandboxBinding?.sessionId
@@ -154,107 +161,55 @@ export class McpPanel extends BasePanel {
154
161
  : 'not isolated',
155
162
  Math.max(0, width - 13),
156
163
  ), sandboxBinding?.sessionId ? C.info : C.dim],
157
- ]),
158
- ];
159
- if (selected.schemaFreshness === 'quarantined') {
160
- detailLines.push(buildPanelLine(width, [
161
- [' Quarantine: ', C.label],
162
- [truncateDisplay(`${selected.quarantineReason ?? 'unknown'}${selected.quarantineDetail ? ` - ${selected.quarantineDetail}` : ''}`, Math.max(0, width - 15)), C.error],
163
164
  ]));
164
- }
165
+ if (selected.schemaFreshness === 'quarantined') {
166
+ detailLines.push(buildPanelLine(width, [
167
+ [' Quarantine: ', C.label],
168
+ [truncateDisplay(`${selected.quarantineReason ?? 'unknown'}${selected.quarantineDetail ? ` - ${selected.quarantineDetail}` : ''}`, Math.max(0, width - 15)), C.error],
169
+ ]));
170
+ }
171
+ if (selectedDecision) {
172
+ const summary = `${selectedDecision.serverName}:${selectedDecision.toolName} ${selectedDecision.verdict.toUpperCase()} ${selectedDecision.capability}${selectedDecision.incoherent ? ' incoherent' : ''}`;
173
+ detailLines.push(buildPanelLine(width, [
174
+ [' Recent: ', C.label],
175
+ [truncateDisplay(summary, Math.max(0, width - 10)), decisionColor(selectedDecision)],
176
+ ]));
177
+ }
165
178
 
166
- const decisions = this.registry.listRecentSecurityDecisions?.(24) ?? [];
167
- const selectedDecision = decisions.find((decision) => decision.serverName === selected.name);
168
- if (selectedDecision) {
169
- const summary = `${selectedDecision.serverName}:${selectedDecision.toolName} ${selectedDecision.verdict.toUpperCase()} ${selectedDecision.capability}${selectedDecision.incoherent ? ' incoherent' : ''}`;
170
- detailLines.push(buildPanelLine(width, [
171
- [' Recent: ', C.label],
172
- [truncateDisplay(summary, Math.max(0, width - 10)), decisionColor(selectedDecision)],
173
- ]));
174
- }
175
- const decisionLines: Line[] = decisions.length === 0
176
- ? [buildPanelLine(width, [[' No MCP decisions recorded yet.', C.dim]])]
177
- : decisions.map((decision) => {
178
- const summary = `${decision.serverName}:${decision.toolName} ${decision.verdict.toUpperCase()} ${decision.capability}${decision.incoherent ? ' incoherent' : ''}`;
179
- return buildPanelLine(width, [
180
- [' ', C.label],
181
- [truncateDisplay(summary, Math.max(0, width - 2)), decisionColor(decision)],
182
- ]);
183
- });
179
+ if (!selected.connected) {
180
+ repairLines.push(buildPanelLine(width, [[' /mcp repair', C.warn], [' review reconnect and startup posture for this server', C.dim]]));
181
+ }
182
+ if (selected.schemaFreshness !== 'fresh') {
183
+ repairLines.push(buildPanelLine(width, [[' /mcp review', C.warn], [' inspect schema freshness, quarantine, and trust posture', C.dim]]));
184
+ }
185
+ if (sandboxBinding?.sessionId) {
186
+ repairLines.push(buildPanelLine(width, [[' /sandbox review', C.info], [' verify the bound MCP isolation session and startup status', C.dim]]));
187
+ }
188
+ if (repairLines.length === 0) {
189
+ repairLines.push(buildPanelLine(width, [[' No immediate MCP repair actions suggested for the selected server.', C.dim]]));
190
+ }
184
191
 
185
- const repairLines: Line[] = [];
186
- if (!selected.connected) {
187
- repairLines.push(buildPanelLine(width, [[' /mcp repair', C.warn], [' review reconnect and startup posture for this server', C.dim]]));
188
- }
189
- if (selected.schemaFreshness !== 'fresh') {
190
- repairLines.push(buildPanelLine(width, [[' /mcp review', C.warn], [' inspect schema freshness, quarantine, and trust posture', C.dim]]));
191
- }
192
- if (sandboxBinding?.sessionId) {
193
- repairLines.push(buildPanelLine(width, [[' /sandbox review', C.info], [' verify the bound MCP isolation session and startup status', C.dim]]));
192
+ const allDecisions = this.registry.listRecentSecurityDecisions?.(24) ?? [];
193
+ const decisionLines: Line[] = allDecisions.length === 0
194
+ ? [buildPanelLine(width, [[' No MCP decisions recorded yet.', C.dim]])]
195
+ : allDecisions.map((decision) => {
196
+ const summary = `${decision.serverName}:${decision.toolName} ${decision.verdict.toUpperCase()} ${decision.capability}${decision.incoherent ? ' incoherent' : ''}`;
197
+ return buildPanelLine(width, [
198
+ [' ', C.label],
199
+ [truncateDisplay(summary, Math.max(0, width - 2)), decisionColor(decision)],
200
+ ]);
201
+ });
202
+ detailLines.push(...repairLines);
203
+ detailLines.push(...decisionLines);
194
204
  }
195
- if (repairLines.length === 0) {
196
- repairLines.push(buildPanelLine(width, [[' No immediate MCP repair actions suggested for the selected server.', C.dim]]));
197
- }
198
- const postureSection: PanelWorkspaceSection = {
199
- title: 'MCP posture',
200
- lines: [
201
- buildKeyValueLine(width, [
202
- { label: 'servers', value: String(entries.length), valueColor: C.value },
203
- { label: 'connected', value: String(connected), valueColor: connected > 0 ? C.ok : C.dim },
204
- { label: 'disconnected', value: String(disconnected), valueColor: disconnected > 0 ? C.warn : C.dim },
205
- { label: 'stale schema', value: String(staleSchemas), valueColor: staleSchemas > 0 ? C.warn : C.dim },
206
- { label: 'quarantined', value: String(quarantined), valueColor: quarantined > 0 ? C.error : C.dim },
207
- ], C),
208
- buildGuidanceLine(width, '/mcp review', 'inspect trust, freshness, and quarantine posture for configured servers', C),
209
- buildGuidanceLine(width, '/mcp repair', 'review reconnect, auth, import, and startup remediation guidance', C),
210
- ],
211
- };
212
- const selectedSection: PanelWorkspaceSection = { title: 'Selected Server', lines: detailLines };
213
- const repairSection: PanelWorkspaceSection = { title: 'Repair', lines: repairLines };
214
- const decisionsSection: PanelWorkspaceSection = { title: 'Recent Decisions', lines: decisionLines };
215
- const resolvedServersSection = resolvePrimaryScrollableSection(width, height, {
216
- intro,
217
- footerLines: [buildPanelLine(width, [[' Up/Down move r refresh', C.dim]])],
218
- palette: C,
219
- beforeSections: [postureSection],
220
- section: {
221
- title: 'Servers',
222
- scrollableLines: entries.map((entry, absolute) => {
223
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
224
- return buildPanelLine(width, [
225
- [' ', C.label, bg],
226
- [entry.name.padEnd(20), C.value, bg],
227
- [` ${(entry.connected ? 'CONNECTED' : 'DISCONNECTED').padEnd(13)}`, entry.connected ? C.ok : C.error, bg],
228
- [` ${entry.trustMode.padEnd(12)}`, modeColor(entry.trustMode), bg],
229
- [` ${entry.role.padEnd(10)}`, C.info, bg],
230
- [` ${entry.schemaFreshness}`, freshnessColor(entry.schemaFreshness), bg],
231
- ]);
232
- }),
233
- selectedIndex: this.selectedIndex,
234
- scrollOffset: this.scrollOffset,
235
- guardRows: 1,
236
- minRows: 4,
237
- appendWindowSummary: { dimColor: C.dim },
238
- },
239
- afterSections: [selectedSection, repairSection, decisionsSection],
240
- });
241
- this.scrollOffset = resolvedServersSection.scrollOffset;
242
205
 
243
- const sections: PanelWorkspaceSection[] = [
244
- postureSection,
245
- resolvedServersSection.section,
246
- selectedSection,
247
- repairSection,
248
- decisionsSection,
249
- ];
250
- const lines = buildPanelWorkspace(width, height, {
206
+ return this.renderList(width, height, {
251
207
  title: 'MCP Control Room',
252
- intro,
253
- sections,
254
- footerLines: [buildPanelLine(width, [[' Up/Down move r refresh', C.dim]])],
255
- palette: C,
208
+ header: headerLines,
209
+ footer: [
210
+ ...detailLines,
211
+ buildPanelLine(width, [[' Up/Down move r refresh', C.dim]]),
212
+ ],
256
213
  });
257
- while (lines.length < height) lines.push(createEmptyLine(width));
258
- return lines.slice(0, height);
259
214
  }
260
215
  }