@pellux/goodvibes-tui 0.28.0 → 0.29.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 (118) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/input/commands/cost-runtime.ts +49 -0
  5. package/src/input/commands/operator-runtime.ts +5 -1
  6. package/src/input/commands.ts +2 -0
  7. package/src/input/handler-feed-routes.ts +12 -35
  8. package/src/input/handler-feed.ts +4 -8
  9. package/src/input/handler-shortcuts.ts +51 -0
  10. package/src/input/handler.ts +43 -6
  11. package/src/input/keybindings.ts +48 -8
  12. package/src/input/panel-integration-actions.ts +107 -1
  13. package/src/main.ts +5 -1
  14. package/src/panels/agent-inspector-panel.ts +125 -36
  15. package/src/panels/agent-inspector-shared.ts +144 -0
  16. package/src/panels/approval-panel.ts +67 -16
  17. package/src/panels/automation-control-panel.ts +368 -124
  18. package/src/panels/builtin/agent.ts +28 -37
  19. package/src/panels/builtin/development.ts +40 -32
  20. package/src/panels/builtin/knowledge.ts +15 -6
  21. package/src/panels/builtin/operations.ts +178 -117
  22. package/src/panels/builtin/session.ts +35 -9
  23. package/src/panels/builtin/shared.ts +98 -6
  24. package/src/panels/cockpit-panel.ts +232 -73
  25. package/src/panels/communication-panel.ts +58 -20
  26. package/src/panels/confirm-state.ts +8 -1
  27. package/src/panels/control-plane-panel.ts +351 -118
  28. package/src/panels/cost-tracker-panel.ts +165 -7
  29. package/src/panels/debug-panel.ts +312 -159
  30. package/src/panels/diff-panel.ts +203 -57
  31. package/src/panels/docs-panel.ts +152 -66
  32. package/src/panels/eval-panel.ts +220 -42
  33. package/src/panels/file-explorer-panel.ts +202 -122
  34. package/src/panels/file-preview-panel.ts +132 -57
  35. package/src/panels/git-panel.ts +309 -128
  36. package/src/panels/hooks-panel.ts +150 -22
  37. package/src/panels/incident-review-panel.ts +223 -35
  38. package/src/panels/index.ts +0 -4
  39. package/src/panels/intelligence-panel.ts +212 -86
  40. package/src/panels/knowledge-graph-panel.ts +461 -101
  41. package/src/panels/local-auth-panel.ts +240 -28
  42. package/src/panels/marketplace-panel.ts +193 -26
  43. package/src/panels/memory-panel.ts +78 -77
  44. package/src/panels/ops-control-panel.ts +146 -29
  45. package/src/panels/ops-strategy-panel.ts +72 -4
  46. package/src/panels/orchestration-panel.ts +231 -69
  47. package/src/panels/panel-list-panel.ts +154 -131
  48. package/src/panels/panel-manager.ts +97 -9
  49. package/src/panels/plan-dashboard-panel.ts +333 -17
  50. package/src/panels/plugins-panel.ts +184 -29
  51. package/src/panels/policy-panel.ts +210 -38
  52. package/src/panels/polish-core.ts +7 -2
  53. package/src/panels/polish.ts +23 -4
  54. package/src/panels/project-planning-answer-actions.ts +134 -0
  55. package/src/panels/project-planning-panel.ts +62 -113
  56. package/src/panels/provider-health-panel.ts +434 -518
  57. package/src/panels/provider-health-routes.ts +203 -0
  58. package/src/panels/provider-health-tracker.ts +194 -6
  59. package/src/panels/provider-health-views.ts +560 -0
  60. package/src/panels/qr-panel.ts +116 -30
  61. package/src/panels/remote-panel.ts +114 -36
  62. package/src/panels/routes-panel.ts +63 -22
  63. package/src/panels/sandbox-panel.ts +174 -42
  64. package/src/panels/scrollable-list-panel.ts +19 -135
  65. package/src/panels/security-panel.ts +133 -33
  66. package/src/panels/services-panel.ts +116 -64
  67. package/src/panels/session-browser-panel.ts +73 -2
  68. package/src/panels/session-maintenance.ts +4 -122
  69. package/src/panels/settings-sync-panel.ts +335 -72
  70. package/src/panels/skills-panel.ts +157 -89
  71. package/src/panels/subscription-panel.ts +86 -33
  72. package/src/panels/symbol-outline-panel.ts +248 -108
  73. package/src/panels/system-messages-panel.ts +114 -13
  74. package/src/panels/tasks-panel.ts +326 -139
  75. package/src/panels/thinking-panel.ts +43 -10
  76. package/src/panels/token-budget-panel.ts +194 -18
  77. package/src/panels/tool-inspector-panel.ts +144 -34
  78. package/src/panels/types.ts +40 -4
  79. package/src/panels/work-plan-panel.ts +280 -17
  80. package/src/panels/worktree-panel.ts +175 -42
  81. package/src/panels/wrfc-panel.ts +116 -24
  82. package/src/renderer/conversation-overlays.ts +25 -11
  83. package/src/renderer/footer-tips.ts +41 -0
  84. package/src/renderer/fullscreen-primitives.ts +22 -16
  85. package/src/renderer/help-overlay.ts +91 -14
  86. package/src/renderer/hint-grammar.ts +52 -0
  87. package/src/renderer/layout.ts +7 -7
  88. package/src/renderer/modal-factory.ts +23 -15
  89. package/src/renderer/model-picker-overlay.ts +21 -7
  90. package/src/renderer/overlay-box.ts +16 -10
  91. package/src/renderer/process-indicator.ts +3 -1
  92. package/src/renderer/progress.ts +5 -4
  93. package/src/renderer/search-overlay.ts +27 -6
  94. package/src/renderer/session-picker-modal.ts +6 -4
  95. package/src/renderer/settings-modal.ts +70 -10
  96. package/src/renderer/shell-surface.ts +41 -15
  97. package/src/renderer/status-glyphs.ts +11 -9
  98. package/src/renderer/theme.ts +60 -3
  99. package/src/renderer/ui-factory.ts +41 -30
  100. package/src/renderer/ui-primitives.ts +23 -2
  101. package/src/runtime/bootstrap-shell.ts +35 -18
  102. package/src/runtime/diagnostics/panels/index.ts +0 -3
  103. package/src/shell/ui-openers.ts +14 -22
  104. package/src/utils/format-duration.ts +2 -2
  105. package/src/utils/splash-lines.ts +44 -3
  106. package/src/version.ts +1 -1
  107. package/src/work-plans/work-plan-store.ts +13 -0
  108. package/src/panels/agent-logs-panel.ts +0 -635
  109. package/src/panels/agent-logs-shared.ts +0 -129
  110. package/src/panels/context-visualizer-panel.ts +0 -238
  111. package/src/panels/forensics-panel.ts +0 -378
  112. package/src/panels/provider-account-snapshot.ts +0 -259
  113. package/src/panels/provider-accounts-panel.ts +0 -255
  114. package/src/panels/provider-stats-panel.ts +0 -391
  115. package/src/panels/schedule-panel.ts +0 -365
  116. package/src/panels/watchers-panel.ts +0 -213
  117. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  118. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -6,35 +6,29 @@ import type { UiReadModel, UiSecuritySnapshot } from '../runtime/ui-read-models.
