@pellux/goodvibes-tui 0.18.19 → 0.18.23

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 (42) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-rendering.ts +20 -6
  5. package/src/input/commands/session.ts +0 -1
  6. package/src/input/feed-context-factory.ts +236 -0
  7. package/src/input/handler-feed-routes.ts +10 -0
  8. package/src/input/handler-feed.ts +44 -6
  9. package/src/input/handler-shortcuts.ts +138 -125
  10. package/src/input/handler.ts +121 -119
  11. package/src/input/keybindings.ts +30 -0
  12. package/src/panels/approval-panel.ts +54 -74
  13. package/src/panels/automation-control-panel.ts +119 -161
  14. package/src/panels/base-panel.ts +71 -0
  15. package/src/panels/communication-panel.ts +68 -107
  16. package/src/panels/confirm-state.ts +61 -0
  17. package/src/panels/control-plane-panel.ts +116 -172
  18. package/src/panels/git-panel.ts +9 -0
  19. package/src/panels/hooks-panel.ts +101 -138
  20. package/src/panels/incident-review-panel.ts +55 -107
  21. package/src/panels/knowledge-panel.ts +63 -14
  22. package/src/panels/local-auth-panel.ts +76 -93
  23. package/src/panels/marketplace-panel.ts +19 -12
  24. package/src/panels/mcp-panel.ts +108 -155
  25. package/src/panels/ops-control-panel.ts +50 -85
  26. package/src/panels/panel-manager.ts +22 -2
  27. package/src/panels/plugins-panel.ts +36 -60
  28. package/src/panels/routes-panel.ts +89 -141
  29. package/src/panels/scrollable-list-panel.ts +71 -16
  30. package/src/panels/security-panel.ts +101 -137
  31. package/src/panels/services-panel.ts +58 -102
  32. package/src/panels/settings-sync-panel.ts +76 -122
  33. package/src/panels/skills-panel.ts +44 -0
  34. package/src/panels/subscription-panel.ts +69 -80
  35. package/src/panels/tasks-panel.ts +129 -179
  36. package/src/panels/watchers-panel.ts +88 -137
  37. package/src/renderer/buffer.ts +11 -0
  38. package/src/renderer/diff.ts +8 -0
  39. package/src/renderer/help-overlay.ts +37 -28
  40. package/src/renderer/markdown.ts +3 -145
  41. package/src/renderer/status-token.ts +71 -0
  42. 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,9 @@ import {
8
7
  buildGuidanceLine,
9
8
  buildPanelLine,
10
9
  buildPanelWorkspace,
11
- resolveScrollablePanelSection,
12
10
  DEFAULT_PANEL_PALETTE,
13
- type PanelWorkspaceSection,
14
11
  } from './polish.ts';
12
+ import { createEmptyLine } from '../types/grid.ts';
15
13
 
16
14
  const C = {
17
15
  ...DEFAULT_PANEL_PALETTE,
@@ -58,8 +56,7 @@ function severityColor(severity: 'low' | 'medium' | 'high' | 'critical'): string
58
56
  }
59
57
  }
60
58
 
