@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,6 +1,5 @@
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 { TokenAuditResult } from '@pellux/goodvibes-sdk/platform/security/token-audit';
5
4
  import type { UiReadModel, UiSecuritySnapshot } from '../runtime/ui-read-models.ts';
6
5
  import {
@@ -8,10 +7,10 @@ import {
8
7
  buildGuidanceLine,
9
8
  buildPanelLine,
10
9
  buildPanelWorkspace,
11
- resolveScrollablePanelSection,
10
+ buildStatusPill,
12
11
  DEFAULT_PANEL_PALETTE,
13
- type PanelWorkspaceSection,
14
12
  } from './polish.ts';
13
+ import { createEmptyLine } from '../types/grid.ts';
15
14
 
16
15
  const C = {
17
16
  ...DEFAULT_PANEL_PALETTE,
@@ -58,12 +57,12 @@ function severityColor(severity: 'low' | 'medium' | 'high' | 'critical'): string
58
57
  }
59
58
  }
60
59
 
61
- export class SecurityPanel extends BasePanel {
62
- private selectedIndex = 0;
60
+ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
63
61
  private readonly unsub: (() => void) | null;
64
62
 
65
63
  public constructor(private readonly readModel: UiReadModel<UiSecuritySnapshot>) {
66
64
  super('security', 'Security', 'U', 'monitoring');
65
+ this.showSelectionGutter = true; // I5: non-color selection affordance
67
66
  this.unsub = this.readModel.subscribe(() => this.markDirty());
68
67
  }
69
68
 
@@ -71,28 +70,41 @@ export class SecurityPanel extends BasePanel {
71
70
  this.unsub?.();
72
71
  }
73
72
 
73
+ protected override getPalette() { return C; }
74
+ protected override getEmptyStateMessage() { return ' No API tokens are registered with the security auditor yet.'; }
75
+ protected override getEmptyStateActions() {
76
+ return [
77
+ { command: '/storage review', summary: 'inspect secure secret storage and environment overrides' },
78
+ { command: '/policy preflight', summary: 'run a live preflight posture review' },
79
+ { command: '/mcp trust', summary: 'inspect active MCP trust and quarantine posture' },
80
+ ];
81
+ }
82
+
83
+ protected getItems(): readonly TokenAuditResult[] {
84
+ return this.readModel.getSnapshot().audit.results;
85
+ }
86
+
87
+ protected renderItem(result: TokenAuditResult, index: number, selected: boolean, width: number): Line {
88
+ const bg = selected ? C.selectBg : undefined;
89
+ return buildPanelLine(width, [
90
+ [' ', C.label, bg],
91
+ [result.label.padEnd(22), C.value, bg],
92
+ [` ${result.tokenId.padEnd(12)}`, C.info, bg],
93
+ [` ${result.scope.policyId.padEnd(10)}`, C.label, bg],
94
+ [` ${resultSummary(result).slice(0, Math.max(0, width - 49))}`, resultColor(result), bg],
95
+ ]);
96
+ }
97
+
74
98
  public handleInput(key: string): boolean {
75
- const snapshot = this.readModel.getSnapshot();
76
99
  if (key === 'r') {
77
100
  this.markDirty();
78
101
  return true;
79
102
  }
80
- if (snapshot.audit.results.length === 0) return false;
81
- if (key === 'up' || key === 'k') {
82
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
83
- this.markDirty();
84
- return true;
85
- }
86
- if (key === 'down' || key === 'j') {
87
- this.selectedIndex = Math.min(snapshot.audit.results.length - 1, this.selectedIndex + 1);
88
- this.markDirty();
89
- return true;
90
- }
91
- return false;
103
+ return super.handleInput(key);
92
104
  }
93
105
 
94
106
  public render(width: number, height: number): Line[] {
95
- this.needsRender = false;
107
+ this.clampSelection();
96
108
  const snapshot = this.readModel.getSnapshot();
97
109
  const view = snapshot.audit;
98
110
  const preflightStatus = snapshot.policy.preflightStatus;
@@ -105,9 +117,8 @@ export class SecurityPanel extends BasePanel {
105
117
  const quarantinedPlugins = snapshot.quarantinedPlugins;
106
118
  const untrustedPlugins = snapshot.untrustedPlugins;
107
119
  const attackPathReview = snapshot.attackPathReview;
108
- const footerLines = [
109
- buildGuidanceLine(width, '/policy preflight', 'run a proactive policy review before risky work starts', C),
110
- ] as const;
120
+ const intro = 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.';
121
+ const footerLine = buildGuidanceLine(width, '/policy preflight', 'run a proactive policy review before risky work starts', C);
111
122
 
112
123
  const governanceLines: Line[] = [
113
124
  buildPanelLine(width, [
@@ -116,214 +127,169 @@ export class SecurityPanel extends BasePanel {
116
127
  [' tokens ', C.label],
117
128
  [String(view.totalTokens), C.value],
118
129
  [' blocked ', C.label],
119
- [String(view.blocked.length), view.blocked.length > 0 ? C.error : C.ok],
130
+ ...buildStatusPill(view.blocked.length > 0 ? 'bad' : 'good', String(view.blocked.length)),
120
131
  [' scope violations ', C.label],
121
- [String(view.scopeViolations.length), view.scopeViolations.length > 0 ? C.error : C.ok],
132
+ ...buildStatusPill(view.scopeViolations.length > 0 ? 'bad' : 'good', String(view.scopeViolations.length)),
122
133
  [' overdue ', C.label],
123
- [String(view.rotationOverdue.length), view.rotationOverdue.length > 0 ? C.error : C.ok],
134
+ ...buildStatusPill(view.rotationOverdue.length > 0 ? 'bad' : 'good', String(view.rotationOverdue.length)),
124
135
  [' warnings ', C.label],
125
- [String(view.rotationWarnings.length), view.rotationWarnings.length > 0 ? C.warn : C.ok],
136
+ ...buildStatusPill(view.rotationWarnings.length > 0 ? 'warn' : 'good', String(view.rotationWarnings.length)),
126
137
  ]),
127
138
  buildPanelLine(width, [
128
139
  [' preflight ', C.label],
129
- [preflightStatus.toUpperCase(), preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : preflightStatus === 'pass' ? C.ok : C.dim],
140
+ ...buildStatusPill(preflightStatus === 'block' ? 'bad' : preflightStatus === 'warn' ? 'warn' : preflightStatus === 'pass' ? 'good' : 'info', preflightStatus.toUpperCase()),
130
141
  [' issues ', C.label],
131
- [String(preflightIssueCount), preflightIssueCount > 0 ? C.warn : C.ok],
142
+ ...buildStatusPill(preflightIssueCount > 0 ? 'warn' : 'good', String(preflightIssueCount)),
132
143
  [' lint ', C.label],
133
- [String(lintFindingCount), lintFindingCount > 0 ? C.warn : C.ok],
144
+ ...buildStatusPill(lintFindingCount > 0 ? 'warn' : 'good', String(lintFindingCount)),
134
145
  [' denied permissions ', C.label],
135
- [String(snapshot.deniedPermissions), snapshot.deniedPermissions > 0 ? C.warn : C.ok],
146
+ ...buildStatusPill(snapshot.deniedPermissions > 0 ? 'warn' : 'good', String(snapshot.deniedPermissions)),
136
147
  ]),
137
- ];
138
-
139
- const threatLines: Line[] = [
140
148
  buildPanelLine(width, [
141
149
  [' quarantined MCP ', C.label],
142
- [String(quarantinedMcp.length), quarantinedMcp.length > 0 ? C.error : C.ok],
150
+ ...buildStatusPill(quarantinedMcp.length > 0 ? 'bad' : 'good', String(quarantinedMcp.length)),
143
151
  [' elevated MCP ', C.label],
144
- [String(elevatedMcp.length), elevatedMcp.length > 0 ? C.warn : C.ok],
152
+ ...buildStatusPill(elevatedMcp.length > 0 ? 'warn' : 'good', String(elevatedMcp.length)),
145
153
  [' quarantined plugins ', C.label],
146
- [String(quarantinedPlugins.length), quarantinedPlugins.length > 0 ? C.error : C.ok],
154
+ ...buildStatusPill(quarantinedPlugins.length > 0 ? 'bad' : 'good', String(quarantinedPlugins.length)),
147
155
  [' untrusted plugins ', C.label],
148
- [String(untrustedPlugins.length), untrustedPlugins.length > 0 ? C.warn : C.ok],
156
+ ...buildStatusPill(untrustedPlugins.length > 0 ? 'warn' : 'good', String(untrustedPlugins.length)),
149
157
  ]),
150
158
  buildPanelLine(width, [
151
159
  [' incidents ', C.label],
152
- [String(incidents.length), incidents.length > 0 ? C.warn : C.ok],
160
+ ...buildStatusPill(incidents.length > 0 ? 'warn' : 'good', String(incidents.length)),
153
161
  ]),
154
162
  ];
155
163
 
156
164
  const attackPathLines: Line[] = [
157
165
  buildPanelLine(width, [
158
166
  [' attack paths ', C.label],
159
- [String(attackPathReview.criticalFindings), attackPathReview.criticalFindings > 0 ? C.error : C.ok],
167
+ ...buildStatusPill(attackPathReview.criticalFindings > 0 ? 'bad' : 'good', String(attackPathReview.criticalFindings)),
160
168
  [' critical ', C.label],
161
- [String(attackPathReview.incoherentFindings), attackPathReview.incoherentFindings > 0 ? C.warn : C.ok],
169
+ ...buildStatusPill(attackPathReview.incoherentFindings > 0 ? 'warn' : 'good', String(attackPathReview.incoherentFindings)),
162
170
  [' review ', C.label],
163
171
  [attackPathReview.summary.slice(0, Math.max(0, width - 36)), C.dim],
164
172
  ]),
165
173
  ];
166
174
 
175
+ // Empty state: no token results yet — show governance + threat posture before base empty state
167
176
  if (view.results.length === 0) {
168
- const emptyLines = [
177
+ const emptyStateLines = [
169
178
  ...governanceLines,
170
- ...threatLines,
171
179
  ...buildEmptyState(
172
180
  width,
173
- ' No API tokens are registered with the security auditor yet.',
181
+ this.getEmptyStateMessage(),
174
182
  'The security control room can already review policy, MCP, plugin, and incident posture, but token-specific scope and rotation audit data has not been registered.',
175
- [
176
- { command: '/storage review', summary: 'inspect secure secret storage and environment overrides' },
177
- { command: '/policy preflight', summary: 'run a live preflight posture review' },
178
- { command: '/mcp trust', summary: 'inspect active MCP trust and quarantine posture' },
179
- ],
183
+ this.getEmptyStateActions(),
180
184
  C,
181
185
  ),
182
186
  ];
183
187
  if (quarantinedMcp.length > 0) {
184
- emptyLines.push(buildPanelLine(width, [[' MCP quarantine still active despite no registered tokens.', C.warn]]));
188
+ emptyStateLines.push(buildPanelLine(width, [[' MCP quarantine still active despite no registered tokens.', C.warn]]));
185
189
  }
186
190
  const workspace = buildPanelWorkspace(width, height, {
187
191
  title: 'Security Control Room',
188
- intro: 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.',
192
+ intro,
189
193
  sections: [
190
- { title: 'Governance', lines: emptyLines },
194
+ { title: 'Governance', lines: emptyStateLines },
191
195
  { title: 'Attack Paths', lines: attackPathLines },
192
196
  ],
193
- footerLines,
197
+ footerLines: [footerLine],
194
198
  palette: C,
195
199
  });
196
200
  while (workspace.length < height) workspace.push(createEmptyLine(width));
197
201
  return workspace.slice(0, height);
198
202
  }
199
203
 
200
- this.selectedIndex = Math.min(this.selectedIndex, view.results.length - 1);
201
- const selected = view.results[this.selectedIndex]!;
202
- const tokenRows: Line[] = [];
203
- for (let index = 0; index < view.results.length; index++) {
204
- const result = view.results[index]!;
205
- const bg = index === this.selectedIndex ? C.selectBg : undefined;
206
- tokenRows.push(buildPanelLine(width, [
207
- [' ', C.label, bg],
208
- [result.label.padEnd(22), C.value, bg],
209
- [` ${result.tokenId.padEnd(12)}`, C.info, bg],
210
- [` ${result.scope.policyId.padEnd(10)}`, C.label, bg],
211
- [` ${resultSummary(result).slice(0, Math.max(0, width - 49))}`, resultColor(result), bg],
212
- ]));
204
+ if (attackPathReview.findings.length > 0) {
205
+ attackPathLines.push(buildPanelLine(width, [[' MCP attack-path review', C.label]]));
206
+ for (const finding of attackPathReview.findings.slice(0, 3)) {
207
+ attackPathLines.push(buildPanelLine(width, [[
208
+ ` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`.slice(0, width),
209
+ severityColor(finding.severity),
210
+ ]]));
211
+ attackPathLines.push(buildPanelLine(width, [[
212
+ ` ${finding.reason}`.slice(0, width),
213
+ C.dim,
214
+ ]]));
215
+ attackPathLines.push(buildPanelLine(width, [[
216
+ ` evidence: ${finding.evidence.join(' | ')}`.slice(0, width),
217
+ C.dim,
218
+ ]]));
219
+ }
213
220
  }
214
221
 
215
- const detailLines: Line[] = [
216
- buildPanelLine(width, [
222
+ const selected = view.results[this.selectedIndex];
223
+ const detailLines: Line[] = [];
224
+ if (selected) {
225
+ detailLines.push(buildPanelLine(width, [
217
226
  [' Token: ', C.label],
218
227
  [selected.label, C.value],
219
228
  [' Policy: ', C.label],
220
229
  [selected.scope.policyId, C.info],
221
230
  [' Blocked: ', C.label],
222
231
  [selected.blocked ? 'yes' : 'no', selected.blocked ? C.error : C.ok],
223
- ]),
224
- buildPanelLine(width, [
232
+ ]));
233
+ detailLines.push(buildPanelLine(width, [
225
234
  [' Scope: ', C.label],
226
235
  [selected.scope.outcome, selected.scope.outcome === 'violation' ? C.error : C.ok],
227
236
  [' Excess: ', C.label],
228
237
  [(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none').slice(0, Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.error : C.dim],
229
- ]),
230
- buildPanelLine(width, [
238
+ ]));
239
+ detailLines.push(buildPanelLine(width, [
231
240
  [' Rotation: ', C.label],
232
241
  [selected.rotation.outcome, selected.rotation.outcome === 'ok' ? C.ok : selected.rotation.outcome === 'warning' ? C.warn : C.error],
233
242
  [' Due: ', C.label],
234
243
  [new Date(selected.rotation.dueAt).toISOString(), C.value],
235
244
  [' Age(d): ', C.label],
236
245
  [String(Math.floor(selected.rotation.ageMs / (24 * 60 * 60 * 1000))), C.value],
237
- ]),
238
- buildPanelLine(width, [[
246
+ ]));
247
+ detailLines.push(buildPanelLine(width, [[
239
248
  `Last audit: ${view.lastAuditAt ? new Date(view.lastAuditAt).toISOString() : 'never'} Press r to refresh.`,
240
249
  C.dim,
241
- ]]),
242
- ];
243
-
244
- if (preflightStatus !== 'n/a') {
245
- detailLines.push(buildPanelLine(width, [[
246
- `Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`.slice(0, width),
247
- preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : C.dim,
248
- ]]));
249
- }
250
- if (quarantinedMcp.length > 0) {
251
- const server = quarantinedMcp[0]!;
252
- detailLines.push(buildPanelLine(width, [[
253
- `MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`.slice(0, width),
254
- C.error,
255
- ]]));
256
- }
257
- if (quarantinedPlugins.length > 0) {
258
- const plugin = quarantinedPlugins[0]!;
259
- detailLines.push(buildPanelLine(width, [[
260
- `Plugin quarantine: ${plugin.name} (${plugin.trustTier})`.slice(0, width),
261
- C.error,
262
- ]]));
263
- } else if (untrustedPlugins.length > 0) {
264
- const plugin = untrustedPlugins[0]!;
265
- detailLines.push(buildPanelLine(width, [[
266
- `Plugin trust warning: ${plugin.name} remains untrusted.`.slice(0, width),
267
- C.warn,
268
250
  ]]));
269
- }
270
- if (latestIncident) {
271
- detailLines.push(buildPanelLine(width, [[
272
- `Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`.slice(0, width),
273
- C.warn,
274
- ]]));
275
- }
276
- if (attackPathReview.findings.length > 0) {
277
- attackPathLines.push(buildPanelLine(width, [[' MCP attack-path review', C.label]]));
278
- for (const finding of attackPathReview.findings.slice(0, 3)) {
279
- attackPathLines.push(buildPanelLine(width, [[
280
- ` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`.slice(0, width),
281
- severityColor(finding.severity),
251
+ if (preflightStatus !== 'n/a') {
252
+ detailLines.push(buildPanelLine(width, [[
253
+ `Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`.slice(0, width),
254
+ preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : C.dim,
282
255
  ]]));
283
- attackPathLines.push(buildPanelLine(width, [[
284
- ` ${finding.reason}`.slice(0, width),
285
- C.dim,
256
+ }
257
+ if (quarantinedMcp.length > 0) {
258
+ const server = quarantinedMcp[0]!;
259
+ detailLines.push(buildPanelLine(width, [[
260
+ `MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`.slice(0, width),
261
+ C.error,
286
262
  ]]));
287
- attackPathLines.push(buildPanelLine(width, [[
288
- ` evidence: ${finding.evidence.join(' | ')}`.slice(0, width),
289
- C.dim,
263
+ }
264
+ if (quarantinedPlugins.length > 0) {
265
+ const plugin = quarantinedPlugins[0]!;
266
+ detailLines.push(buildPanelLine(width, [[
267
+ `Plugin quarantine: ${plugin.name} (${plugin.trustTier})`.slice(0, width),
268
+ C.error,
269
+ ]]));
270
+ } else if (untrustedPlugins.length > 0) {
271
+ const plugin = untrustedPlugins[0]!;
272
+ detailLines.push(buildPanelLine(width, [[
273
+ `Plugin trust warning: ${plugin.name} remains untrusted.`.slice(0, width),
274
+ C.warn,
275
+ ]]));
276
+ }
277
+ if (latestIncident) {
278
+ detailLines.push(buildPanelLine(width, [[
279
+ `Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`.slice(0, width),
280
+ C.warn,
290
281
  ]]));
291
282
  }
292
283
  }
293
284
 
294
- const governanceSection: PanelWorkspaceSection = { title: 'Governance', lines: governanceLines };
295
- const trustSection: PanelWorkspaceSection = { title: 'Policy And Trust', lines: threatLines };
296
- const selectedSection: PanelWorkspaceSection = { title: 'Selected Token', lines: detailLines };
297
- const attackPathSection: PanelWorkspaceSection = { title: 'Attack Paths', lines: attackPathLines };
298
- const tokenAuditSection = resolveScrollablePanelSection(width, height, {
299
- intro: 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.',
300
- footerLines,
301
- palette: C,
302
- beforeSections: [governanceSection, trustSection],
303
- section: {
304
- title: 'Token Audit',
305
- scrollableLines: tokenRows,
306
- selectedIndex: this.selectedIndex,
307
- scrollOffset: this.selectedIndex,
308
- minRows: 1,
309
- },
310
- afterSections: [selectedSection, attackPathSection],
311
- });
312
-
313
- const lines = buildPanelWorkspace(width, height, {
285
+ return this.renderList(width, height, {
314
286
  title: 'Security Control Room',
315
- intro: 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.',
316
- sections: [
317
- governanceSection,
318
- trustSection,
319
- tokenAuditSection.section,
320
- selectedSection,
321
- attackPathSection,
322
- ] satisfies readonly PanelWorkspaceSection[],
323
- footerLines,
324
- palette: C,
287
+ header: governanceLines,
288
+ footer: [
289
+ ...detailLines,
290
+ ...attackPathLines,
291
+ footerLine,
292
+ ],
325
293
  });
326
- while (lines.length < height) lines.push(createEmptyLine(width));
327
- return lines.slice(0, height);
328
294
  }
329
295
  }
@@ -1,6 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import {
5
5
  type ServiceConfig,
6
6
  type ServiceInspection,
@@ -11,9 +11,8 @@ import {
11
11
  buildEmptyState,
12
12
  buildPanelLine,
13
13
  buildPanelWorkspace,
14
+ buildStatusPill,
14
15
  DEFAULT_PANEL_PALETTE,
15
- resolvePrimaryScrollableSection,
16
- type PanelWorkspaceSection,
17
16
  } from './polish.ts';
18
17
 
19
18
  const C = {
@@ -54,7 +53,6 @@ function statusColor(entry: ServicePanelEntry): string {
54
53
 
55
54
  function authSummary(config: ServiceConfig, manager: SubscriptionAccessQuery): string {
56
55
  const provider = config.providerId ?? config.name;
57
- const hasActiveSubscription = manager.get(provider) != null;
58
56
  const hasOverride = manager.getAccessToken(provider) != null;
59
57
  switch (config.authType) {
60
58
  case 'bearer':
@@ -66,16 +64,14 @@ function authSummary(config: ServiceConfig, manager: SubscriptionAccessQuery): s
66
64
  ? 'oauth-override'
67
65
  : config.apiKeyHeader ? `api-key:${config.apiKeyHeader}` : 'api-key';
68
66
  case 'oauth':
69
- return hasActiveSubscription ? 'oauth(active)' : 'oauth';
67
+ return manager.get(provider) != null ? 'oauth(active)' : 'oauth';
70
68
  }
71
69
  }
72
70
 
73
- export class ServicesPanel extends BasePanel {
71
+ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
74
72
  private readonly registry: ServiceInspectionQuery;
75
73
  private readonly subscriptionManager: SubscriptionAccessQuery;
76
74
  private entries: ServicePanelEntry[] = [];
77
- private selectedIndex = 0;
78
- private scrollOffset = 0;
79
75
  private loading = false;
80
76
 
81
77
  public constructor(
@@ -83,6 +79,7 @@ export class ServicesPanel extends BasePanel {
83
79
  subscriptionManager: SubscriptionAccessQuery,
84
80
  ) {
85
81
  super('services', 'Services', 'V', 'monitoring');
82
+ this.showSelectionGutter = true; // I5: non-color selection affordance
86
83
  this.registry = registry;
87
84
  this.subscriptionManager = subscriptionManager;
88
85
  void this.refresh();
@@ -95,27 +92,40 @@ export class ServicesPanel extends BasePanel {
95
92
  }
96
93
  }
97
94
 
95
+ protected override getPalette() { return C; }
96
+ protected override getEmptyStateMessage() { return ' No services configured.'; }
97
+ protected override getEmptyStateActions() {
98
+ return [
99
+ { command: '/services auth-review', summary: 'inspect service auth posture and registry config' },
100
+ { command: '/subscription', summary: 'review provider login state and override posture' },
101
+ ];
102
+ }
103
+
104
+ protected getItems(): readonly ServicePanelEntry[] {
105
+ return this.entries;
106
+ }
107
+
108
+ protected renderItem(entry: ServicePanelEntry, index: number, selected: boolean, width: number): Line {
109
+ const bg = selected ? C.selectBg : undefined;
110
+ return buildPanelLine(width, [
111
+ [' ', C.label, bg],
112
+ [entry.name.padEnd(16), C.value, bg],
113
+ [` ${statusLabel(entry).padEnd(12)}`, statusColor(entry), bg],
114
+ [` ${authSummary(entry.inspection.config, this.subscriptionManager).padEnd(18)}`, C.info, bg],
115
+ [` ${entry.inspection.config.baseUrl ?? '(no baseUrl)'}`, C.dim, bg],
116
+ ]);
117
+ }
118
+
98
119
  public handleInput(key: string): boolean {
99
120
  if (key === 'r') {
100
121
  void this.refresh();
101
122
  return true;
102
123
  }
103
- if (this.entries.length === 0) return false;
104
- if (key === 'up' || key === 'k') {
105
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
106
- this.markDirty();
107
- return true;
108
- }
109
- if (key === 'down' || key === 'j') {
110
- this.selectedIndex = Math.min(this.entries.length - 1, this.selectedIndex + 1);
111
- this.markDirty();
112
- return true;
113
- }
114
124
  if (key === 't') {
115
125
  void this.testSelected();
116
126
  return true;
117
127
  }
118
- return false;
128
+ return super.handleInput(key);
119
129
  }
120
130
 
121
131
  private async refresh(): Promise<void> {
@@ -152,7 +162,7 @@ export class ServicesPanel extends BasePanel {
152
162
  }
153
163
 
154
164
  public render(width: number, height: number): Line[] {
155
- this.needsRender = false;
165
+ this.clampSelection();
156
166
  const intro = 'Credential posture, subscription overrides, and live connection checks for configured services.';
157
167
 
158
168
  if (this.loading && this.entries.length === 0) {
@@ -166,106 +176,54 @@ export class ServicesPanel extends BasePanel {
166
176
  return lines;
167
177
  }
168
178
 
169
- if (this.entries.length === 0) {
170
- const workspace = buildPanelWorkspace(width, height, {
171
- title: 'Service Control Room',
172
- intro,
173
- sections: [{
174
- lines: buildEmptyState(
175
- width,
176
- ' No services configured.',
177
- 'Add entries to .goodvibes/tui/services.json and store secrets before using service-backed product flows.',
178
- [
179
- { command: '/services auth-review', summary: 'inspect service auth posture and registry config' },
180
- { command: '/subscription', summary: 'review provider login state and override posture' },
181
- ],
182
- C,
183
- ),
184
- }],
185
- palette: C,
186
- });
187
- while (workspace.length < height) workspace.push(createEmptyLine(width));
188
- return workspace;
189
- }
190
-
191
- const selected = this.entries[this.selectedIndex]!;
192
- const inspect = selected.inspection;
193
- const detailLines: Line[] = [
194
- buildPanelLine(width, [
179
+ const selected = this.entries[this.selectedIndex];
180
+ const detailLines: Line[] = [];
181
+ if (selected) {
182
+ const inspect = selected.inspection;
183
+ detailLines.push(buildPanelLine(width, [
195
184
  [' Service: ', C.label],
196
185
  [selected.name, C.value],
197
186
  [' State: ', C.label],
198
187
  [statusLabel(selected), statusColor(selected)],
199
188
  [' Auth: ', C.label],
200
189
  [authSummary(inspect.config, this.subscriptionManager), C.info],
201
- ]),
202
- buildPanelLine(width, [
190
+ ]));
191
+ detailLines.push(buildPanelLine(width, [
203
192
  [' Primary credential: ', C.label],
204
- [inspect.hasPrimaryCredential ? 'present' : 'missing', inspect.hasPrimaryCredential ? C.ok : C.error],
193
+ ...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
205
194
  [' Webhook URL: ', C.label],
206
- [inspect.hasWebhookUrl ? 'present' : 'missing', inspect.hasWebhookUrl ? C.ok : C.dim],
195
+ ...buildStatusPill(inspect.hasWebhookUrl ? 'good' : 'info', inspect.hasWebhookUrl ? 'present' : 'missing'),
207
196
  [' Signing secret: ', C.label],
208
- [inspect.hasSigningSecret ? 'present' : 'missing', inspect.hasSigningSecret ? C.ok : C.dim],
209
- ]),
210
- ];
211
- if (selected.lastTest) {
212
- detailLines.push(buildPanelLine(width, [
213
- [' Last test: ', C.label],
214
- [selected.lastTest.ok ? 'ok' : 'failed', selected.lastTest.ok ? C.ok : C.error],
215
- [' Status: ', C.label],
216
- [selected.lastTest.status != null ? String(selected.lastTest.status) : 'n/a', C.value],
217
- [' URL: ', C.label],
218
- [(selected.lastTest.testedUrl ?? 'n/a').slice(0, Math.max(0, width - 34)), C.dim],
197
+ ...buildStatusPill(inspect.hasSigningSecret ? 'good' : 'info', inspect.hasSigningSecret ? 'present' : 'missing'),
219
198
  ]));
220
- if (selected.lastTest.error) {
199
+ if (selected.lastTest) {
221
200
  detailLines.push(buildPanelLine(width, [
222
- [' Error: ', C.label],
223
- [selected.lastTest.error.slice(0, Math.max(0, width - 10)), C.error],
201
+ [' Last test: ', C.label],
202
+ ...buildStatusPill(selected.lastTest.ok ? 'good' : 'bad', selected.lastTest.ok ? 'ok' : 'failed'),
203
+ [' Status: ', C.label],
204
+ [selected.lastTest.status != null ? String(selected.lastTest.status) : 'n/a', C.value],
205
+ [' URL: ', C.label],
206
+ [(selected.lastTest.testedUrl ?? 'n/a').slice(0, Math.max(0, width - 34)), C.dim],
224
207
  ]));
208
+ if (selected.lastTest.error) {
209
+ detailLines.push(buildPanelLine(width, [
210
+ [' Error: ', C.label],
211
+ [selected.lastTest.error.slice(0, Math.max(0, width - 10)), C.error],
212
+ ]));
213
+ }
214
+ } else {
215
+ detailLines.push(buildPanelLine(width, [[' Press t to test the selected service or r to refresh credential status.', C.dim]]));
225
216
  }
226
- } else {
227
- detailLines.push(buildPanelLine(width, [[' Press t to test the selected service or r to refresh credential status.', C.dim]]));
217
+ detailLines.push(buildPanelLine(width, [[' Services resolve credentials through hierarchy-aware secure storage, plaintext fallback policy, and project-local config.', C.dim]]));
228
218
  }
229
- detailLines.push(buildPanelLine(width, [[' Services resolve credentials through hierarchy-aware secure storage, plaintext fallback policy, and project-local config.', C.dim]]));
230
- const detailSection: PanelWorkspaceSection = { title: 'Details', lines: detailLines };
231
- const resolvedServicesSection = resolvePrimaryScrollableSection(width, height, {
232
- intro,
233
- footerLines: [buildPanelLine(width, [[' Up/Down move t test selected service r refresh inspections', C.dim]])],
234
- palette: C,
235
- section: {
236
- title: 'Services',
237
- scrollableLines: this.entries.map((entry, absolute) => {
238
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
239
- return buildPanelLine(width, [
240
- [' ', C.label, bg],
241
- [entry.name.padEnd(16), C.value, bg],
242
- [` ${statusLabel(entry).padEnd(12)}`, statusColor(entry), bg],
243
- [` ${authSummary(entry.inspection.config, this.subscriptionManager).padEnd(18)}`, C.info, bg],
244
- [` ${entry.inspection.config.baseUrl ?? '(no baseUrl)'}`, C.dim, bg],
245
- ]);
246
- }),
247
- selectedIndex: this.selectedIndex,
248
- scrollOffset: this.scrollOffset,
249
- guardRows: 1,
250
- minRows: 4,
251
- appendWindowSummary: { dimColor: C.dim },
252
- },
253
- afterSections: [detailSection],
254
- });
255
- this.scrollOffset = resolvedServicesSection.scrollOffset;
256
219
 
257
- const sections: PanelWorkspaceSection[] = [
258
- resolvedServicesSection.section,
259
- detailSection,
260
- ];
261
- const lines = buildPanelWorkspace(width, height, {
220
+ return this.renderList(width, height, {
262
221
  title: 'Service Control Room',
263
- intro,
264
- sections,
265
- footerLines: [buildPanelLine(width, [[' Up/Down move t test selected service r refresh inspections', C.dim]])],
266
- palette: C,
222
+ footer: [
223
+ ...detailLines,
224
+ buildPanelLine(width, [[' Up/Down move t test selected service r refresh inspections', C.dim]]),
225
+ ],
226
+ emptyMessage: intro,
267
227
  });
268
- while (lines.length < height) lines.push(createEmptyLine(width));
269
- return lines.slice(0, height);
270
228
  }
271
229
  }