@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
@@ -1,18 +1,30 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
- import { formatLatencyMs } from '../utils/format-duration.ts';
3
2
  import { BasePanel } from './base-panel.ts';
4
- import { createEmptyLine, createStyledCell, type Line } from '../types/grid.ts';
5
- import type { ProviderAuthRouteDescriptor } from '@pellux/goodvibes-sdk/platform/providers';
6
- import type { ProviderEvent, TurnEvent } from '@/runtime/index.ts';
3
+ import type { Line } from '../types/grid.ts';
4
+ import type { ModelDomainState, ProviderEvent, TurnEvent } from '@/runtime/index.ts';
5
+ import { createInitialModelState, evaluateSessionMaintenance } from '@/runtime/index.ts';
7
6
  import type { UiEventFeed } from '../runtime/ui-events.ts';
8
7
  import {
9
8
  type ProviderRuntimeInspectionQuery,
10
9
  } from '../runtime/ui-service-queries.ts';
11
- import { ProviderHealthTracker, type ProviderStatus } from './provider-health-tracker.ts';
10
+ import {
11
+ ProviderHealthTracker,
12
+ type ProviderHealthMeta,
13
+ } from './provider-health-tracker.ts';
14
+ import {
15
+ buildAccountPosture,
16
+ type ProviderAccountPosture,
17
+ } from './provider-health-routes.ts';
12
18
  import {
13
19
  buildProviderHealthDomainSummaries,
14
20
  type HealthDomainSummary,
15
21
  } from './provider-health-domains.ts';
22
+ import {
23
+ ProviderHealthDataProvider,
24
+ type FallbackChainData,
25
+ type ProviderHealthEntry,
26
+ } from '../runtime/ui/provider-health/index.ts';
27
+ import type { PanelIntegrationContext } from './types.ts';
16
28
  import type {
17
29
  UiContinuitySnapshot,
18
30
  UiIntelligenceSnapshot,
@@ -25,15 +37,9 @@ import type {
25
37
  UiSettingsSnapshot,
26
38
  UiWorktreeSnapshot,
27
39
  } from '../runtime/ui-read-models.ts';
28
- import { evaluateSessionMaintenance } from '@/runtime/index.ts';
29
40
  import {
30
- buildAlignedRow,
31
- buildBodyText,
32
- buildDetailBlock,
33
41
  buildEmptyState,
34
- buildGuidanceLine,
35
42
  buildKeyboardHints,
36
- buildKeyValueLine,
37
43
  buildPanelWorkspace,
38
44
  buildSummaryBlock,
39
45
  DEFAULT_PANEL_PALETTE,
@@ -41,6 +47,17 @@ import {
41
47
  resolvePrimaryScrollableSection,
42
48
  type PanelWorkspaceSection,
43
49
  } from './polish.ts';
50
+ import {
51
+ buildChainLines,
52
+ buildDomainLines,
53
+ buildMaintenanceLines,
54
+ buildPostureLines,
55
+ buildProviderColumnHeader,
56
+ buildProviderRow,
57
+ buildRouteColumnHeader,
58
+ buildRouteViewLines,
59
+ buildSelectedDetailSection,
60
+ } from './provider-health-views.ts';
44
61
 
45
62
  // ---------------------------------------------------------------------------
46
63
  // Types
@@ -59,307 +76,60 @@ export interface ProviderHealthPanelDeps {
59
76
  readonly intelligence: UiReadModel<UiIntelligenceSnapshot>;
60
77
  readonly continuity: UiReadModel<UiContinuitySnapshot>;
61
78
  readonly worktrees: UiReadModel<UiWorktreeSnapshot>;
79
+ /**
80
+ * Model-domain access wired from the runtime store at the composition root
81
+ * (selectModel over RuntimeStore). Drives fallback-chain rendering and
82
+ * TURN_ERROR attribution to the active provider.
83
+ */
84
+ readonly modelState?: {
85
+ readonly get: () => ModelDomainState;
86
+ readonly subscribe: (listener: () => void) => () => void;
87
+ };
62
88
  }
63
89
 
64
- // Colors
65
- // ---------------------------------------------------------------------------
90
+ type ConsoleView = 'providers' | 'routes' | 'domains';
91
+
92
+ const CONSOLE_VIEWS: readonly ConsoleView[] = ['providers', 'routes', 'domains'] as const;
66
93
 
94
+ // Base chrome plus console accents (domain accents only — hex ratchet).
67
95
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
68
96
  title: '#00ffff',
69
97
  unknown: '244',
70
98
  rowSelectBg: '#111827',
71
99
  });
72
100
 
73
- const LATENCY_WARN_MS = 2_000;
74
- const LATENCY_BAD_MS = 5_000;
101
+ const INTRO = 'Provider console: status, latency timelines, error attribution, auth routes, and fallback posture.';
75
102
 
76
103
  // ---------------------------------------------------------------------------