6
6
  import {
7
7
  buildAlignedRow,
8
8
  buildEmptyState,
9
- buildGuidanceLine,
9
+ buildKeyboardHints,
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
12
  buildStatusPill,
13
13
  DEFAULT_PANEL_PALETTE,
14
14
  } from './polish.ts';
15
15
  import { createEmptyLine } from '../types/grid.ts';
16
+ import type { PanelIntegrationContext } from './types.ts';
16
17
 
17
- const C = {
18
- ...DEFAULT_PANEL_PALETTE,
19
- header: '#94a3b8',
20
- headerBg: '#1e293b',
21
- dim: '#475569',
22
- ok: '#22c55e',
23
- warn: '#eab308',
24
- error: '#ef4444',
25
- selectBg: '#0f172a',
26
- } as const;
18
+ // Base chrome only — title band, state colors, and text tokens all come
19
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
20
+ const C = DEFAULT_PANEL_PALETTE;
27
21
 
28
22
  function managedColor(managed: boolean): string {
29
23
  return managed ? C.warn : C.info;
30
24
  }
31
25
 
32
26
  function resultColor(result: TokenAuditResult): string {
33
- if (result.blocked) return C.error;
34
- if (result.scope.outcome === 'violation') return C.error;
35
- if (result.rotation.outcome === 'overdue') return C.error;
27
+ if (result.blocked) return C.bad;
28
+ if (result.scope.outcome === 'violation') return C.bad;
29
+ if (result.rotation.outcome === 'overdue') return C.bad;
36
30
  if (result.rotation.outcome === 'warning') return C.warn;
37
- return C.ok;
31
+ return C.good;
38
32
  }
39
33
 
40
34
  function resultSummary(result: TokenAuditResult): string {
@@ -50,20 +44,55 @@ function severityColor(severity: 'low' | 'medium' | 'high' | 'critical'): string
50
44
  switch (severity) {
51
45
  case 'critical':
52
46
  case 'high':
53
- return C.error;
47
+ return C.bad;
54
48
  case 'medium':
55
49
  return C.warn;
56
50
  case 'low':
57
51
  default:
58
- return C.ok;
52
+ return C.good;
59
53
  }
60
54
  }
61
55
 