61
- export class SecurityPanel extends BasePanel {
62
- private selectedIndex = 0;
59
+ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
63
60
  private readonly unsub: (() => void) | null;
64
61
 
65
62
  public constructor(private readonly readModel: UiReadModel<UiSecuritySnapshot>) {
@@ -71,28 +68,41 @@ export class SecurityPanel extends BasePanel {
71
68
  this.unsub?.();
72
69
  }
73
70
 
71
+ protected override getPalette() { return C; }
72
+ protected override getEmptyStateMessage() { return ' No API tokens are registered with the security auditor yet.'; }
73
+ protected override getEmptyStateActions() {
74
+ return [
75
+ { command: '/storage review', summary: 'inspect secure secret storage and environment overrides' },
76
+ { command: '/policy preflight', summary: 'run a live preflight posture review' },
77
+ { command: '/mcp trust', summary: 'inspect active MCP trust and quarantine posture' },
78
+ ];
79
+ }
80
+
81
+ protected getItems(): readonly TokenAuditResult[] {
82
+ return this.readModel.getSnapshot().audit.results;
83
+ }
84
+
85
+ protected renderItem(result: TokenAuditResult, index: number, selected: boolean, width: number): Line {
86
+ const bg = selected ? C.selectBg : undefined;
87
+ return buildPanelLine(width, [
88
+ [' ', C.label, bg],
89
+ [result.label.padEnd(22), C.value, bg],
90
+ [` ${result.tokenId.padEnd(12)}`, C.info, bg],
91
+ [` ${result.scope.policyId.padEnd(10)}`, C.label, bg],
92
+ [` ${resultSummary(result).slice(0, Math.max(0, width - 49))}`, resultColor(result), bg],
93
+ ]);
94
+ }
95
+
74
96
  public handleInput(key: string): boolean {
75
- const snapshot = this.readModel.getSnapshot();
76
97
  if (key === 'r') {
77
98
  this.markDirty();
78
99
  return true;
79
100
  }
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;
101
+ return super.handleInput(key);
92
102
  }
93
103
 
94
104
  public render(width: number, height: number): Line[] {
95
- this.needsRender = false;
105
+ this.clampSelection();
96
106
  const snapshot = this.readModel.getSnapshot();
97
107
  const view = snapshot.audit;
98
108
  const preflightStatus = snapshot.policy.preflightStatus;
@@ -105,9 +115,8 @@ export class SecurityPanel extends BasePanel {
105
115
  const quarantinedPlugins = snapshot.quarantinedPlugins;
106
116
  const untrustedPlugins = snapshot.untrustedPlugins;
107
117
  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;
118
+ const intro = 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.';
119
+ const footerLine = buildGuidanceLine(width, '/policy preflight', 'run a proactive policy review before risky work starts', C);
111
120
 
112
121
  const governanceLines: Line[] = [
113
122
  buildPanelLine(width, [
@@ -134,9 +143,6 @@ export class SecurityPanel extends BasePanel {
134
143
  [' denied permissions ', C.label],
135
144
  [String(snapshot.deniedPermissions), snapshot.deniedPermissions > 0 ? C.warn : C.ok],
136
145
  ]),
137
- ];
138
-
139
- const threatLines: Line[] = [
140
146
  buildPanelLine(width, [
141
147
  [' quarantined MCP ', C.label],
142
148
  [String(quarantinedMcp.length), quarantinedMcp.length > 0 ? C.error : C.ok],
@@ -164,166 +170,124 @@ export class SecurityPanel extends BasePanel {
164
170
  ]),
165
171
  ];
166
172
 
173
+ // Empty state: no token results yet — show governance + threat posture before base empty state
167
174
  if (view.results.length === 0) {
168
- const emptyLines = [
175
+ const emptyStateLines = [
169
176
  ...governanceLines,
170
- ...threatLines,
171
177
  ...buildEmptyState(
172
178
  width,
173
- ' No API tokens are registered with the security auditor yet.',
179
+ this.getEmptyStateMessage(),
174
180
  '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
- ],
181
+ this.getEmptyStateActions(),
180
182
  C,
181
183
  ),
182
184
  ];
183
185
  if (quarantinedMcp.length > 0) {
184
- emptyLines.push(buildPanelLine(width, [[' MCP quarantine still active despite no registered tokens.', C.warn]]));
186
+ emptyStateLines.push(buildPanelLine(width, [[' MCP quarantine still active despite no registered tokens.', C.warn]]));
185
187
  }
186
188
  const workspace = buildPanelWorkspace(width, height, {
187
189
  title: 'Security Control Room',
188
- intro: 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.',
190
+ intro,
189
191
  sections: [
190
- { title: 'Governance', lines: emptyLines },
192
+ { title: 'Governance', lines: emptyStateLines },
191
193
  { title: 'Attack Paths', lines: attackPathLines },
192
194
  ],
193
- footerLines,
195
+ footerLines: [footerLine],
194
196
  palette: C,
195
197
  });
196
198
  while (workspace.length < height) workspace.push(createEmptyLine(width));
197
199
  return workspace.slice(0, height);
198
200
  }
199
201
 
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
- ]));
202
+ if (attackPathReview.findings.length > 0) {
203
+ attackPathLines.push(buildPanelLine(width, [[' MCP attack-path review', C.label]]));
204
+ for (const finding of attackPathReview.findings.slice(0, 3)) {
205
+ attackPathLines.push(buildPanelLine(width, [[
206
+ ` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`.slice(0, width),
207
+ severityColor(finding.severity),
208
+ ]]));
209
+ attackPathLines.push(buildPanelLine(width, [[
210
+ ` ${finding.reason}`.slice(0, width),
211
+ C.dim,
212
+ ]]));
213
+ attackPathLines.push(buildPanelLine(width, [[
214
+ ` evidence: ${finding.evidence.join(' | ')}`.slice(0, width),
215
+ C.dim,
216
+ ]]));
217
+ }
213
218
  }
214
219
 
215
- const detailLines: Line[] = [
216
- buildPanelLine(width, [
220
+ const selected = view.results[this.selectedIndex];
221
+ const detailLines: Line[] = [];
222
+ if (selected) {
223
+ detailLines.push(buildPanelLine(width, [
217
224
  [' Token: ', C.label],
218
225
  [selected.label, C.value],
219
226
  [' Policy: ', C.label],
220
227
  [selected.scope.policyId, C.info],
221
228
  [' Blocked: ', C.label],
222
229
  [selected.blocked ? 'yes' : 'no', selected.blocked ? C.error : C.ok],
223
- ]),
224
- buildPanelLine(width, [
230
+ ]));
231
+ detailLines.push(buildPanelLine(width, [
225
232
  [' Scope: ', C.label],
226
233
  [selected.scope.outcome, selected.scope.outcome === 'violation' ? C.error : C.ok],
227
234
  [' Excess: ', C.label],
228
235
  [(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, [
236
+ ]));
237
+ detailLines.push(buildPanelLine(width, [
231
238
  [' Rotation: ', C.label],
232
239
  [selected.rotation.outcome, selected.rotation.outcome === 'ok' ? C.ok : selected.rotation.outcome === 'warning' ? C.warn : C.error],
233
240
  [' Due: ', C.label],
234
241
  [new Date(selected.rotation.dueAt).toISOString(), C.value],
235
242
  [' Age(d): ', C.label],
236
243
  [String(Math.floor(selected.rotation.ageMs / (24 * 60 * 60 * 1000))), C.value],
237
- ]),
238
- buildPanelLine(width, [[
244
+ ]));
245
+ detailLines.push(buildPanelLine(width, [[
239
246
  `Last audit: ${view.lastAuditAt ? new Date(view.lastAuditAt).toISOString() : 'never'} Press r to refresh.`,
240
247
  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
248
  ]]));
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),
249
+ if (preflightStatus !== 'n/a') {
250
+ detailLines.push(buildPanelLine(width, [[
251
+ `Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`.slice(0, width),
252
+ preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : C.dim,
282
253
  ]]));
283
- attackPathLines.push(buildPanelLine(width, [[
284
- ` ${finding.reason}`.slice(0, width),
285
- C.dim,
254
+ }
255
+ if (quarantinedMcp.length > 0) {
256
+ const server = quarantinedMcp[0]!;
257
+ detailLines.push(buildPanelLine(width, [[
258
+ `MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`.slice(0, width),
259
+ C.error,
286
260
  ]]));
287
- attackPathLines.push(buildPanelLine(width, [[
288
- ` evidence: ${finding.evidence.join(' | ')}`.slice(0, width),
289
- C.dim,
261
+ }
262
+ if (quarantinedPlugins.length > 0) {
263
+ const plugin = quarantinedPlugins[0]!;
264
+ detailLines.push(buildPanelLine(width, [[
265
+ `Plugin quarantine: ${plugin.name} (${plugin.trustTier})`.slice(0, width),
266
+ C.error,
267
+ ]]));
268
+ } else if (untrustedPlugins.length > 0) {
269
+ const plugin = untrustedPlugins[0]!;
270
+ detailLines.push(buildPanelLine(width, [[
271
+ `Plugin trust warning: ${plugin.name} remains untrusted.`.slice(0, width),
272
+ C.warn,
273
+ ]]));
274
+ }
275
+ if (latestIncident) {
276
+ detailLines.push(buildPanelLine(width, [[
277
+ `Latest incident: ${latestIncident.classification} - ${latestIncident.summary}`.slice(0, width),
278
+ C.warn,
290
279
  ]]));
291
280
  }
292
281
  }
293
282
 
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, {
283
+ return this.renderList(width, height, {
314
284
  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,
285
+ header: governanceLines,
286
+ footer: [
287
+ ...detailLines,
288
+ ...attackPathLines,
289
+ footerLine,
290
+ ],
325
291
  });
326
- while (lines.length < height) lines.push(createEmptyLine(width));
327
- return lines.slice(0, height);
328
292
  }
329
293
  }
@@ -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,
@@ -12,8 +12,6 @@ import {
12
12
  buildPanelLine,
13
13
  buildPanelWorkspace,
14
14
  DEFAULT_PANEL_PALETTE,
15
- resolvePrimaryScrollableSection,
16
- type PanelWorkspaceSection,
17
15
  } from './polish.ts';
18
16
 
19
17
  const C = {
@@ -54,7 +52,6 @@ function statusColor(entry: ServicePanelEntry): string {
54
52
 
55
53
  function authSummary(config: ServiceConfig, manager: SubscriptionAccessQuery): string {
56
54
  const provider = config.providerId ?? config.name;
57
- const hasActiveSubscription = manager.get(provider) != null;
58
55
  const hasOverride = manager.getAccessToken(provider) != null;
59
56
  switch (config.authType) {
60
57
  case 'bearer':
@@ -66,16 +63,14 @@ function authSummary(config: ServiceConfig, manager: SubscriptionAccessQuery): s
66
63
  ? 'oauth-override'
67
64
  : config.apiKeyHeader ? `api-key:${config.apiKeyHeader}` : 'api-key';
68
65
  case 'oauth':
69
- return hasActiveSubscription ? 'oauth(active)' : 'oauth';
66
+ return manager.get(provider) != null ? 'oauth(active)' : 'oauth';
70
67
  }
71
68
  }
72
69
 
73
- export class ServicesPanel extends BasePanel {
70
+ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
74
71
  private readonly registry: ServiceInspectionQuery;
75
72
  private readonly subscriptionManager: SubscriptionAccessQuery;
76
73
  private entries: ServicePanelEntry[] = [];
77
- private selectedIndex = 0;
78
- private scrollOffset = 0;
79
74
  private loading = false;
80
75
 
81
76
  public constructor(
@@ -95,27 +90,40 @@ export class ServicesPanel extends BasePanel {
95
90
  }
96
91
  }
97
92
 
93
+ protected override getPalette() { return C; }
94
+ protected override getEmptyStateMessage() { return ' No services configured.'; }
95
+ protected override getEmptyStateActions() {
96
+ return [
97
+ { command: '/services auth-review', summary: 'inspect service auth posture and registry config' },
98
+ { command: '/subscription', summary: 'review provider login state and override posture' },
99
+ ];
100
+ }
101
+
102
+ protected getItems(): readonly ServicePanelEntry[] {
103
+ return this.entries;
104
+ }
105
+
106
+ protected renderItem(entry: ServicePanelEntry, index: number, selected: boolean, width: number): Line {
107
+ const bg = selected ? C.selectBg : undefined;
108
+ return buildPanelLine(width, [
109
+ [' ', C.label, bg],
110
+ [entry.name.padEnd(16), C.value, bg],
111
+ [` ${statusLabel(entry).padEnd(12)}`, statusColor(entry), bg],
112
+ [` ${authSummary(entry.inspection.config, this.subscriptionManager).padEnd(18)}`, C.info, bg],
113
+ [` ${entry.inspection.config.baseUrl ?? '(no baseUrl)'}`, C.dim, bg],
114
+ ]);
115
+ }
116
+
98
117
  public handleInput(key: string): boolean {
99
118
  if (key === 'r') {
100
119
  void this.refresh();
101
120
  return true;
102
121
  }
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
122
  if (key === 't') {
115
123
  void this.testSelected();
116
124
  return true;
117
125
  }
118
- return false;
126
+ return super.handleInput(key);
119
127
  }
120
128
 
121
129
  private async refresh(): Promise<void> {
@@ -152,7 +160,7 @@ export class ServicesPanel extends BasePanel {
152
160
  }
153
161
 
154
162
  public render(width: number, height: number): Line[] {
155
- this.needsRender = false;
163
+ this.clampSelection();
156
164
  const intro = 'Credential posture, subscription overrides, and live connection checks for configured services.';
157
165
 
158
166
  if (this.loading && this.entries.length === 0) {
@@ -166,106 +174,54 @@ export class ServicesPanel extends BasePanel {
166
174
  return lines;
167
175
  }
168
176
 
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, [
177
+ const selected = this.entries[this.selectedIndex];
178
+ const detailLines: Line[] = [];
179
+ if (selected) {
180
+ const inspect = selected.inspection;
181
+ detailLines.push(buildPanelLine(width, [
195
182
  [' Service: ', C.label],
196
183
  [selected.name, C.value],
197
184
  [' State: ', C.label],
198
185
  [statusLabel(selected), statusColor(selected)],
199
186
  [' Auth: ', C.label],
200
187
  [authSummary(inspect.config, this.subscriptionManager), C.info],
201
- ]),
202
- buildPanelLine(width, [
188
+ ]));
189
+ detailLines.push(buildPanelLine(width, [
203
190
  [' Primary credential: ', C.label],
204
191
  [inspect.hasPrimaryCredential ? 'present' : 'missing', inspect.hasPrimaryCredential ? C.ok : C.error],
205
192
  [' Webhook URL: ', C.label],
206
193
  [inspect.hasWebhookUrl ? 'present' : 'missing', inspect.hasWebhookUrl ? C.ok : C.dim],
207
194
  [' Signing secret: ', C.label],
208
195
  [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],
219
196
  ]));
220
- if (selected.lastTest.error) {
197
+ if (selected.lastTest) {
221
198
  detailLines.push(buildPanelLine(width, [
222
- [' Error: ', C.label],
223
- [selected.lastTest.error.slice(0, Math.max(0, width - 10)), C.error],
199
+ [' Last test: ', C.label],
200
+ [selected.lastTest.ok ? 'ok' : 'failed', selected.lastTest.ok ? C.ok : C.error],
201
+ [' Status: ', C.label],
202
+ [selected.lastTest.status != null ? String(selected.lastTest.status) : 'n/a', C.value],
203
+ [' URL: ', C.label],
204
+ [(selected.lastTest.testedUrl ?? 'n/a').slice(0, Math.max(0, width - 34)), C.dim],
224
205
  ]));
206
+ if (selected.lastTest.error) {
207
+ detailLines.push(buildPanelLine(width, [
208
+ [' Error: ', C.label],
209
+ [selected.lastTest.error.slice(0, Math.max(0, width - 10)), C.error],
210
+ ]));
211
+ }
212
+ } else {
213
+ detailLines.push(buildPanelLine(width, [[' Press t to test the selected service or r to refresh credential status.', C.dim]]));
225
214
  }
226
- } else {
227
- detailLines.push(buildPanelLine(width, [[' Press t to test the selected service or r to refresh credential status.', C.dim]]));
215
+ detailLines.push(buildPanelLine(width, [[' Services resolve credentials through hierarchy-aware secure storage, plaintext fallback policy, and project-local config.', C.dim]]));
228
216
  }
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
217
 
257
- const sections: PanelWorkspaceSection[] = [
258
- resolvedServicesSection.section,
259
- detailSection,
260
- ];
261
- const lines = buildPanelWorkspace(width, height, {
218
+ return this.renderList(width, height, {
262
219
  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,
220
+ footer: [
221
+ ...detailLines,
222
+ buildPanelLine(width, [[' Up/Down move t test selected service r refresh inspections', C.dim]]),
223
+ ],
224
+ emptyMessage: intro,
267
225
  });
268
- while (lines.length < height) lines.push(createEmptyLine(width));
269
- return lines.slice(0, height);
270
226
  }
271
227
  }