77
- // Helpers
78
- // ---------------------------------------------------------------------------
79
-
80
- function statusDot(status: ProviderStatus): { char: string; color: string } {
81
- switch (status) {
82
- case 'healthy': return { char: '●', color: C.good };
83
- case 'degraded': return { char: '◑', color: C.warn };
84
- case 'rate_limited': return { char: '◐', color: C.warn };
85
- case 'auth_error': return { char: '✕', color: C.bad };
86
- case 'unavailable': return { char: '✕', color: C.bad };
87
- default: return { char: '○', color: C.unknown };
88
- }
89
- }
90
-
91
- function statusLabel(status: ProviderStatus): string {
92
- switch (status) {
93
- case 'healthy': return 'online';
94
- case 'degraded': return 'degraded';
95
- case 'rate_limited': return 'rate-limited';
96
- case 'auth_error': return 'auth error';
97
- case 'unavailable': return 'unavailable';
98
- default: return 'unknown';
99
- }
100
- }
101
-
102
- function latencyColor(ms: number): string {
103
- if (ms >= LATENCY_BAD_MS) return C.bad;
104
- if (ms >= LATENCY_WARN_MS) return C.warn;
105
- return C.good;
106
- }
107
-
108
- function fmtMs(ms: number): string {
109
- return formatLatencyMs(ms);
110
- }
111
-
112
- function fmtAgo(ts: number | undefined): string {
113
- if (!ts) return 'n/a';
114
- const sec = Math.floor((Date.now() - ts) / 1000);
115
- if (sec < 60) return `${sec}s ago`;
116
- if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
117
- return `${Math.floor(sec / 3600)}h ago`;
118
- }
119
-
120
- function fmtCooldown(expiresAt: number): string {
121
- const remaining = Math.ceil((expiresAt - Date.now()) / 1000);
122
- if (remaining <= 0) return 'expiring';
123
- return `${remaining}s cooldown`;
124
- }
125
-
126
- type ProviderPanelAuthRoute = ProviderAuthRouteDescriptor['route'] | 'unconfigured';
127
- type ProviderPanelAuthFreshness = NonNullable<ProviderAuthRouteDescriptor['freshness']> | 'unconfigured';
128
-
129
- interface ProviderRuntimeRecord {
130
- readonly providerId: string;
131
- readonly active: boolean;
132
- readonly modelCount: number;
133
- readonly activeRoute: ProviderPanelAuthRoute;
134
- readonly preferredRoute: ProviderPanelAuthRoute;
135
- readonly activeRouteReason: string;
136
- readonly authFreshness: ProviderPanelAuthFreshness;
137
- readonly fallbackRisk?: string;
138
- readonly issues: readonly string[];
139
- readonly recommendedActions: readonly string[];
140
- }
141
-
142
- function domainColor(level: HealthDomainSummary['level']): string {
143
- switch (level) {
144
- case 'good':
145
- return C.good;
146
- case 'warn':
147
- return C.warn;
148
- case 'bad':
149
- return C.bad;
150
- default:
151
- return C.value;
152
- }
153
- }
154
-
155
- const AUTH_ROUTE_PRIORITY: readonly ProviderPanelAuthRoute[] = [
156
- 'subscription-oauth',
157
- 'service-oauth',
158
- 'secret-ref',
159
- 'api-key',
160
- 'anonymous',
161
- 'none',
162
- 'unconfigured',
163
- ] as const;
164
-
165
- function routePriority(route: ProviderPanelAuthRoute): number {
166
- const priority = AUTH_ROUTE_PRIORITY.indexOf(route);
167
- return priority >= 0 ? priority : AUTH_ROUTE_PRIORITY.length;
168
- }
169
-
170
- function routeColor(route: ProviderPanelAuthRoute): string {
171
- switch (route) {
172
- case 'subscription-oauth':
173
- return C.title;
174
- case 'service-oauth':
175
- return C.good;
176
- case 'api-key':
177
- return C.warn;
178
- case 'secret-ref':
179
- return C.value;
180
- case 'anonymous':
181
- case 'none':
182
- return C.dim;
183
- default:
184
- return C.value;
185
- }
186
- }
187
-
188
- function freshnessColor(freshness: ProviderPanelAuthFreshness): string {
189
- switch (freshness) {
190
- case 'expired':
191
- return C.bad;
192
- case 'expiring':
193
- case 'pending':
194
- return C.warn;
195
- case 'healthy':
196
- return C.good;
197
- default:
198
- return C.dim;
199
- }
200
- }
201
-
202
- function buildSyntheticAuthRoutes(
203
- auth: {
204
- readonly mode: 'api-key' | 'oauth' | 'anonymous' | 'none';
205
- readonly configured: boolean;
206
- readonly detail?: string;
207
- readonly envVars?: readonly string[];
208
- } | undefined,
209
- ): readonly ProviderAuthRouteDescriptor[] {
210
- if (!auth) return [];
211
- switch (auth.mode) {
212
- case 'none':
213
- return [{
214
- route: 'none',
215
- label: 'No auth required',
216
- configured: true,
217
- usable: true,
218
- freshness: 'healthy',
219
- detail: auth.detail ?? 'Provider does not require interactive credentials.',
220
- }];
221
- case 'anonymous':
222
- return [{
223
- route: 'anonymous',
224
- label: 'Anonymous / local access',
225
- configured: auth.configured,
226
- usable: auth.configured,
227
- freshness: auth.configured ? 'healthy' : 'unconfigured',
228
- detail: auth.detail ?? 'Provider can be used without stored credentials.',
229
- }];
230
- case 'api-key':
231
- return [{
232
- route: 'api-key',
233
- label: 'Ambient API key',
234
- configured: auth.configured,
235
- usable: auth.configured,
236
- freshness: auth.configured ? 'healthy' : 'unconfigured',
237
- detail: auth.detail ?? 'Provider expects a configured API key.',
238
- ...(auth.envVars?.length ? { envVars: auth.envVars } : {}),
239
- ...(auth.envVars?.length
240
- ? { repairHints: [`Set ${auth.envVars.join(' or ')} in the environment or secrets store.`] }
241
- : {}),
242
- }];
243
- case 'oauth':
244
- return [{
245
- route: 'service-oauth',
246
- label: 'OAuth session',
247
- configured: auth.configured,
248
- usable: auth.configured,
249
- freshness: auth.configured ? 'healthy' : 'unconfigured',
250
- detail: auth.detail ?? 'Provider expects an OAuth-backed credential.',
251
- repairHints: ['Refresh or repair the provider OAuth session before relying on it.'],
252
- }];
253
- default:
254
- return [];
255
- }
256
- }
257
-
258
- function getUsableRoute(route: ProviderAuthRouteDescriptor): boolean {
259
- return route.usable ?? route.configured;
260
- }
261
-
262
- function pickRoute(
263
- routes: readonly ProviderAuthRouteDescriptor[],
264
- ): ProviderAuthRouteDescriptor | null {
265
- if (routes.length === 0) return null;
266
- return [...routes].sort((left, right) => routePriority(left.route) - routePriority(right.route))[0] ?? null;
267
- }
268
-
269
- function buildProviderRuntimeRecord(
270
- snapshot: {
271
- readonly providerId: string;
272
- readonly active: boolean;
273
- readonly modelCount: number;
274
- readonly runtime: {
275
- readonly auth?: {
276
- readonly mode: 'api-key' | 'oauth' | 'anonymous' | 'none';
277
- readonly configured: boolean;
278
- readonly detail?: string;
279
- readonly envVars?: readonly string[];
280
- readonly routes?: readonly ProviderAuthRouteDescriptor[];
281
- };
282
- };
283
- },
284
- ): ProviderRuntimeRecord {
285
- const auth = snapshot.runtime.auth;
286
- const routes = auth?.routes?.length ? auth.routes : buildSyntheticAuthRoutes(auth);
287
- const configuredRoutes = routes.filter((route) => route.configured);
288
- const usableRoutes = routes.filter(getUsableRoute);
289
- const preferredRoute = pickRoute(configuredRoutes.length > 0 ? configuredRoutes : routes);
290
- const activeRoute = pickRoute(usableRoutes.length > 0 ? usableRoutes : (preferredRoute ? [preferredRoute] : []));
291
- const activeRouteId = activeRoute?.route ?? 'unconfigured';
292
- const preferredRouteId = preferredRoute?.route ?? activeRouteId;
293
- const authFreshness = activeRoute?.freshness ?? (activeRouteId === 'none' ? 'healthy' : 'unconfigured');
294
- const fallbackRisk = usableRoutes.length > 1
295
- ? 'Multiple auth routes are simultaneously usable; verify route priority before switching providers.'
296
- : undefined;
297
-
298
- const issueSet = new Set<string>();
299
- const actionSet = new Set<string>();
300
-
301
- if (activeRouteId === 'unconfigured' && auth?.mode !== 'none') {
302
- issueSet.add('Provider has no usable auth route configured.');
303
- }
304
-
305
- for (const route of routes) {
306
- if (route.freshness === 'expired') {
307
- issueSet.add(route.detail ?? `${route.label} is expired.`);
308
- } else if (route.freshness === 'pending') {
309
- issueSet.add(route.detail ?? `${route.label} is pending completion.`);
310
- } else if (route.configured && !getUsableRoute(route)) {
311
- issueSet.add(route.detail ?? `${route.label} is configured but not currently usable.`);
312
- }
313
- for (const hint of route.repairHints ?? []) {
314
- if (hint.trim().length > 0) actionSet.add(hint);
315
- }
316
- }
317
-
318
- if (fallbackRisk) issueSet.add(fallbackRisk);
319
- if (issueSet.size > 0 && actionSet.size === 0 && activeRouteId !== 'none') {
320
- actionSet.add(`Review ${snapshot.providerId} provider credentials and routing metadata.`);
321
- }
322
-
323
- return {
324
- providerId: snapshot.providerId,
325
- active: snapshot.active,
326
- modelCount: snapshot.modelCount,
327
- activeRoute: activeRouteId,
328
- preferredRoute: preferredRouteId,
329
- activeRouteReason: activeRoute?.detail
330
- ?? auth?.detail
331
- ?? (activeRouteId === 'none'
332
- ? 'Provider does not require interactive credentials.'
333
- : 'No usable auth route is configured for this provider.'),
334
- authFreshness,
335
- ...(fallbackRisk ? { fallbackRisk } : {}),
336
- issues: [...issueSet],
337
- recommendedActions: [...actionSet],
338
- };
339
- }
340
-
341
- // ---------------------------------------------------------------------------
342
- // ProviderHealthPanel
104
+ // ProviderHealthPanel — the merged provider console (WO-112)
343
105
  // ---------------------------------------------------------------------------