56
+ // Relative-time formatting for audit/rotation timestamps — humanized instead
57
+ // of raw ISO strings (WO-137). Mirrors the fmtAgo pattern already used by
58
+ // approval-panel.ts / debug-panel.ts / communication-panel.ts.
59
+ function fmtAgo(ts: number): string {
60
+ const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
61
+ if (sec < 5) return 'just now';
62
+ if (sec < 60) return `${sec}s ago`;
63
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
64
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
65
+ return `${Math.floor(sec / 86400)}d ago`;
66
+ }
67
+
68
+ function fmtDue(msUntilDue: number): string {
69
+ const overdue = msUntilDue < 0;
70
+ const sec = Math.floor(Math.abs(msUntilDue) / 1000);
71
+ const unit = sec < 3600
72
+ ? `${Math.max(1, Math.floor(sec / 60))}m`
73
+ : sec < 86400
74
+ ? `${Math.floor(sec / 3600)}h`
75
+ : `${Math.floor(sec / 86400)}d`;
76
+ return overdue ? `overdue by ${unit}` : `in ${unit}`;
77
+ }
78
+
79
+ // Attack-path finding rows: 3 rendered lines each (severity/route, reason,
80
+ // evidence). Bounds the per-render window to the panel's actual height
81
+ // instead of a fixed "3 findings" cap, and pages through the rest via
82
+ // attackPathScroll ('[' / ']').
83
+ const ATTACK_PATH_FINDING_LINES = 3;
84
+
62
85
  export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
63
86
  private readonly unsub: (() => void) | null;
87
+ /** Scroll offset into attackPathReview.findings (in finding units, not lines). */
88
+ private attackPathScroll = 0;
89
+ /** Set by 'f'; consumed by handlePanelIntegrationAction to dispatch /policy preflight. */
90
+ private pendingPreflight = false;
91
+ /** Set by 'i'; consumed by handlePanelIntegrationAction to jump to the incident panel. */
92
+ private pendingIncidentJump = false;
64
93
 