344
106
 
345
107
  /**
346
- * Real-time provider health / status dashboard.
347
- *
348
- * Displays for each known provider:
349
- * - Status indicator (online / rate-limited / error / unknown)
350
- * - Last response latency
351
- * - Last seen timestamp
352
- * - Last error message (if any)
353
- * - Active cooldown timer for rate-limited providers
108
+ * Single provider console. Absorbs the retired providers (stats) and accounts
109
+ * panels:
110
+ * - Status, latency avg/p95, error attribution, sparkline timelines, and
111
+ * per-provider token/cost totals in one table (ProviderHealthDataProvider).
112
+ * - Fallback chain with current node and fallover count.
113
+ * - Auth routes with per-route freshness, issues, and repair hints
114
+ * (ProviderRuntimeInspectionQuery.inspectAll() is the single route source).
115
+ * - Repair Domains + Session Maintenance as a secondary 't' view.
116
+ * - Enter dispatches '/accounts repair <provider>' through the panel
117
+ * integration context; r forces a posture refresh.
354
118
  */
355
119
  export class ProviderHealthPanel extends BasePanel {
356
120
  private _unsubs: Array<() => void> = [];
357
- private _refreshTimerId: ReturnType<typeof setInterval> | null = null;
121
+ private _tickTimerId: ReturnType<typeof setInterval> | null = null;
358
122
  private _selectedIndex = 0;
359
123
  private _scrollOffset = 0;
360
- private _accountRecords = new Map<string, ProviderRuntimeRecord>();
124
+ private _view: ConsoleView = 'providers';
125
+ private _accountRecords = new Map<string, ProviderAccountPosture>();
361
126
  private _accountRefreshAt = 0;
362
127
  private _accountLoading = false;
128
+ private _inflightProvider: string | null = null;
129
+ private _lastResponseProvider: string | null = null;
130
+ private _unattributedError: string | null = null;
131
+ private _modelState: ModelDomainState;
132
+ private readonly _dataProvider: ProviderHealthDataProvider;
363
133
  private readonly providerHealthTracker = new ProviderHealthTracker();
364
134
 
365
135
  constructor(
@@ -367,10 +137,13 @@ export class ProviderHealthPanel extends BasePanel {
367
137
  private readonly deps: ProviderHealthPanelDeps,
368
138
  private readonly requestRender: () => void = () => {},
369
139
  ) {
370
- super('provider-health', 'Health', 'N', 'monitoring');
140
+ super('provider-health', 'Health', 'N', 'providers');
141
+ this._modelState = deps.modelState?.get() ?? this._syntheticModelState();
142
+ this._dataProvider = new ProviderHealthDataProvider(this._buildHealthState(), this._modelState);
371
143
  this._subscribe();
372
144
  void this._refreshAccountPosture(true);
373
- this._ensureRefreshTimer();
145
+ // Background 30s posture refresh (auth routes go stale otherwise); cleared on destroy.
146
+ this.registerTimer(setInterval(() => { void this._refreshAccountPosture(); }, 30_000));
374
147
  }
375
148
 
376
149
  // -------------------------------------------------------------------------
@@ -390,33 +163,79 @@ export class ProviderHealthPanel extends BasePanel {
390
163
  }),
391
164
  );
392
165
 
166
+ // The provider named on the in-flight LLM request is the authoritative
167
+ // attribution target when the turn subsequently errors.
168
+ this._unsubs.push(
169
+ this.deps.turnEvents.on('LLM_REQUEST_STARTED', (payload) => {
170
+ this._inflightProvider = payload.provider;
171
+ }),
172
+ );
173
+
393
174
  this._unsubs.push(
394
175
  this.deps.turnEvents.on('LLM_RESPONSE_RECEIVED', (payload) => {
395
- this.providerHealthTracker.onLlmResponse(payload.provider);
176
+ this.providerHealthTracker.onLlmResponse(payload.provider, {
177
+ model: payload.model,
178
+ inputTokens: payload.inputTokens,
179
+ outputTokens: payload.outputTokens,
180
+ ...(payload.cacheReadTokens !== undefined ? { cacheReadTokens: payload.cacheReadTokens } : {}),
181
+ ...(payload.cacheWriteTokens !== undefined ? { cacheWriteTokens: payload.cacheWriteTokens } : {}),
182
+ });
183
+ this._lastResponseProvider = payload.provider;
184
+ this._pushHealthState();
396
185
  this._markDirtyAndRender();
397
186
  }),
398
187
  );