65
94
  public constructor(private readonly readModel: UiReadModel<UiSecuritySnapshot>) {
66
- super('security', 'Security', 'U', 'monitoring');
95
+ super('security', 'Security', '', 'security-policy');
67
96
  this.showSelectionGutter = true; // I5: non-color selection affordance
68
97
  this.filterEnabled = true;
69
98
  this.filterLabel = 'Filter tokens';
@@ -77,9 +106,13 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
77
106
  protected override getPalette() { return C; }
78
107
  protected override getEmptyStateMessage() { return ' No API tokens are registered with the security auditor yet.'; }
79
108
  protected override getEmptyStateActions() {
109
+ // WO-160: '/policy preflight' dropped — 'f' already dispatches it for
110
+ // real (see handleInput/handlePanelIntegrationAction) and is advertised
111
+ // as a live key hint in the footer even in this empty state, so listing
112
+ // it here too was a redundant action substitute. '/storage review' and
113
+ // '/mcp trust' stay: neither has an in-panel key equivalent.
80
114
  return [
81
115
  { command: '/storage review', summary: 'inspect secure secret storage and environment overrides' },
82
- { command: '/policy preflight', summary: 'run a live preflight posture review' },
83
116
  { command: '/mcp trust', summary: 'inspect active MCP trust and quarantine posture' },
84
117
  ];
85
118
  }
@@ -109,12 +142,58 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
109
142
 
110
143
  public handleInput(key: string): boolean {
111
144
  if (!this.filterActive && key === 'r') {
145
+ // Real re-audit: force the read model to recompute (ApiTokenAuditor.auditAll
146
+ // under the hood) right now instead of waiting on the next incidental
147
+ // render, so lastAuditAt visibly advances the instant 'r' is pressed.
148
+ this.readModel.getSnapshot();
149
+ this.markDirty();
150
+ return true;
151
+ }
152
+ if (!this.filterActive && key === 'f') {
153
+ this.pendingPreflight = true;
154
+ this.markDirty();
155
+ return true;
156
+ }
157
+ if (!this.filterActive && key === 'i') {
158
+ if (!this.readModel.getSnapshot().latestIncident) return false;
159
+ this.pendingIncidentJump = true;
160
+ this.markDirty();
161
+ return true;
162
+ }
163
+ if (!this.filterActive && key === '[') {
164
+ this.attackPathScroll = Math.max(0, this.attackPathScroll - 1);
165
+ this.markDirty();
166
+ return true;
167
+ }
168
+ if (!this.filterActive && key === ']') {
169
+ this.attackPathScroll += 1;
112
170
  this.markDirty();
113
171
  return true;
114
172
  }
115
173
  return super.handleInput(key);
116
174
  }
117
175
 
176
+ /**
177
+ * f (preflight) and i (jump to incident) both require the integration
178
+ * context (executeCommand / panelManager), which is only available here —
179
+ * same staged-pending-action pattern as worktree-panel.ts and
180
+ * incident-review-panel.ts.
181
+ */
182
+ public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
183
+ if (this.pendingPreflight) {
184
+ this.pendingPreflight = false;
185
+ if (!ctx.executeCommand) return false;
186
+ void ctx.executeCommand('policy', ['preflight']).catch(() => { /* surfaced via /policy output */ });
187
+ return true;
188
+ }
189
+ if (this.pendingIncidentJump) {
190
+ this.pendingIncidentJump = false;
191
+ ctx.panelManager.open('incident');
192
+ return true;
193
+ }
194
+ return false;
195
+ }
196
+
118
197
  protected override filterMatches(result: TokenAuditResult, q: string): boolean {
119
198
  return result.label.toLowerCase().includes(q)
120
199
  || result.scope.policyId.toLowerCase().includes(q)
@@ -136,7 +215,10 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
136
215
  const untrustedPlugins = snapshot.untrustedPlugins;
137
216
  const attackPathReview = snapshot.attackPathReview;
138
217
  const intro = 'Token audit, policy posture, MCP attack-path review, plugin trust, and incident pressure.';
139
- const footerLine = buildGuidanceLine(width, '/policy preflight', 'run a proactive policy review before risky work starts', C);
218
+ // WO-160: dropped the printed '/policy preflight' guidance line 'f' is
219
+ // already bound to dispatch it for real (see handleInput/
220
+ // handlePanelIntegrationAction below) and is advertised in the keyboard
221
+ // hints footer, so the printed command was a pure action substitute.
140
222
 
141
223
  const governanceLines: Line[] = [
142
224
  buildPanelLine(width, [
@@ -212,7 +294,7 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
212
294
  { title: 'Governance', lines: emptyStateLines },
213
295
  { title: 'Attack Paths', lines: attackPathLines },
214
296
  ],
215
- footerLines: [footerLine],
297
+ footerLines: [buildKeyboardHints(width, [{ keys: 'f', label: 'preflight' }], C)],
216
298
  palette: C,
217
299
  });
218
300
  while (workspace.length < height) workspace.push(createEmptyLine(width));
@@ -221,7 +303,15 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
221
303
 
222
304
  if (attackPathReview.findings.length > 0) {
223
305
  attackPathLines.push(buildPanelLine(width, [[' MCP attack-path review', C.label]]));
224
- for (const finding of attackPathReview.findings.slice(0, 3)) {
306
+ // Fully scrollable no fixed "3 findings" cap. The visible window
307
+ // scales with the panel's actual height; '[' / ']' page through the
308
+ // rest when there are more findings than currently fit.
309
+ const windowSize = Math.max(1, Math.floor((height - 16) / ATTACK_PATH_FINDING_LINES));
310
+ const maxScroll = Math.max(0, attackPathReview.findings.length - windowSize);
311
+ this.attackPathScroll = Math.min(this.attackPathScroll, maxScroll);
312
+ const start = this.attackPathScroll;
313
+ const end = Math.min(attackPathReview.findings.length, start + windowSize);
314
+ for (const finding of attackPathReview.findings.slice(start, end)) {
225
315
  attackPathLines.push(buildPanelLine(width, [[
226
316
  truncateDisplay(` ${finding.severity.toUpperCase()} ${finding.serverName}: ${finding.route}`, width),
227
317
  severityColor(finding.severity),
@@ -235,6 +325,12 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
235
325
  C.dim,
236
326
  ]]));
237
327
  }
328
+ if (attackPathReview.findings.length > windowSize) {
329
+ attackPathLines.push(buildPanelLine(width, [[
330
+ ` showing ${start + 1}-${end} of ${attackPathReview.findings.length} findings ([ / ] to scroll)`,
331
+ C.dim,
332
+ ]]));
333
+ }
238
334
  }
239
335
 
240
336
  // Column header for the token audit list so the aligned columns are legible.
@@ -258,7 +354,9 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
258
354
  ),
259
355
  ];
260
356
 
261
- const selected = view.results[this.selectedIndex];
357
+ // Detail must track the filtered view the list highlights, not the raw
358
+ // audit results — under an applied token filter they diverge.
359
+ const selected = this.getSelectedItem();
262
360
  const detailLines: Line[] = [];
263
361
  if (selected) {
264
362
  detailLines.push(buildPanelLine(width, [
@@ -267,44 +365,44 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
267
365
  [' Policy: ', C.label],
268
366
  [selected.scope.policyId, C.info],
269
367
  [' Blocked: ', C.label],
270
- [selected.blocked ? 'yes' : 'no', selected.blocked ? C.error : C.ok],
368
+ [selected.blocked ? 'yes' : 'no', selected.blocked ? C.bad : C.good],
271
369
  ]));
272
370
  detailLines.push(buildPanelLine(width, [
273
371
  [' Scope: ', C.label],
274
- [selected.scope.outcome, selected.scope.outcome === 'violation' ? C.error : C.ok],
372
+ [selected.scope.outcome, selected.scope.outcome === 'violation' ? C.bad : C.good],
275
373
  [' Excess: ', C.label],
276
- [truncateDisplay(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none', Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.error : C.dim],
374
+ [truncateDisplay(selected.scope.excessScopes.length > 0 ? selected.scope.excessScopes.join(', ') : 'none', Math.max(0, width - 27)), selected.scope.excessScopes.length > 0 ? C.bad : C.dim],
277
375
  ]));
278
376
  detailLines.push(buildPanelLine(width, [
279
377
  [' Rotation: ', C.label],
280
- [selected.rotation.outcome, selected.rotation.outcome === 'ok' ? C.ok : selected.rotation.outcome === 'warning' ? C.warn : C.error],
378
+ [selected.rotation.outcome, selected.rotation.outcome === 'ok' ? C.good : selected.rotation.outcome === 'warning' ? C.warn : C.bad],
281
379
  [' Due: ', C.label],
282
- [new Date(selected.rotation.dueAt).toISOString(), C.value],
380
+ [fmtDue(selected.rotation.msUntilDue), selected.rotation.msUntilDue < 0 ? C.bad : C.value],
283
381
  [' Age(d): ', C.label],
284
382
  [String(Math.floor(selected.rotation.ageMs / (24 * 60 * 60 * 1000))), C.value],
285
383
  ]));
286
384
  detailLines.push(buildPanelLine(width, [[
287
- `Last audit: ${view.lastAuditAt ? new Date(view.lastAuditAt).toISOString() : 'never'} Press r to refresh.`,
385
+ `Last audit: ${view.lastAuditAt ? fmtAgo(view.lastAuditAt) : 'never'} Press r to refresh.`,
288
386
  C.dim,
289
387
  ]]));
290
388
  if (preflightStatus !== 'n/a') {
291
389
  detailLines.push(buildPanelLine(width, [[
292
390
  truncateDisplay(`Policy preflight: ${preflightStatus} (${preflightIssueCount} issue${preflightIssueCount === 1 ? '' : 's'})`, width),
293
- preflightStatus === 'block' ? C.error : preflightStatus === 'warn' ? C.warn : C.dim,
391
+ preflightStatus === 'block' ? C.bad : preflightStatus === 'warn' ? C.warn : C.dim,
294
392
  ]]));
295
393
  }
296
394
  if (quarantinedMcp.length > 0) {
297
395
  const server = quarantinedMcp[0]!;
298
396
  detailLines.push(buildPanelLine(width, [[
299
397
  truncateDisplay(`MCP quarantine: ${server.name} ${server.quarantineReason ?? 'unknown'}${server.quarantineDetail ? ` - ${server.quarantineDetail}` : ''}`, width),
300
- C.error,
398
+ C.bad,
301
399
  ]]));
302
400
  }
303
401
  if (quarantinedPlugins.length > 0) {
304
402
  const plugin = quarantinedPlugins[0]!;
305
403
  detailLines.push(buildPanelLine(width, [[
306
404
  truncateDisplay(`Plugin quarantine: ${plugin.name} (${plugin.trustTier})`, width),
307
- C.error,
405
+ C.bad,
308
406
  ]]));
309
407
  } else if (untrustedPlugins.length > 0) {
310
408
  const plugin = untrustedPlugins[0]!;
@@ -331,6 +429,9 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
331
429
  { keys: '↑/↓', label: 'select token' },
332
430
  { keys: '/', label: 'filter' },
333
431
  { keys: 'r', label: 'refresh audit' },
432
+ { keys: 'f', label: 'preflight' },
433
+ ...(latestIncident ? [{ keys: 'i', label: 'jump to incident' }] : []),
434
+ ...(attackPathReview.findings.length > 0 ? [{ keys: '[ / ]', label: 'scroll attack paths' }] : []),
334
435
  ];
335
436
 
336
437
  return this.renderList(width, height, {
@@ -339,7 +440,6 @@ export class SecurityPanel extends ScrollableListPanel<TokenAuditResult> {
339
440
  footer: [
340
441
  ...detailLines,
341
442
  ...attackPathLines,
342
- footerLine,
343
443
  ],
344
444
  hints,
345
445
  });
@@ -2,11 +2,13 @@ import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
3
  import { truncateDisplay } from '../utils/terminal-width.ts';
4
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
+ import type { PanelIntegrationContext } from './types.ts';
5
6
  import {
6
7
  type ServiceConfig,
7
8
  type ServiceInspection,
8
9
  type ServiceConnectionTestResult,
9
10
  } from '@pellux/goodvibes-sdk/platform/config';
11
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
10
12
  import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
11
13
  import {
12
14
  buildDetailBlock,
@@ -18,28 +20,21 @@ import {
18
20
  DEFAULT_PANEL_PALETTE,
19
21
  } from './polish.ts';
20
22
 
21
- const C = {
22
- ...DEFAULT_PANEL_PALETTE,
23
- header: '#94a3b8',
24
- headerBg: '#1e293b',
25
- label: '#64748b',
26
- value: '#e2e8f0',
27
- dim: '#475569',
28
- ok: '#22c55e',
29
- warn: '#eab308',
30
- error: '#ef4444',
31
- info: '#38bdf8',
32
- selectBg: '#0f172a',
33
- empty: '#334155',
34
- } as const;
23
+ // Base chrome only — title band, state colors, and text tokens all come
24
+ // straight from DEFAULT_PANEL_PALETTE (WO-002).
25
+ const C = DEFAULT_PANEL_PALETTE;
35
26
 
36
27
  interface ServicePanelEntry {
37
28
  readonly name: string;
38
- readonly inspection: ServiceInspection;
29
+ // null when registry.inspect(name) resolved null (e.g. a config entry that
30
+ // disappeared or failed to resolve between getAll() and inspect()) — the
31
+ // row still renders, flagged, instead of crashing the whole refresh.
32
+ readonly inspection: ServiceInspection | null;
39
33
  readonly lastTest?: ServiceConnectionTestResult;
40
34
  }
41
35
 
42
36
  function statusLabel(entry: ServicePanelEntry): string {
37
+ if (!entry.inspection) return 'INSPECT FAILED';
43
38
  if (!entry.inspection.hasPrimaryCredential) return 'UNCONFIGURED';
44
39
  if (entry.lastTest?.ok) return 'HEALTHY';
45
40
  if (entry.lastTest && !entry.lastTest.ok) return 'ERROR';
@@ -48,8 +43,8 @@ function statusLabel(entry: ServicePanelEntry): string {
48
43
 
49
44
  function statusColor(entry: ServicePanelEntry): string {
50
45
  const label = statusLabel(entry);
51
- if (label === 'HEALTHY') return C.ok;
52
- if (label === 'ERROR') return C.error;
46
+ if (label === 'HEALTHY') return C.good;
47
+ if (label === 'ERROR' || label === 'INSPECT FAILED') return C.bad;
53
48
  if (label === 'CONFIGURED') return C.warn;
54
49
  return C.dim;
55
50
  }
@@ -76,12 +71,16 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
76
71
  private readonly subscriptionManager: SubscriptionAccessQuery;
77
72
  private entries: ServicePanelEntry[] = [];
78
73
  private loading = false;
74
+ // s = open the Subscription panel via the handleInput -> handlePanelIntegrationAction
75
+ // bridge (handleInput has no ctx.panelManager — same pattern used elsewhere in
76
+ // this wave, e.g. session-browser-panel.ts's pendingOpenPanels).
77
+ private pendingOpenSubscription = false;
79
78
 
80
79
  public constructor(
81
80
  registry: ServiceInspectionQuery,
82
81
  subscriptionManager: SubscriptionAccessQuery,
83
82
  ) {
84
- super('services', 'Services', 'V', 'monitoring');
83
+ super('services', 'Services', 'V', 'providers');
85
84
  this.showSelectionGutter = true; // I5: non-color selection affordance
86
85
  this.filterEnabled = true;
87
86
  this.filterLabel = 'Filter services';
@@ -92,7 +91,7 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
92
91
 
93
92
  protected override filterMatches(entry: ServicePanelEntry, q: string): boolean {
94
93
  return entry.name.toLowerCase().includes(q)
95
- || (entry.inspection.config.baseUrl ?? '').toLowerCase().includes(q);
94
+ || (entry.inspection?.config.baseUrl ?? '').toLowerCase().includes(q);
96
95
  }
97
96
 
98
97
  public override onActivate(): void {
@@ -107,7 +106,7 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
107
106
  protected override getEmptyStateActions() {
108
107
  return [
109
108
  { command: '/services auth-review', summary: 'inspect service auth posture and registry config' },
110
- { command: '/subscription', summary: 'review provider login state and override posture' },
109
+ { command: 's', summary: 'open the Subscription panel — review provider login state and override posture' },
111
110
  ];
112
111
  }
113
112
 
@@ -117,12 +116,14 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
117
116
 
118
117
  protected renderItem(entry: ServicePanelEntry, index: number, selected: boolean, width: number): Line {
119
118
  const bg = selected ? C.selectBg : undefined;
119
+ const authText = entry.inspection ? authSummary(entry.inspection.config, this.subscriptionManager) : 'n/a';
120
+ const baseUrlText = entry.inspection ? (entry.inspection.config.baseUrl ?? '(no baseUrl)') : '(inspection failed)';
120
121
  return buildPanelLine(width, [
121
122
  [' ', C.label, bg],
122
123
  [entry.name.padEnd(16), C.value, bg],
123
124
  [` ${statusLabel(entry).padEnd(12)}`, statusColor(entry), bg],
124
- [` ${authSummary(entry.inspection.config, this.subscriptionManager).padEnd(18)}`, C.info, bg],
125
- [` ${truncateDisplay(entry.inspection.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 48))}`, C.dim, bg],
125
+ [` ${authText.padEnd(18)}`, C.info, bg],
126
+ [` ${truncateDisplay(baseUrlText, Math.max(0, width - 48))}`, C.dim, bg],
126
127
  ]);
127
128
  }
128
129
 
@@ -136,43 +137,85 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
136
137
  void this.testSelected();
137
138
  return true;
138
139
  }
140
+ if (key === 'T') {
141
+ void this.testAll();
142
+ return true;
143
+ }
144
+ if (key === 's') {
145
+ this.pendingOpenSubscription = true;
146
+ return true;
147
+ }
139
148
  }
140
149
  return super.handleInput(key);
141
150
  }
142
151
 
152
+ /** Drains the 's' key's pending open request via the ctx.panelManager bridge. */
153
+ handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
154
+ if (!this.pendingOpenSubscription) return false;
155
+ this.pendingOpenSubscription = false;
156
+ ctx.panelManager.open('subscription');
157
+ return true;
158
+ }
159
+
143
160
  private async refresh(): Promise<void> {
144
161
  this.loading = true;
145
162
  this.markDirty();
146
- const configs = this.registry.getAll();
147
- const names = Object.keys(configs).sort((a, b) => a.localeCompare(b));
148
- const inspections = await Promise.all(
149
- names.map(async (name) => ({
150
- name,
151
- inspection: (await this.registry.inspect(name))!,
152
- })),
153
- );
154
- const previousTests = new Map(this.entries.map((entry) => [entry.name, entry.lastTest] as const));
155
- this.entries = inspections.map((entry) => ({
156
- ...entry,
157
- ...(previousTests.get(entry.name) ? { lastTest: previousTests.get(entry.name) } : {}),
158
- }));
159
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.entries.length - 1));
160
- this.loading = false;
161
- this.markDirty();
163
+ try {
164
+ const configs = this.registry.getAll();
165
+ const names = Object.keys(configs).sort((a, b) => a.localeCompare(b));
166
+ const inspections = await Promise.all(
167
+ names.map(async (name) => ({
168
+ name,
169
+ inspection: await this.registry.inspect(name),
170
+ })),
171
+ );
172
+ const previousTests = new Map(this.entries.map((entry) => [entry.name, entry.lastTest] as const));
173
+ this.entries = inspections.map((entry) => ({
174
+ ...entry,
175
+ ...(previousTests.get(entry.name) ? { lastTest: previousTests.get(entry.name) } : {}),
176
+ }));
177
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.entries.length - 1));
178
+ } catch (e) {
179
+ this.setError(`Refresh failed: ${summarizeError(e)}`);
180
+ } finally {
181
+ // Always clear the loading flag — a thrown getAll()/inspect() must
182
+ // never leave the panel stuck on the "Loading configured services..." screen.
183
+ this.loading = false;
184
+ this.markDirty();
185
+ }
162
186
  }
163
187
 
164
188
  private async testSelected(): Promise<void> {
165
- const selected = this.entries[this.selectedIndex];
189
+ // selectedIndex tracks the filtered view, so the acted-on entry must come
190
+ // from getVisibleItems() — this.entries would desync under a '/' filter.
191
+ const selected = this.getSelectedItem();
166
192
  if (!selected) return;
167
- const result = await this.registry.testConnection(selected.name);
168
- this.entries = this.entries.map((entry) => (
169
- entry.name === selected.name
170
- ? { ...entry, lastTest: result }
171
- : entry
172
- ));
193
+ try {
194
+ const result = await this.registry.testConnection(selected.name);
195
+ this.entries = this.entries.map((entry) => (
196
+ entry.name === selected.name
197
+ ? { ...entry, lastTest: result }
198
+ : entry
199
+ ));
200
+ } catch (e) {
201
+ this.setError(`Test failed: ${summarizeError(e)}`);
202
+ }
173
203
  this.markDirty();
174
204
  }
175
205
 
206
+ /** T = test every configured service's connection, one call per row (services-runtime.ts's single-service test pattern, looped). */
207
+ private async testAll(): Promise<void> {
208
+ for (const entry of this.entries) {
209
+ try {
210
+ const result = await this.registry.testConnection(entry.name);
211
+ this.entries = this.entries.map((e) => (e.name === entry.name ? { ...e, lastTest: result } : e));
212
+ } catch (e) {
213
+ this.setError(`Test failed for ${entry.name}: ${summarizeError(e)}`);
214
+ }
215
+ this.markDirty();
216
+ }
217
+ }
218
+
176
219
  public render(width: number, height: number): Line[] {
177
220
  this.clampSelection();
178
221
  const intro = 'Credential posture, subscription overrides, and live connection checks for configured services.';
@@ -200,13 +243,14 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
200
243
  const headerLines: Line[] = [
201
244
  buildKeyValueLine(width, [
202
245
  { label: 'services', value: String(this.entries.length), valueColor: this.entries.length > 0 ? C.info : C.dim },
203
- { label: 'healthy', value: String(counts.healthy), valueColor: counts.healthy > 0 ? C.ok : C.dim },
204
- { label: 'errors', value: String(counts.error), valueColor: counts.error > 0 ? C.error : C.dim },
246
+ { label: 'healthy', value: String(counts.healthy), valueColor: counts.healthy > 0 ? C.good : C.dim },
247
+ { label: 'errors', value: String(counts.error), valueColor: counts.error > 0 ? C.bad : C.dim },
205
248
  { label: 'unconfigured', value: String(counts.unconfigured), valueColor: counts.unconfigured > 0 ? C.warn : C.dim },
206
249
  ], C),
207
250
  ];
208
251
 
209
- const selected = this.entries[this.selectedIndex];
252
+ // Detail must describe the row the (possibly filtered) list highlights.
253
+ const selected = this.getSelectedItem();
210
254
  const detailRows: Line[] = [];
211
255
  if (selected) {
212
256
  const inspect = selected.inspection;
@@ -216,22 +260,28 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
216
260
  [' State: ', C.label],
217
261
  [statusLabel(selected), statusColor(selected)],
218
262
  [' Auth: ', C.label],
219
- [authSummary(inspect.config, this.subscriptionManager), C.info],
220
- ]));
221
- detailRows.push(buildPanelLine(width, [
222
- [' Base URL: ', C.label],
223
- [truncateDisplay(inspect.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 13)), C.dim],
224
- ]));
225
- detailRows.push(buildPanelLine(width, [
226
- [' Primary credential: ', C.label],
227
- ...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
228
- [' Webhook URL: ', C.label],
229
- ...buildStatusPill(inspect.hasWebhookUrl ? 'good' : 'info', inspect.hasWebhookUrl ? 'present' : 'missing'),
230
- [' Signing secret: ', C.label],
231
- ...buildStatusPill(inspect.hasSigningSecret ? 'good' : 'info', inspect.hasSigningSecret ? 'present' : 'missing'),
232
- [' App token: ', C.label],
233
- ...buildStatusPill(inspect.hasAppToken ? 'good' : 'info', inspect.hasAppToken ? 'present' : 'missing'),
263
+ [inspect ? authSummary(inspect.config, this.subscriptionManager) : 'n/a', C.info],
234
264
  ]));