399
188
 
400
189
  this._unsubs.push(
401
190
  this.deps.turnEvents.on('TURN_ERROR', (payload) => {
402
- this.providerHealthTracker.onTurnError(payload.error);
191
+ const providerId = this._resolveActiveProviderId();
192
+ if (providerId) {
193
+ this.providerHealthTracker.onTurnError(payload.error, providerId);
194
+ this._unattributedError = null;
195
+ } else {
196
+ // No provider is resolvable (nothing registered yet) — surface the
197
+ // error in the posture block instead of inventing a phantom row.
198
+ this._unattributedError = payload.error.slice(0, 120);
199
+ }
200
+ this._inflightProvider = null;
201
+ this._pushHealthState();
403
202
  this._markDirtyAndRender();
404
203
  }),
405
204
  );
406
205
 
206
+ this._unsubs.push(
207
+ this.deps.turnEvents.on('TURN_COMPLETED', () => {
208
+ this._inflightProvider = null;
209
+ }),
210
+ );
211
+
212
+ this._unsubs.push(
213
+ this.deps.turnEvents.on('TURN_CANCEL', () => {
214
+ this._inflightProvider = null;
215
+ }),
216
+ );
217
+
407
218
  this._unsubs.push(
408
219
  this.deps.providerEvents.on('PROVIDERS_CHANGED', () => {
409
- this.providerHealthTracker.onProvidersChanged([
410
- ...new Set([
411
- ...this.deps.providers.getSnapshot().providerIds,
412
- ...this.providerRuntime.listProviderIds(),
413
- ]),
414
- ]);
220
+ this.providerHealthTracker.onProvidersChanged(this._knownProviders());
415
221
  void this._refreshAccountPosture(true);
222
+ this._pushHealthState();
416
223
  this._markDirtyAndRender();
417
224
  }),
418
225
  );
419
226
 
227
+ if (this.deps.modelState) {
228
+ const modelState = this.deps.modelState;
229
+ this._unsubs.push(modelState.subscribe(() => {
230
+ const next = modelState.get();
231
+ if (next !== this._modelState) {
232
+ this._modelState = next;
233
+ this._dataProvider.updateModelState(next);
234
+ this._markDirtyAndRender();
235
+ }
236
+ }));
237
+ }
238
+
420
239
  for (const readModel of [
421
240
  this.deps.providers,
422
241
  this.deps.session,
@@ -445,39 +264,57 @@ export class ProviderHealthPanel extends BasePanel {
445
264
  super.onActivate();
446
265
  this.markDirty();
447
266
  void this._refreshAccountPosture(true);
448
- this._ensureRefreshTimer();
267
+ this._startTickTimer();
449
268
  }
450
269
 
451
270
  override onDeactivate(): void {
452
271
  super.onDeactivate();
272
+ // Stop the per-second display tick while hidden (TokenBudgetPanel pattern);
273
+ // the 30s posture refresh keeps running so data stays warm for preload.
274
+ this._stopTickTimer();
453
275
  }
454
276
 
455
277
  override onDestroy(): void {
456
278
  super.onDestroy();
457
- this._refreshTimerId = null;
279
+ this._tickTimerId = null;
280
+ this._dataProvider.dispose();
458
281
  for (const unsub of this._unsubs) unsub();
459
282
  this._unsubs = [];
460
283
  }
461
284
 
462
- private _ensureRefreshTimer(): void {
463
- if (this._refreshTimerId !== null) return;
464
- this._refreshTimerId = this.registerTimer(setInterval(() => {
465
- if (Date.now() - this._accountRefreshAt > 30_000) {
466
- void this._refreshAccountPosture();
467
- }
285
+ private _startTickTimer(): void {
286
+ if (this._tickTimerId !== null) return;
287
+ this._tickTimerId = this.registerTimer(setInterval(() => {
468
288
  this.markDirty();
469
289
  this.requestRender();
470
290
  }, 1_000));
471
291
  }
472
292
 
293
+ private _stopTickTimer(): void {
294
+ if (this._tickTimerId !== null) {
295
+ this.clearTimer(this._tickTimerId);
296
+ this._tickTimerId = null;
297
+ }
298
+ }
299
+
300
+ // -------------------------------------------------------------------------
301
+ // Input
302
+ // -------------------------------------------------------------------------
303
+
473
304
  handleInput(key: string): boolean {
474
- const knownSet = new Set([
475
- ...this.deps.providers.getSnapshot().providerIds,
476
- ...this.providerRuntime.listProviderIds(),
477
- ...this._accountRecords.keys(),
478
- ]);
479
- for (const h of this.providerHealthTracker.getAll()) knownSet.add(h.name);
480
- const providers = [...knownSet].sort();
305
+ if (key === 't' || key === 'tab') {
306
+ const next = CONSOLE_VIEWS[(CONSOLE_VIEWS.indexOf(this._view) + 1) % CONSOLE_VIEWS.length];
307
+ this._view = next ?? 'providers';
308
+ this._scrollOffset = 0;
309
+ this.markDirty();
310
+ return true;
311
+ }
312
+ if (key === 'r') {
313
+ void this._refreshAccountPosture(true);
314
+ this.markDirty();
315
+ return true;
316
+ }
317
+ const providers = this._knownProviders();
481
318
  if (providers.length === 0) return false;
482
319
  if (key === 'j' || key === 'down' || key === '\x1b[B') {
483
320
  this._selectedIndex = Math.min(providers.length - 1, this._selectedIndex + 1);
@@ -492,6 +329,95 @@ export class ProviderHealthPanel extends BasePanel {
492
329
  return false;
493
330
  }
494
331
 
332
+ /** Enter dispatches the real repair command for the selected provider. */
333
+ handlePanelIntegrationAction(key: string, ctx: PanelIntegrationContext): boolean {
334
+ if (key !== 'enter' && key !== 'return') return false;
335
+ if (!ctx.executeCommand) return false;
336
+ const provider = this._selectedProvider();
337
+ if (!provider) return false;
338
+ void ctx.executeCommand('accounts', ['repair', provider]).catch(() => {
339
+ // Command output surfaces in conversation; dispatch failures are non-fatal here.
340
+ });
341
+ return true;
342
+ }
343
+
344
+ // -------------------------------------------------------------------------
345
+ // Data plumbing
346
+ // -------------------------------------------------------------------------
347
+
348
+ private _knownProviders(): string[] {
349
+ const known = new Set<string>([
350
+ ...this.deps.providers.getSnapshot().providerIds,
351
+ ...this.providerRuntime.listProviderIds(),
352
+ ...this._accountRecords.keys(),
353
+ ]);
354
+ for (const health of this.providerHealthTracker.getAll()) known.add(health.name);
355
+ return [...known].sort();
356
+ }
357
+
358
+ private _selectedProvider(): string | undefined {
359
+ const providers = this._knownProviders();
360
+ return providers[Math.min(this._selectedIndex, Math.max(0, providers.length - 1))];
361
+ }
362
+
363
+ /**
364
+ * Resolve the provider that should own a TURN_ERROR: the in-flight LLM
365
+ * request's provider, then the store model domain, then the configured
366
+ * provider.model, then the last responding provider, then the sole known
367
+ * provider. Never returns 'unknown'.
368
+ */
369
+ private _resolveActiveProviderId(): string | undefined {
370
+ if (this._inflightProvider) return this._inflightProvider;
371
+ if (this.deps.modelState) {
372
+ const storeProvider = this.deps.modelState.get().activeProviderId;
373
+ if (storeProvider && storeProvider !== 'unknown') return storeProvider;
374
+ }
375
+ const raw = this.deps.configManager.get('provider.model');
376
+ if (typeof raw === 'string' && raw.includes(':')) {
377
+ const providerId = raw.split(':')[0];
378
+ if (providerId) return providerId;
379
+ }
380
+ if (this._lastResponseProvider) return this._lastResponseProvider;
381
+ const known = this._knownProviders();
382
+ if (known.length === 1) return known[0];
383
+ return undefined;
384
+ }
385
+
386
+ private _syntheticModelState(): ModelDomainState {
387
+ const base = createInitialModelState();
388
+ const raw = this.deps.configManager.get('provider.model');
389
+ if (typeof raw !== 'string' || !raw.includes(':')) return base;
390
+ const [providerId = '', ...rest] = raw.split(':');
391
+ const modelId = rest.join(':');
392
+ if (!providerId || !modelId) return base;
393
+ return {
394
+ ...base,
395
+ activeProviderId: providerId,
396
+ activeModelId: modelId,
397
+ displayName: raw,
398
+ registryKey: raw,
399
+ source: 'provider-health-panel',
400
+ lastUpdatedAt: Date.now(),
401
+ };
402
+ }
403
+
404
+ private _buildHealthState() {
405
+ const activeId = this._resolveActiveProviderId();
406
+ const meta: ProviderHealthMeta[] = this._knownProviders().map((providerId) => {
407
+ const account = this._accountRecords.get(providerId);
408
+ return {
409
+ providerId,
410
+ isActive: providerId === activeId,
411
+ isConfigured: account ? account.activeRoute !== 'unconfigured' : true,
412
+ };
413
+ });
414
+ return this.providerHealthTracker.buildHealthDomainState(meta);
415
+ }
416
+
417
+ private _pushHealthState(): void {
418
+ this._dataProvider.updateHealthState(this._buildHealthState());
419
+ }
420
+
495
421
  private async _refreshAccountPosture(force = false): Promise<void> {
496
422
  if (this._accountLoading) return;
497
423
  if (!force && Date.now() - this._accountRefreshAt < 15_000) return;
@@ -500,10 +426,11 @@ export class ProviderHealthPanel extends BasePanel {
500
426
  const snapshots = await this.providerRuntime.inspectAll();
501
427
  this._accountRecords = new Map(
502
428
  snapshots
503
- .map((snapshot) => buildProviderRuntimeRecord(snapshot))
429
+ .map((snapshot) => buildAccountPosture(snapshot))
504
430
  .map((record) => [record.providerId, record] as const),
505
431
  );
506
432
  this._accountRefreshAt = Date.now();
433
+ this._pushHealthState();
507
434
  this.markDirty();
508
435
  this.requestRender();
509
436
  } finally {
@@ -511,26 +438,45 @@ export class ProviderHealthPanel extends BasePanel {
511
438
  }
512
439
  }
513
440
 
441
+ private _collectDomainSummaries(): HealthDomainSummary[] {
442
+ return [...buildProviderHealthDomainSummaries({
443
+ configManager: this.deps.configManager,
444
+ auth: this.deps.localAuth.getSnapshot(),
445
+ settings: this.deps.settings.getSnapshot(),
446
+ remote: this.deps.remote.getSnapshot(),
447
+ security: this.deps.security.getSnapshot(),
448
+ intelligence: this.deps.intelligence.getSnapshot(),
449
+ continuity: this.deps.continuity.getSnapshot(),
450
+ worktrees: this.deps.worktrees.getSnapshot(),
451
+ session: this.deps.session.getSnapshot(),
452
+ })];
453
+ }
454
+
455
+ private _evaluateMaintenance() {
456
+ const session = this.deps.session.getSnapshot();
457
+ return evaluateSessionMaintenance({
458
+ configManager: this.deps.configManager,
459
+ currentTokens: session.estimatedContextTokens,
460
+ contextWindow: session.contextWindow,
461
+ messageCount: session.messageCount,
462
+ session: session.session,
463
+ });
464
+ }
465
+
514
466
  // -------------------------------------------------------------------------
515
467
  // Rendering
516
468
  // -------------------------------------------------------------------------
517
469
 
518
470
  override render(width: number, height: number): Line[] {
519
- const intro = 'Cross-domain health workspace for providers, auth, settings, remote, MCP, continuity, worktrees, and maintenance posture.';
471
+ const palette = { ...DEFAULT_PANEL_PALETTE, header: C.title };
520
472
 
521
- const knownSet = new Set([
522
- ...this.deps.providers.getSnapshot().providerIds,
523
- ...this.providerRuntime.listProviderIds(),
524
- ...this._accountRecords.keys(),
525
- ]);
526
- for (const h of this.providerHealthTracker.getAll()) knownSet.add(h.name);
527
- const providers = [...knownSet].sort();
473
+ const providers = this._knownProviders();
528
474
  this._selectedIndex = Math.min(this._selectedIndex, Math.max(0, providers.length - 1));
529
475
 
530
476
  if (providers.length === 0) {
531
477
  return buildPanelWorkspace(width, height, {
532
478
  title: 'Health',
533
- intro,
479
+ intro: INTRO,
534
480
  sections: [{
535
481
  lines: buildEmptyState(
536
482
  width,
@@ -540,201 +486,97 @@ export class ProviderHealthPanel extends BasePanel {
540
486
  { command: '/provider', summary: 'review current provider and model selection' },
541
487
  { command: '/subscription', summary: 'review provider login and subscription state' },
542
488
  ],
543
- { ...DEFAULT_PANEL_PALETTE, header: C.title },
489
+ palette,
544
490
  ),
545
491
  }],
546
- palette: { ...DEFAULT_PANEL_PALETTE, header: C.title },
492
+ palette,
547
493
  });
548
494
  }
549
495
 
550
- let online = 0;
551
- let rateLimited = 0;
552
- let errored = 0;
553
- let accountIssues = 0;
554
- let expiringAuth = 0;
555
- for (const name of providers) {
556
- const status = this.providerHealthTracker.get(name)?.status ?? 'unknown';
557
- if (status === 'healthy') online++;
558
- else if (status === 'rate_limited') rateLimited++;
559
- else if (status === 'degraded' || status === 'auth_error' || status === 'unavailable') errored++;
560
- const account = this._accountRecords.get(name);
561
- if (account) {
562
- accountIssues += account.issues.length;
563
- if (account.authFreshness === 'expiring' || account.authFreshness === 'expired' || account.authFreshness === 'pending') {
564
- expiringAuth++;
565
- }
566
- }
567
- }
496
+ const snapshot = this._dataProvider.getSnapshot();
497
+ const entriesById = new Map(snapshot.entries.map((entry) => [entry.providerId, entry] as const));
498
+ const selectedName = providers[this._selectedIndex];
568
499
 
569
- const postureLines = [
570
- buildKeyValueLine(width, [
571
- { label: 'providers', value: String(providers.length), valueColor: C.value },
572
- { label: 'online', value: String(online), valueColor: C.good },
573
- { label: 'rate-limited', value: String(rateLimited), valueColor: C.warn },
574
- { label: 'error', value: String(errored), valueColor: C.bad },
575
- { label: 'auth alerts', value: String(expiringAuth), valueColor: expiringAuth > 0 ? C.warn : C.dim },
576
- { label: 'account issues', value: String(accountIssues), valueColor: accountIssues > 0 ? C.bad : C.dim },
577
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }),
578
- buildGuidanceLine(width, '/provider', 'review provider selection and routing if health posture degrades', { ...DEFAULT_PANEL_PALETTE, header: C.title }),
579
- buildGuidanceLine(width, '/accounts', 'inspect auth routes, fallback posture, and billing-path safety', { ...DEFAULT_PANEL_PALETTE, header: C.title }),
580
- ];
581
-
582
- const domainLines: Line[] = [];
583
- for (const domain of buildProviderHealthDomainSummaries({
584
- configManager: this.deps.configManager,
585
- auth: this.deps.localAuth.getSnapshot(),
586
- settings: this.deps.settings.getSnapshot(),
587
- remote: this.deps.remote.getSnapshot(),
588
- security: this.deps.security.getSnapshot(),
589
- intelligence: this.deps.intelligence.getSnapshot(),
590
- continuity: this.deps.continuity.getSnapshot(),
591
- worktrees: this.deps.worktrees.getSnapshot(),
592
- session: this.deps.session.getSnapshot(),
593
- })) {
594
- domainLines.push(buildAlignedRow(
595
- width,
596
- [
597
- { text: domain.name, fg: C.value, bold: true },
598
- { text: domain.summary, fg: domainColor(domain.level) },
599
- { text: domain.next, fg: C.dim },
600
- ],
601
- [
602
- { width: 14 },
603
- { width: Math.max(10, width - 38) },
604
- { width: 20 },
605
- ],
606
- ));
607
- for (const detail of domain.details.slice(0, 2)) {
608
- domainLines.push(...buildBodyText(width, ` ${detail}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.dim));
609
- }
610
- if (domain.nextSteps.length > 1) {
611
- domainLines.push(...buildBodyText(width, ` next: ${domain.nextSteps.join(' | ')}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.title));
500
+ const footerHint = buildKeyboardHints(width, [
501
+ { keys: 'j/k', label: 'select provider' },
502
+ { keys: 'enter', label: 'repair auth routes' },
503
+ { keys: 'r', label: 'refresh posture' },
504
+ { keys: 't', label: `view: ${this._view}` },
505
+ { keys: '/provider', label: 'switch model' },
506
+ ], palette);
507
+
508
+ const collapsedDomains = this._view !== 'domains'
509
+ ? {
510
+ attention: this._collectDomainSummaries().filter((summary) => summary.level === 'warn' || summary.level === 'bad').length,
511
+ maintenanceLevel: this._evaluateMaintenance().level,
612
512
  }
613
- }
513
+ : undefined;
514
+
515
+ const postureSection: PanelWorkspaceSection = {
516
+ lines: buildSummaryBlock(width, 'Provider console posture', buildPostureLines(width, C, palette, {
517
+ providers,
518
+ entriesById,
519
+ accounts: this._accountRecords,
520
+ trackerRecords: this.providerHealthTracker.getAll(),
521
+ compositeStatus: snapshot.compositeStatus,
522
+ falloverCount: snapshot.fallbackChain.falloverCount,
523
+ activeProvider: this._resolveActiveProviderId(),
524
+ ...(collapsedDomains ? { collapsedDomains } : {}),
525
+ unattributedError: this._unattributedError,
526
+ }), palette),
527
+ };
614
528
 
615
- const selectedName = providers[this._selectedIndex];
616
- const selectedHealth = selectedName ? this.providerHealthTracker.get(selectedName) : undefined;
617
- const selectedAccount = selectedName ? this._accountRecords.get(selectedName) : undefined;
618
- const selectedLines: Line[] = [];
619
- const maintenanceLines: Line[] = [];
620
- const session = this.deps.session.getSnapshot();
621
- const maintenance = evaluateSessionMaintenance({
622
- configManager: this.deps.configManager,
623
- currentTokens: session.estimatedContextTokens,
624
- contextWindow: session.contextWindow,
625
- messageCount: session.messageCount,
626
- session: session.session,
627
- });
628
- maintenanceLines.push(buildKeyValueLine(width, [
629
- { label: 'level', value: maintenance.level, valueColor: maintenance.level === 'needs-repair' ? C.bad : maintenance.level === 'suggest-compact' || maintenance.level === 'watch' ? C.warn : C.good },
630
- { label: 'guidance', value: maintenance.guidanceMode, valueColor: C.value },
631
- { label: 'usage', value: `${maintenance.usagePct}%`, valueColor: maintenance.usagePct >= 80 ? C.bad : maintenance.usagePct >= 70 ? C.warn : C.value },
632
- { label: 'remaining', value: maintenance.remainingTokens.toLocaleString(), valueColor: C.value },
633
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }));
634
- for (const reason of maintenance.reasons.slice(0, 3)) {
635
- maintenanceLines.push(...buildBodyText(width, reason, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.dim));
636
- }
637
- if (maintenance.nextSteps.length > 0) {
638
- maintenanceLines.push(...buildBodyText(width, `Next: ${maintenance.nextSteps.join(' | ')}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.title));
529
+ if (this._view === 'domains') {
530
+ return this._renderDomainsView(width, height, postureSection, footerHint, palette);
639
531
  }
640
- if (selectedName) {
641
- const status = selectedHealth?.status ?? 'unknown';
642
- selectedLines.push(buildKeyValueLine(width, [
643
- { label: 'provider', value: selectedName, valueColor: C.value },
644
- { label: 'status', value: statusLabel(status), valueColor: statusDot(status).color },
645
- { label: 'last ok', value: fmtAgo(selectedHealth?.lastSuccessAt), valueColor: C.value },
646
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }));
647
- if (selectedHealth?.rateLimitExpiresAt && selectedHealth.rateLimitExpiresAt > Date.now()) {
648
- selectedLines.push(...buildBodyText(width, `Cooldown: ${fmtCooldown(selectedHealth.rateLimitExpiresAt)}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.warn));
649
- }
650
- if (selectedHealth?.lastErrorMessage) {
651
- selectedLines.push(...buildBodyText(width, `Last error: ${selectedHealth.lastErrorMessage}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.bad));
652
- }
653
- if (selectedAccount) {
654
- selectedLines.push(buildKeyValueLine(width, [
655
- { label: 'route', value: selectedAccount.activeRoute, valueColor: routeColor(selectedAccount.activeRoute) },
656
- { label: 'preferred', value: selectedAccount.preferredRoute, valueColor: C.dim },
657
- { label: 'freshness', value: selectedAccount.authFreshness, valueColor: freshnessColor(selectedAccount.authFreshness) },
658
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }));
659
- selectedLines.push(buildKeyValueLine(width, [
660
- { label: 'models', value: String(selectedAccount.modelCount), valueColor: C.value },
661
- { label: 'active', value: selectedAccount.active ? 'yes' : 'no', valueColor: selectedAccount.active ? C.good : C.dim },
662
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }));
663
- selectedLines.push(...buildBodyText(width, `Auth route: ${selectedAccount.activeRouteReason}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.dim));
664
- if (selectedAccount.fallbackRisk) {
665
- selectedLines.push(...buildBodyText(width, `Fallback: ${selectedAccount.fallbackRisk}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.warn));
666
- }
667
- if (selectedAccount.issues.length > 0) {
668
- selectedLines.push(...buildBodyText(width, `Issue: ${selectedAccount.issues[0]!}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.bad));
669
- }
670
- if (selectedAccount.recommendedActions.length > 0) {
671
- selectedLines.push(...buildBodyText(width, `Next: ${selectedAccount.recommendedActions[0]!}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.title));
672
- }
673
- }
532
+ if (this._view === 'routes') {
533
+ return this._renderRoutesView(width, height, postureSection, footerHint, palette, selectedName);
674
534
  }
535
+ return this._renderProvidersView(width, height, postureSection, footerHint, palette, providers, entriesById, snapshot.fallbackChain, selectedName);
536
+ }
675
537
 
676
- const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'Health posture', postureLines, { ...DEFAULT_PANEL_PALETTE, header: C.title }) };
677
- const domainsSection: PanelWorkspaceSection = { title: 'Repair Domains', lines: domainLines };
678
- const maintenanceSections = maintenanceLines.length > 0 ? [{ title: 'Session Maintenance', lines: maintenanceLines } satisfies PanelWorkspaceSection] : [];
679
- const selectedSections = selectedLines.length > 0 ? [{ lines: buildDetailBlock(width, 'Selected provider', selectedLines, { ...DEFAULT_PANEL_PALETTE, header: C.title }) } satisfies PanelWorkspaceSection] : [];
680
- const footerHint = buildKeyboardHints(width, [
681
- { keys: 'j/k or Up/Down', label: 'select provider' },
682
- { keys: '/provider', label: 'switch model' },
683
- { keys: '/accounts', label: 'auth routes' },
684
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title });
685
- const providerColumnHeader = buildAlignedRow(
686
- width,
687
- [
688
- { text: 'provider', fg: C.label, bold: true },
689
- { text: 'status', fg: C.label, bold: true },
690
- { text: 'lat', fg: C.label, bold: true },
691
- { text: 'last ok', fg: C.label, bold: true },
692
- { text: 'auth', fg: C.label, bold: true },
693
- ],
694
- [
695
- { width: 18 },
696
- { width: 13 },
697
- { width: 8, align: 'right' },
698
- { width: 10, align: 'right' },
699
- { width: 12 },
700
- ],
701
- { marker: '▸' },
702
- );
703
- const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
704
- intro,
538
+ private _renderProvidersView(
539
+ width: number,
540
+ height: number,
541
+ postureSection: PanelWorkspaceSection,
542
+ footerHint: Line,
543
+ palette: typeof DEFAULT_PANEL_PALETTE,
544
+ providers: readonly string[],
545
+ entriesById: ReadonlyMap<string, ProviderHealthEntry>,
546
+ chain: FallbackChainData,
547
+ selectedName: string | undefined,
548
+ ): Line[] {
549
+ const chainLines = buildChainLines(width, C, palette, chain);
550
+ const chainSections: PanelWorkspaceSection[] = chainLines.length > 0
551
+ ? [{ title: 'Fallback Chain', lines: chainLines }]
552
+ : [];
553
+
554
+ const rows = providers.map((name, absolute) => buildProviderRow(width, C, {
555
+ name,
556
+ entry: entriesById.get(name),
557
+ health: this.providerHealthTracker.get(name),
558
+ account: this._accountRecords.get(name),
559
+ selected: absolute === this._selectedIndex,
560
+ }));
561
+
562
+ const selectedSections: PanelWorkspaceSection[] = selectedName
563
+ ? [buildSelectedDetailSection(width, C, palette, {
564
+ selectedName,
565
+ entry: entriesById.get(selectedName),
566
+ health: this.providerHealthTracker.get(selectedName),
567
+ account: this._accountRecords.get(selectedName),
568
+ })]
569
+ : [];
570
+
571
+ const resolved = resolvePrimaryScrollableSection(width, height, {
572
+ intro: INTRO,
705
573
  footerLines: [footerHint],
706
- palette: { ...DEFAULT_PANEL_PALETTE, header: C.title },
707
- beforeSections: [postureSection, domainsSection, ...maintenanceSections],
574
+ palette,
575
+ beforeSections: [postureSection, ...chainSections],
708
576
  section: {
709
577
  title: 'Providers',
710
- fixedLines: [providerColumnHeader],
711
- scrollableLines: providers.map((name, absolute) => {
712
- const health = this.providerHealthTracker.get(name);
713
- const status = health?.status ?? 'unknown';
714
- const dot = statusDot(status);
715
- const latency = health?.lastLatencyMs !== undefined ? fmtMs(health.lastLatencyMs) : 'n/a';
716
- const latencyFg = health?.lastLatencyMs !== undefined ? latencyColor(health.lastLatencyMs) : C.dim;
717
- const account = this._accountRecords.get(name);
718
- const authFg = account ? freshnessColor(account.authFreshness) : C.dim;
719
- return buildAlignedRow(
720
- width,
721
- [
722
- { text: `${dot.char} ${name}`, fg: C.value, bold: absolute === this._selectedIndex },
723
- { text: statusLabel(status), fg: dot.color },
724
- { text: latency, fg: latencyFg },
725
- { text: fmtAgo(health?.lastSuccessAt), fg: C.value },
726
- { text: account ? account.authFreshness : '-', fg: authFg },
727
- ],
728
- [
729
- { width: 18 },
730
- { width: 13 },
731
- { width: 8, align: 'right' },
732
- { width: 10, align: 'right' },
733
- { width: 12 },
734
- ],
735
- { selected: absolute === this._selectedIndex, selectedBg: C.rowSelectBg, marker: '▸' },
736
- );
737
- }),
578
+ fixedLines: [buildProviderColumnHeader(width, C)],
579
+ scrollableLines: rows,
738
580
  selectedIndex: this._selectedIndex,
739
581
  scrollOffset: this._scrollOffset,
740
582
  guardRows: 1,
@@ -743,20 +585,94 @@ export class ProviderHealthPanel extends BasePanel {
743
585
  },
744
586
  afterSections: selectedSections,
745
587
  });
746
- this._scrollOffset = resolvedProvidersSection.scrollOffset;
747
- const sections: PanelWorkspaceSection[] = [
748
- postureSection,
749
- domainsSection,
750
- ...maintenanceSections,
751
- resolvedProvidersSection.section,
752
- ...selectedSections,
753
- ];
588
+ this._scrollOffset = resolved.scrollOffset;
589
+
590
+ return buildPanelWorkspace(width, height, {
591
+ title: 'Health',
592
+ intro: INTRO,
593
+ sections: [postureSection, ...chainSections, resolved.section, ...selectedSections],
594
+ footerLines: [footerHint],
595
+ palette,
596
+ });
597
+ }
598
+
599
+ private _renderRoutesView(
600
+ width: number,
601
+ height: number,
602
+ postureSection: PanelWorkspaceSection,
603
+ footerHint: Line,
604
+ palette: typeof DEFAULT_PANEL_PALETTE,
605
+ selectedName: string | undefined,
606
+ ): Line[] {
607
+ const account = selectedName ? this._accountRecords.get(selectedName) : undefined;
608
+ const routeLines = buildRouteViewLines(width, C, palette, account);
609
+
610
+ const resolved = resolvePrimaryScrollableSection(width, height, {
611
+ intro: INTRO,
612
+ footerLines: [footerHint],
613
+ palette,
614
+ beforeSections: [postureSection],
615
+ section: {
616
+ title: `Auth Routes — ${selectedName ?? 'n/a'}`,
617
+ fixedLines: [buildRouteColumnHeader(width, C)],
618
+ scrollableLines: routeLines,
619
+ selectedIndex: 0,
620
+ scrollOffset: this._scrollOffset,
621
+ guardRows: 1,
622
+ minRows: 4,
623
+ appendWindowSummary: { dimColor: C.dim },
624
+ },
625
+ afterSections: [],
626
+ });
627
+ this._scrollOffset = resolved.scrollOffset;
628
+
629
+ return buildPanelWorkspace(width, height, {
630
+ title: 'Health',
631
+ intro: INTRO,
632
+ sections: [postureSection, resolved.section],
633
+ footerLines: [footerHint],
634
+ palette,
635
+ });
636
+ }
637
+
638
+ private _renderDomainsView(
639
+ width: number,
640
+ height: number,
641
+ postureSection: PanelWorkspaceSection,
642
+ footerHint: Line,
643
+ palette: typeof DEFAULT_PANEL_PALETTE,
644
+ ): Line[] {
645
+ const domainLines = buildDomainLines(width, C, palette, this._collectDomainSummaries());
646
+ const maintenanceSection: PanelWorkspaceSection = {
647
+ title: 'Session Maintenance',
648
+ lines: buildMaintenanceLines(width, C, palette, this._evaluateMaintenance()),
649
+ };
650
+
651
+ const resolved = resolvePrimaryScrollableSection(width, height, {
652
+ intro: INTRO,
653
+ footerLines: [footerHint],
654
+ palette,
655
+ beforeSections: [postureSection],
656
+ section: {
657
+ title: 'Repair Domains',
658
+ fixedLines: [],
659
+ scrollableLines: domainLines,
660
+ selectedIndex: 0,
661
+ scrollOffset: this._scrollOffset,
662
+ guardRows: 1,
663
+ minRows: 4,
664
+ appendWindowSummary: { dimColor: C.dim },
665
+ },
666
+ afterSections: [maintenanceSection],
667
+ });
668
+ this._scrollOffset = resolved.scrollOffset;
669
+
754
670
  return buildPanelWorkspace(width, height, {
755
671
  title: 'Health',
756
- intro,
757
- sections,
672
+ intro: INTRO,
673
+ sections: [postureSection, resolved.section, maintenanceSection],
758
674
  footerLines: [footerHint],
759
- palette: { ...DEFAULT_PANEL_PALETTE, header: C.title },
675
+ palette,
760
676
  });
761
677
  }
762
678
  }