265
+ if (!inspect) {
266
+ detailRows.push(buildPanelLine(width, [
267
+ [' Inspection failed — the registry could not resolve this service\'s config. Press r to retry.', C.bad],
268
+ ]));
269
+ } else {
270
+ detailRows.push(buildPanelLine(width, [
271
+ [' Base URL: ', C.label],
272
+ [truncateDisplay(inspect.config.baseUrl ?? '(no baseUrl)', Math.max(0, width - 13)), C.dim],
273
+ ]));
274
+ detailRows.push(buildPanelLine(width, [
275
+ [' Primary credential: ', C.label],
276
+ ...buildStatusPill(inspect.hasPrimaryCredential ? 'good' : 'bad', inspect.hasPrimaryCredential ? 'present' : 'missing'),
277
+ [' Webhook URL: ', C.label],
278
+ ...buildStatusPill(inspect.hasWebhookUrl ? 'good' : 'info', inspect.hasWebhookUrl ? 'present' : 'missing'),
279
+ [' Signing secret: ', C.label],
280
+ ...buildStatusPill(inspect.hasSigningSecret ? 'good' : 'info', inspect.hasSigningSecret ? 'present' : 'missing'),
281
+ [' App token: ', C.label],
282
+ ...buildStatusPill(inspect.hasAppToken ? 'good' : 'info', inspect.hasAppToken ? 'present' : 'missing'),
283
+ ]));
284
+ }
235
285
  if (selected.lastTest) {
236
286
  detailRows.push(buildPanelLine(width, [
237
287
  [' Last test: ', C.label],
@@ -244,7 +294,7 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
244
294
  if (selected.lastTest.error) {
245
295
  detailRows.push(buildPanelLine(width, [
246
296
  [' Error: ', C.label],
247
- [truncateDisplay(selected.lastTest.error, Math.max(0, width - 10)), C.error],
297
+ [truncateDisplay(selected.lastTest.error, Math.max(0, width - 10)), C.bad],
248
298
  ]));
249
299
  }
250
300
  } else {
@@ -259,8 +309,10 @@ export class ServicesPanel extends ScrollableListPanel<ServicePanelEntry> {
259
309
  : [
260
310
  { keys: 'Up/Down', label: 'move' },
261
311
  ...(selected ? [{ keys: 't', label: 'test selected' }] : []),
312
+ ...(this.entries.length > 0 ? [{ keys: 'T', label: 'test all' }] : []),
262
313
  { keys: 'r', label: 'refresh' },
263
314
  { keys: '/', label: 'filter' },
315
+ { keys: 's', label: 'subscription panel' },
264
316
  ];
265
317
 
266
318
  const footer: Line[] = selected