@pellux/goodvibes-tui 0.27.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 (143) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/cost-runtime.ts +49 -0
  9. package/src/input/commands/eval.ts +4 -3
  10. package/src/input/commands/operator-runtime.ts +5 -1
  11. package/src/input/commands.ts +2 -0
  12. package/src/input/handler-feed-routes.ts +60 -26
  13. package/src/input/handler-feed.ts +17 -8
  14. package/src/input/handler-picker-routes.ts +18 -13
  15. package/src/input/handler-shortcuts.ts +51 -0
  16. package/src/input/handler-ui-state.ts +4 -0
  17. package/src/input/handler.ts +43 -6
  18. package/src/input/keybindings.ts +53 -8
  19. package/src/input/model-picker.ts +5 -0
  20. package/src/input/panel-integration-actions.ts +117 -1
  21. package/src/main.ts +5 -2
  22. package/src/panels/agent-inspector-panel.ts +139 -45
  23. package/src/panels/agent-inspector-shared.ts +147 -3
  24. package/src/panels/approval-panel.ts +204 -102
  25. package/src/panels/automation-control-panel.ts +370 -103
  26. package/src/panels/base-panel.ts +26 -2
  27. package/src/panels/builtin/agent.ts +28 -37
  28. package/src/panels/builtin/development.ts +40 -32
  29. package/src/panels/builtin/knowledge.ts +15 -6
  30. package/src/panels/builtin/operations.ts +178 -117
  31. package/src/panels/builtin/session.ts +35 -9
  32. package/src/panels/builtin/shared.ts +98 -6
  33. package/src/panels/cockpit-panel.ts +253 -75
  34. package/src/panels/cockpit-read-model.ts +2 -1
  35. package/src/panels/communication-panel.ts +159 -56
  36. package/src/panels/confirm-state.ts +8 -1
  37. package/src/panels/control-plane-panel.ts +354 -95
  38. package/src/panels/cost-tracker-panel.ts +243 -57
  39. package/src/panels/debug-panel.ts +347 -159
  40. package/src/panels/diff-panel.ts +257 -82
  41. package/src/panels/docs-panel.ts +166 -64
  42. package/src/panels/eval-panel.ts +336 -109
  43. package/src/panels/expandable-list-panel.ts +189 -0
  44. package/src/panels/file-explorer-panel.ts +238 -159
  45. package/src/panels/file-preview-panel.ts +141 -59
  46. package/src/panels/git-panel.ts +360 -191
  47. package/src/panels/hooks-panel.ts +181 -19
  48. package/src/panels/incident-review-panel.ts +252 -43
  49. package/src/panels/index.ts +0 -4
  50. package/src/panels/intelligence-panel.ts +310 -103
  51. package/src/panels/knowledge-graph-panel.ts +480 -58
  52. package/src/panels/local-auth-panel.ts +251 -33
  53. package/src/panels/marketplace-panel.ts +219 -33
  54. package/src/panels/memory-panel.ts +83 -81
  55. package/src/panels/ops-control-panel.ts +211 -32
  56. package/src/panels/ops-strategy-panel.ts +131 -45
  57. package/src/panels/orchestration-panel.ts +259 -67
  58. package/src/panels/panel-list-panel.ts +184 -136
  59. package/src/panels/panel-manager.ts +120 -13
  60. package/src/panels/plan-dashboard-panel.ts +507 -74
  61. package/src/panels/plugins-panel.ts +227 -34
  62. package/src/panels/policy-panel.ts +264 -55
  63. package/src/panels/polish-core.ts +162 -0
  64. package/src/panels/polish-tables.ts +258 -0
  65. package/src/panels/polish.ts +67 -149
  66. package/src/panels/project-planning-answer-actions.ts +134 -0
  67. package/src/panels/project-planning-panel.ts +84 -113
  68. package/src/panels/provider-health-panel.ts +438 -480
  69. package/src/panels/provider-health-routes.ts +203 -0
  70. package/src/panels/provider-health-tracker.ts +194 -6
  71. package/src/panels/provider-health-views.ts +560 -0
  72. package/src/panels/qr-panel.ts +136 -38
  73. package/src/panels/remote-panel.ts +123 -38
  74. package/src/panels/routes-panel.ts +87 -24
  75. package/src/panels/sandbox-panel.ts +232 -65
  76. package/src/panels/scrollable-list-panel.ts +143 -145
  77. package/src/panels/security-panel.ts +204 -52
  78. package/src/panels/services-panel.ts +174 -76
  79. package/src/panels/session-browser-panel.ts +101 -6
  80. package/src/panels/session-maintenance.ts +4 -122
  81. package/src/panels/settings-sync-panel.ts +346 -68
  82. package/src/panels/skills-panel.ts +157 -89
  83. package/src/panels/subscription-panel.ts +105 -34
  84. package/src/panels/symbol-outline-panel.ts +287 -154
  85. package/src/panels/system-messages-panel.ts +172 -38
  86. package/src/panels/tasks-panel.ts +348 -134
  87. package/src/panels/thinking-panel.ts +66 -32
  88. package/src/panels/token-budget-panel.ts +233 -46
  89. package/src/panels/tool-inspector-panel.ts +170 -54
  90. package/src/panels/types.ts +65 -4
  91. package/src/panels/work-plan-panel.ts +393 -39
  92. package/src/panels/worktree-panel.ts +239 -61
  93. package/src/panels/wrfc-panel-format.ts +133 -0
  94. package/src/panels/wrfc-panel.ts +146 -150
  95. package/src/renderer/compaction-preview.ts +2 -1
  96. package/src/renderer/compositor.ts +46 -43
  97. package/src/renderer/conversation-overlays.ts +25 -11
  98. package/src/renderer/footer-tips.ts +41 -0
  99. package/src/renderer/fullscreen-primitives.ts +22 -16
  100. package/src/renderer/help-overlay.ts +91 -14
  101. package/src/renderer/hint-grammar.ts +52 -0
  102. package/src/renderer/layout.ts +7 -7
  103. package/src/renderer/modal-factory.ts +23 -15
  104. package/src/renderer/model-picker-overlay.ts +30 -11
  105. package/src/renderer/model-workspace.ts +2 -3
  106. package/src/renderer/overlay-box.ts +16 -10
  107. package/src/renderer/panel-composite.ts +7 -20
  108. package/src/renderer/panel-workspace-bar.ts +14 -8
  109. package/src/renderer/process-indicator.ts +3 -1
  110. package/src/renderer/progress.ts +8 -6
  111. package/src/renderer/search-overlay.ts +27 -6
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal.ts +70 -10
  114. package/src/renderer/shell-surface.ts +41 -15
  115. package/src/renderer/status-glyphs.ts +11 -9
  116. package/src/renderer/tab-strip.ts +148 -34
  117. package/src/renderer/theme.ts +60 -3
  118. package/src/renderer/ui-factory.ts +45 -36
  119. package/src/renderer/ui-primitives.ts +23 -2
  120. package/src/runtime/bootstrap-shell.ts +35 -18
  121. package/src/runtime/diagnostics/panels/index.ts +0 -3
  122. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  123. package/src/runtime/ui/model-picker/types.ts +4 -0
  124. package/src/shell/ui-openers.ts +14 -22
  125. package/src/utils/format-duration.ts +55 -0
  126. package/src/utils/format-number.ts +71 -0
  127. package/src/utils/splash-lines.ts +44 -3
  128. package/src/version.ts +1 -1
  129. package/src/work-plans/work-plan-store.ts +13 -0
  130. package/src/panels/agent-logs-panel.ts +0 -666
  131. package/src/panels/agent-logs-shared.ts +0 -129
  132. package/src/panels/context-visualizer-panel.ts +0 -214
  133. package/src/panels/forensics-panel.ts +0 -364
  134. package/src/panels/panel-picker.ts +0 -106
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -218
  137. package/src/panels/provider-stats-panel.ts +0 -366
  138. package/src/panels/schedule-panel.ts +0 -342
  139. package/src/panels/watchers-panel.ts +0 -193
  140. package/src/renderer/panel-picker-overlay.ts +0 -202
  141. package/src/renderer/panel-tab-bar.ts +0 -69
  142. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  143. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,17 +1,30 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import { BasePanel } from './base-panel.ts';
3
- import { createEmptyLine, createStyledCell, type Line } from '../types/grid.ts';
4
- import type { ProviderAuthRouteDescriptor } from '@pellux/goodvibes-sdk/platform/providers';
5
- 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';
6
6
  import type { UiEventFeed } from '../runtime/ui-events.ts';
7
7
  import {
8
8
  type ProviderRuntimeInspectionQuery,
9
9
  } from '../runtime/ui-service-queries.ts';
10
- 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';
11
18
  import {
12
19
  buildProviderHealthDomainSummaries,
13
20
  type HealthDomainSummary,
14
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';
15
28
  import type {
16
29
  UiContinuitySnapshot,
17
30
  UiIntelligenceSnapshot,
@@ -24,15 +37,9 @@ import type {
24
37
  UiSettingsSnapshot,
25
38
  UiWorktreeSnapshot,
26
39
  } from '../runtime/ui-read-models.ts';
27
- import { evaluateSessionMaintenance } from '@/runtime/index.ts';
28
40
  import {
29
- buildBodyText,
30
- buildDetailBlock,
31
41
  buildEmptyState,
32
- buildGuidanceLine,
33
- buildKeyValueLine,
34
- buildPanelListRow,
35
- buildPanelLine,
42
+ buildKeyboardHints,
36
43
  buildPanelWorkspace,
37
44
  buildSummaryBlock,
38
45
  DEFAULT_PANEL_PALETTE,
@@ -40,6 +47,17 @@ import {
40
47
  resolvePrimaryScrollableSection,
41
48
  type PanelWorkspaceSection,
42
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';
43
61
 
44
62
  // ---------------------------------------------------------------------------
45
63
  // Types
@@ -58,309 +76,60 @@ export interface ProviderHealthPanelDeps {
58
76
  readonly intelligence: UiReadModel<UiIntelligenceSnapshot>;
59
77
  readonly continuity: UiReadModel<UiContinuitySnapshot>;
60
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
+ };
61
88
  }
62
89
 
63
- // Colors
64
- // ---------------------------------------------------------------------------
90
+ type ConsoleView = 'providers' | 'routes' | 'domains';
91
+
92
+ const CONSOLE_VIEWS: readonly ConsoleView[] = ['providers', 'routes', 'domains'] as const;
65
93
 
94
+ // Base chrome plus console accents (domain accents only — hex ratchet).
66
95
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
67
96
  title: '#00ffff',
68
97
  unknown: '244',
98
+ rowSelectBg: '#111827',
69
99
  });
70
100
 
71
- const LATENCY_WARN_MS = 2_000;
72
- const LATENCY_BAD_MS = 5_000;
101
+ const INTRO = 'Provider console: status, latency timelines, error attribution, auth routes, and fallback posture.';
73
102
 
74
103
  // ---------------------------------------------------------------------------
75
- // Helpers
76
- // ---------------------------------------------------------------------------
77
-
78
- function statusDot(status: ProviderStatus): { char: string; color: string } {
79
- switch (status) {
80
- case 'healthy': return { char: '●', color: C.good };
81
- case 'degraded': return { char: '◑', color: C.warn };
82
- case 'rate_limited': return { char: '◐', color: C.warn };
83
- case 'auth_error': return { char: '✕', color: C.bad };
84
- case 'unavailable': return { char: '✕', color: C.bad };
85
- default: return { char: '○', color: C.unknown };
86
- }
87
- }
88
-
89
- function statusLabel(status: ProviderStatus): string {
90
- switch (status) {
91
- case 'healthy': return 'online';
92
- case 'degraded': return 'degraded';
93
- case 'rate_limited': return 'rate-limited';
94
- case 'auth_error': return 'auth error';
95
- case 'unavailable': return 'unavailable';
96
- default: return 'unknown';
97
- }
98
- }
99
-
100
- function latencyColor(ms: number): string {
101
- if (ms >= LATENCY_BAD_MS) return C.bad;
102
- if (ms >= LATENCY_WARN_MS) return C.warn;
103
- return C.good;
104
- }
105
-
106
- function fmtMs(ms: number): string {
107
- if (ms <= 0) return 'n/a';
108
- if (ms >= 10_000) return `${(ms / 1000).toFixed(1)}s`;
109
- if (ms >= 1_000) return `${(ms / 1000).toFixed(2)}s`;
110
- return `${Math.round(ms)}ms`;
111
- }
112
-
113
- function fmtAgo(ts: number | undefined): string {
114
- if (!ts) return 'n/a';
115
- const sec = Math.floor((Date.now() - ts) / 1000);
116
- if (sec < 60) return `${sec}s ago`;
117
- if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
118
- return `${Math.floor(sec / 3600)}h ago`;
119
- }
120
-
121
- function fmtCooldown(expiresAt: number): string {
122
- const remaining = Math.ceil((expiresAt - Date.now()) / 1000);
123
- if (remaining <= 0) return 'expiring';
124
- return `${remaining}s cooldown`;
125
- }
126
-
127
- type ProviderPanelAuthRoute = ProviderAuthRouteDescriptor['route'] | 'unconfigured';
128
- type ProviderPanelAuthFreshness = NonNullable<ProviderAuthRouteDescriptor['freshness']> | 'unconfigured';
129
-
130
- interface ProviderRuntimeRecord {
131
- readonly providerId: string;
132
- readonly active: boolean;
133
- readonly modelCount: number;
134
- readonly activeRoute: ProviderPanelAuthRoute;
135
- readonly preferredRoute: ProviderPanelAuthRoute;
136
- readonly activeRouteReason: string;
137
- readonly authFreshness: ProviderPanelAuthFreshness;
138
- readonly fallbackRisk?: string;
139
- readonly issues: readonly string[];
140
- readonly recommendedActions: readonly string[];
141
- }
142
-
143
- function domainColor(level: HealthDomainSummary['level']): string {
144
- switch (level) {
145
- case 'good':
146
- return C.good;
147
- case 'warn':
148
- return C.warn;
149
- case 'bad':
150
- return C.bad;
151
- default:
152
- return C.value;
153
- }
154
- }
155
-
156
- const AUTH_ROUTE_PRIORITY: readonly ProviderPanelAuthRoute[] = [
157
- 'subscription-oauth',
158
- 'service-oauth',
159
- 'secret-ref',
160
- 'api-key',
161
- 'anonymous',
162
- 'none',
163
- 'unconfigured',
164
- ] as const;
165
-
166
- function routePriority(route: ProviderPanelAuthRoute): number {
167
- const priority = AUTH_ROUTE_PRIORITY.indexOf(route);
168
- return priority >= 0 ? priority : AUTH_ROUTE_PRIORITY.length;
169
- }
170
-
171
- function routeColor(route: ProviderPanelAuthRoute): string {
172
- switch (route) {
173
- case 'subscription-oauth':
174
- return C.title;
175
- case 'service-oauth':
176
- return C.good;
177
- case 'api-key':
178
- return C.warn;
179
- case 'secret-ref':
180
- return C.value;
181
- case 'anonymous':
182
- case 'none':
183
- return C.dim;
184
- default:
185
- return C.value;
186
- }
187
- }
188
-
189
- function freshnessColor(freshness: ProviderPanelAuthFreshness): string {
190
- switch (freshness) {
191
- case 'expired':
192
- return C.bad;
193
- case 'expiring':
194
- case 'pending':
195
- return C.warn;
196
- case 'healthy':
197
- return C.good;
198
- default:
199
- return C.dim;
200
- }
201
- }
202
-
203
- function buildSyntheticAuthRoutes(
204
- auth: {
205
- readonly mode: 'api-key' | 'oauth' | 'anonymous' | 'none';
206
- readonly configured: boolean;
207
- readonly detail?: string;
208
- readonly envVars?: readonly string[];
209
- } | undefined,
210
- ): readonly ProviderAuthRouteDescriptor[] {
211
- if (!auth) return [];
212
- switch (auth.mode) {
213
- case 'none':
214
- return [{
215
- route: 'none',
216
- label: 'No auth required',
217
- configured: true,
218
- usable: true,
219
- freshness: 'healthy',
220
- detail: auth.detail ?? 'Provider does not require interactive credentials.',
221
- }];
222
- case 'anonymous':
223
- return [{
224
- route: 'anonymous',
225
- label: 'Anonymous / local access',
226
- configured: auth.configured,
227
- usable: auth.configured,
228
- freshness: auth.configured ? 'healthy' : 'unconfigured',
229
- detail: auth.detail ?? 'Provider can be used without stored credentials.',
230
- }];
231
- case 'api-key':
232
- return [{
233
- route: 'api-key',
234
- label: 'Ambient API key',
235
- configured: auth.configured,
236
- usable: auth.configured,
237
- freshness: auth.configured ? 'healthy' : 'unconfigured',
238
- detail: auth.detail ?? 'Provider expects a configured API key.',
239
- ...(auth.envVars?.length ? { envVars: auth.envVars } : {}),
240
- ...(auth.envVars?.length
241
- ? { repairHints: [`Set ${auth.envVars.join(' or ')} in the environment or secrets store.`] }
242
- : {}),
243
- }];
244
- case 'oauth':
245
- return [{
246
- route: 'service-oauth',
247
- label: 'OAuth session',
248
- configured: auth.configured,
249
- usable: auth.configured,
250
- freshness: auth.configured ? 'healthy' : 'unconfigured',
251
- detail: auth.detail ?? 'Provider expects an OAuth-backed credential.',
252
- repairHints: ['Refresh or repair the provider OAuth session before relying on it.'],
253
- }];
254
- default:
255
- return [];
256
- }
257
- }
258
-
259
- function getUsableRoute(route: ProviderAuthRouteDescriptor): boolean {
260
- return route.usable ?? route.configured;
261
- }
262
-
263
- function pickRoute(
264
- routes: readonly ProviderAuthRouteDescriptor[],
265
- ): ProviderAuthRouteDescriptor | null {
266
- if (routes.length === 0) return null;
267
- return [...routes].sort((left, right) => routePriority(left.route) - routePriority(right.route))[0] ?? null;
268
- }
269
-
270
- function buildProviderRuntimeRecord(
271
- snapshot: {
272
- readonly providerId: string;
273
- readonly active: boolean;
274
- readonly modelCount: number;
275
- readonly runtime: {
276
- readonly auth?: {
277
- readonly mode: 'api-key' | 'oauth' | 'anonymous' | 'none';
278
- readonly configured: boolean;
279
- readonly detail?: string;
280
- readonly envVars?: readonly string[];
281
- readonly routes?: readonly ProviderAuthRouteDescriptor[];
282
- };
283
- };
284
- },
285
- ): ProviderRuntimeRecord {
286
- const auth = snapshot.runtime.auth;
287
- const routes = auth?.routes?.length ? auth.routes : buildSyntheticAuthRoutes(auth);
288
- const configuredRoutes = routes.filter((route) => route.configured);
289
- const usableRoutes = routes.filter(getUsableRoute);
290
- const preferredRoute = pickRoute(configuredRoutes.length > 0 ? configuredRoutes : routes);
291
- const activeRoute = pickRoute(usableRoutes.length > 0 ? usableRoutes : (preferredRoute ? [preferredRoute] : []));
292
- const activeRouteId = activeRoute?.route ?? 'unconfigured';
293
- const preferredRouteId = preferredRoute?.route ?? activeRouteId;
294
- const authFreshness = activeRoute?.freshness ?? (activeRouteId === 'none' ? 'healthy' : 'unconfigured');
295
- const fallbackRisk = usableRoutes.length > 1
296
- ? 'Multiple auth routes are simultaneously usable; verify route priority before switching providers.'
297
- : undefined;
298
-
299
- const issueSet = new Set<string>();
300
- const actionSet = new Set<string>();
301
-
302
- if (activeRouteId === 'unconfigured' && auth?.mode !== 'none') {
303
- issueSet.add('Provider has no usable auth route configured.');
304
- }
305
-
306
- for (const route of routes) {
307
- if (route.freshness === 'expired') {
308
- issueSet.add(route.detail ?? `${route.label} is expired.`);
309
- } else if (route.freshness === 'pending') {
310
- issueSet.add(route.detail ?? `${route.label} is pending completion.`);
311
- } else if (route.configured && !getUsableRoute(route)) {
312
- issueSet.add(route.detail ?? `${route.label} is configured but not currently usable.`);
313
- }
314
- for (const hint of route.repairHints ?? []) {
315
- if (hint.trim().length > 0) actionSet.add(hint);
316
- }
317
- }
318
-
319
- if (fallbackRisk) issueSet.add(fallbackRisk);
320
- if (issueSet.size > 0 && actionSet.size === 0 && activeRouteId !== 'none') {
321
- actionSet.add(`Review ${snapshot.providerId} provider credentials and routing metadata.`);
322
- }
323
-
324
- return {
325
- providerId: snapshot.providerId,
326
- active: snapshot.active,
327
- modelCount: snapshot.modelCount,
328
- activeRoute: activeRouteId,
329
- preferredRoute: preferredRouteId,
330
- activeRouteReason: activeRoute?.detail
331
- ?? auth?.detail
332
- ?? (activeRouteId === 'none'
333
- ? 'Provider does not require interactive credentials.'
334
- : 'No usable auth route is configured for this provider.'),
335
- authFreshness,
336
- ...(fallbackRisk ? { fallbackRisk } : {}),
337
- issues: [...issueSet],
338
- recommendedActions: [...actionSet],
339
- };
340
- }
341
-
342
- // ---------------------------------------------------------------------------
343
- // ProviderHealthPanel
104
+ // ProviderHealthPanel — the merged provider console (WO-112)
344
105
  // ---------------------------------------------------------------------------
345
106
 
346
107
  /**
347
- * Real-time provider health / status dashboard.
348
- *
349
- * Displays for each known provider:
350
- * - Status indicator (online / rate-limited / error / unknown)
351
- * - Last response latency
352
- * - Last seen timestamp
353
- * - Last error message (if any)
354
- * - 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.
355
118
  */
356
119
  export class ProviderHealthPanel extends BasePanel {
357
120
  private _unsubs: Array<() => void> = [];
358
- private _refreshTimerId: ReturnType<typeof setInterval> | null = null;
121
+ private _tickTimerId: ReturnType<typeof setInterval> | null = null;
359
122
  private _selectedIndex = 0;
360
123
  private _scrollOffset = 0;
361
- private _accountRecords = new Map<string, ProviderRuntimeRecord>();
124
+ private _view: ConsoleView = 'providers';
125
+ private _accountRecords = new Map<string, ProviderAccountPosture>();
362
126
  private _accountRefreshAt = 0;
363
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;
364
133
  private readonly providerHealthTracker = new ProviderHealthTracker();
365
134
 
366
135
  constructor(
@@ -368,10 +137,13 @@ export class ProviderHealthPanel extends BasePanel {
368
137
  private readonly deps: ProviderHealthPanelDeps,
369
138
  private readonly requestRender: () => void = () => {},
370
139
  ) {
371
- 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);
372
143
  this._subscribe();
373
144
  void this._refreshAccountPosture(true);
374
- this._ensureRefreshTimer();
145
+ // Background 30s posture refresh (auth routes go stale otherwise); cleared on destroy.
146
+ this.registerTimer(setInterval(() => { void this._refreshAccountPosture(); }, 30_000));
375
147
  }
376
148
 
377
149
  // -------------------------------------------------------------------------
@@ -391,33 +163,79 @@ export class ProviderHealthPanel extends BasePanel {
391
163
  }),
392
164
  );
393
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
+
394
174
  this._unsubs.push(
395
175
  this.deps.turnEvents.on('LLM_RESPONSE_RECEIVED', (payload) => {
396
- 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();
397
185
  this._markDirtyAndRender();
398
186
  }),
399
187
  );
400
188
 
401
189
  this._unsubs.push(
402
190
  this.deps.turnEvents.on('TURN_ERROR', (payload) => {
403
- 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();
404
202
  this._markDirtyAndRender();
405
203
  }),
406
204
  );
407
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
+
408
218
  this._unsubs.push(
409
219
  this.deps.providerEvents.on('PROVIDERS_CHANGED', () => {
410
- this.providerHealthTracker.onProvidersChanged([
411
- ...new Set([
412
- ...this.deps.providers.getSnapshot().providerIds,
413
- ...this.providerRuntime.listProviderIds(),
414
- ]),
415
- ]);
220
+ this.providerHealthTracker.onProvidersChanged(this._knownProviders());
416
221
  void this._refreshAccountPosture(true);
222
+ this._pushHealthState();
417
223
  this._markDirtyAndRender();
418
224
  }),
419
225
  );
420
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
+
421
239
  for (const readModel of [
422
240
  this.deps.providers,
423
241
  this.deps.session,
@@ -446,39 +264,57 @@ export class ProviderHealthPanel extends BasePanel {
446
264
  super.onActivate();
447
265
  this.markDirty();
448
266
  void this._refreshAccountPosture(true);
449
- this._ensureRefreshTimer();
267
+ this._startTickTimer();
450
268
  }
451
269
 
452
270
  override onDeactivate(): void {
453
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();
454
275
  }
455
276
 
456
277
  override onDestroy(): void {
457
278
  super.onDestroy();
458
- this._refreshTimerId = null;
279
+ this._tickTimerId = null;
280
+ this._dataProvider.dispose();
459
281
  for (const unsub of this._unsubs) unsub();
460
282
  this._unsubs = [];
461
283
  }
462
284
 
463
- private _ensureRefreshTimer(): void {
464
- if (this._refreshTimerId !== null) return;
465
- this._refreshTimerId = this.registerTimer(setInterval(() => {
466
- if (Date.now() - this._accountRefreshAt > 30_000) {
467
- void this._refreshAccountPosture();
468
- }
285
+ private _startTickTimer(): void {
286
+ if (this._tickTimerId !== null) return;
287
+ this._tickTimerId = this.registerTimer(setInterval(() => {
469
288
  this.markDirty();
470
289
  this.requestRender();
471
290
  }, 1_000));
472
291
  }
473
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
+
474
304
  handleInput(key: string): boolean {
475
- const knownSet = new Set([
476
- ...this.deps.providers.getSnapshot().providerIds,
477
- ...this.providerRuntime.listProviderIds(),
478
- ...this._accountRecords.keys(),
479
- ]);
480
- for (const h of this.providerHealthTracker.getAll()) knownSet.add(h.name);
481
- 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();
482
318
  if (providers.length === 0) return false;
483
319
  if (key === 'j' || key === 'down' || key === '\x1b[B') {
484
320
  this._selectedIndex = Math.min(providers.length - 1, this._selectedIndex + 1);
@@ -493,6 +329,95 @@ export class ProviderHealthPanel extends BasePanel {
493
329
  return false;
494
330
  }
495
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
+
496
421
  private async _refreshAccountPosture(force = false): Promise<void> {
497
422
  if (this._accountLoading) return;
498
423
  if (!force && Date.now() - this._accountRefreshAt < 15_000) return;
@@ -501,10 +426,11 @@ export class ProviderHealthPanel extends BasePanel {
501
426
  const snapshots = await this.providerRuntime.inspectAll();
502
427
  this._accountRecords = new Map(
503
428
  snapshots
504
- .map((snapshot) => buildProviderRuntimeRecord(snapshot))
429
+ .map((snapshot) => buildAccountPosture(snapshot))
505
430
  .map((record) => [record.providerId, record] as const),
506
431
  );
507
432
  this._accountRefreshAt = Date.now();
433
+ this._pushHealthState();
508
434
  this.markDirty();
509
435
  this.requestRender();
510
436
  } finally {
@@ -512,26 +438,45 @@ export class ProviderHealthPanel extends BasePanel {
512
438
  }
513
439
  }
514
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
+
515
466
  // -------------------------------------------------------------------------
516
467
  // Rendering
517
468
  // -------------------------------------------------------------------------
518
469
 
519
470
  override render(width: number, height: number): Line[] {
520
- 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 };
521
472
 
522
- const knownSet = new Set([
523
- ...this.deps.providers.getSnapshot().providerIds,
524
- ...this.providerRuntime.listProviderIds(),
525
- ...this._accountRecords.keys(),
526
- ]);
527
- for (const h of this.providerHealthTracker.getAll()) knownSet.add(h.name);
528
- const providers = [...knownSet].sort();
473
+ const providers = this._knownProviders();
529
474
  this._selectedIndex = Math.min(this._selectedIndex, Math.max(0, providers.length - 1));
530
475
 
531
476
  if (providers.length === 0) {
532
477
  return buildPanelWorkspace(width, height, {
533
478
  title: 'Health',
534
- intro,
479
+ intro: INTRO,
535
480
  sections: [{
536
481
  lines: buildEmptyState(
537
482
  width,
@@ -541,158 +486,97 @@ export class ProviderHealthPanel extends BasePanel {
541
486
  { command: '/provider', summary: 'review current provider and model selection' },
542
487
  { command: '/subscription', summary: 'review provider login and subscription state' },
543
488
  ],
544
- { ...DEFAULT_PANEL_PALETTE, header: C.title },
489
+ palette,
545
490
  ),
546
491
  }],
547
- palette: { ...DEFAULT_PANEL_PALETTE, header: C.title },
492
+ palette,
548
493
  });
549
494
  }
550
495
 
551
- let online = 0;
552
- let rateLimited = 0;
553
- let errored = 0;
554
- let accountIssues = 0;
555
- let expiringAuth = 0;
556
- for (const name of providers) {
557
- const status = this.providerHealthTracker.get(name)?.status ?? 'unknown';
558
- if (status === 'healthy') online++;
559
- else if (status === 'rate_limited') rateLimited++;
560
- else if (status === 'degraded' || status === 'auth_error' || status === 'unavailable') errored++;
561
- const account = this._accountRecords.get(name);
562
- if (account) {
563
- accountIssues += account.issues.length;
564
- if (account.authFreshness === 'expiring' || account.authFreshness === 'expired' || account.authFreshness === 'pending') {
565
- expiringAuth++;
566
- }
567
- }
568
- }
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];
569
499
 
570
- const postureLines = [
571
- buildKeyValueLine(width, [
572
- { label: 'providers', value: String(providers.length), valueColor: C.value },
573
- { label: 'online', value: String(online), valueColor: C.good },
574
- { label: 'rate-limited', value: String(rateLimited), valueColor: C.warn },
575
- { label: 'error', value: String(errored), valueColor: C.bad },
576
- { label: 'auth alerts', value: String(expiringAuth), valueColor: expiringAuth > 0 ? C.warn : C.dim },
577
- { label: 'account issues', value: String(accountIssues), valueColor: accountIssues > 0 ? C.bad : C.dim },
578
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }),
579
- buildGuidanceLine(width, '/provider', 'review provider selection and routing if health posture degrades', { ...DEFAULT_PANEL_PALETTE, header: C.title }),
580
- buildGuidanceLine(width, '/accounts', 'inspect auth routes, fallback posture, and billing-path safety', { ...DEFAULT_PANEL_PALETTE, header: C.title }),
581
- ];
582
-
583
- const domainLines: Line[] = [];
584
- for (const domain of buildProviderHealthDomainSummaries({
585
- configManager: this.deps.configManager,
586
- auth: this.deps.localAuth.getSnapshot(),
587
- settings: this.deps.settings.getSnapshot(),
588
- remote: this.deps.remote.getSnapshot(),
589
- security: this.deps.security.getSnapshot(),
590
- intelligence: this.deps.intelligence.getSnapshot(),
591
- continuity: this.deps.continuity.getSnapshot(),
592
- worktrees: this.deps.worktrees.getSnapshot(),
593
- session: this.deps.session.getSnapshot(),
594
- })) {
595
- domainLines.push(buildPanelLine(width, [
596
- [' ', C.label],
597
- [domain.name.padEnd(14), C.value],
598
- [domain.summary.slice(0, Math.max(0, width - 36)).padEnd(Math.max(0, width - 36)), domainColor(domain.level)],
599
- [' ', C.label],
600
- [domain.next.slice(0, 20), C.dim],
601
- ]));
602
- for (const detail of domain.details.slice(0, 2)) {
603
- domainLines.push(...buildBodyText(width, ` ${detail}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.dim));
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,
604
512
  }
605
- if (domain.nextSteps.length > 1) {
606
- domainLines.push(...buildBodyText(width, ` next: ${domain.nextSteps.join(' | ')}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.title));
607
- }
608
- }
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
+ };
609
528
 
610
- const selectedName = providers[this._selectedIndex];
611
- const selectedHealth = selectedName ? this.providerHealthTracker.get(selectedName) : undefined;
612
- const selectedAccount = selectedName ? this._accountRecords.get(selectedName) : undefined;
613
- const selectedLines: Line[] = [];
614
- const maintenanceLines: Line[] = [];
615
- const session = this.deps.session.getSnapshot();
616
- const maintenance = evaluateSessionMaintenance({
617
- configManager: this.deps.configManager,
618
- currentTokens: session.estimatedContextTokens,
619
- contextWindow: session.contextWindow,
620
- messageCount: session.messageCount,
621
- session: session.session,
622
- });
623
- maintenanceLines.push(buildKeyValueLine(width, [
624
- { label: 'level', value: maintenance.level, valueColor: maintenance.level === 'needs-repair' ? C.bad : maintenance.level === 'suggest-compact' || maintenance.level === 'watch' ? C.warn : C.good },
625
- { label: 'guidance', value: maintenance.guidanceMode, valueColor: C.value },
626
- { label: 'usage', value: `${maintenance.usagePct}%`, valueColor: maintenance.usagePct >= 80 ? C.bad : maintenance.usagePct >= 70 ? C.warn : C.value },
627
- { label: 'remaining', value: maintenance.remainingTokens.toLocaleString(), valueColor: C.value },
628
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }));
629
- for (const reason of maintenance.reasons.slice(0, 3)) {
630
- maintenanceLines.push(...buildBodyText(width, reason, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.dim));
529
+ if (this._view === 'domains') {
530
+ return this._renderDomainsView(width, height, postureSection, footerHint, palette);
631
531
  }
632
- if (maintenance.nextSteps.length > 0) {
633
- maintenanceLines.push(...buildBodyText(width, `Next: ${maintenance.nextSteps.join(' | ')}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.title));
634
- }
635
- if (selectedName) {
636
- const status = selectedHealth?.status ?? 'unknown';
637
- selectedLines.push(buildKeyValueLine(width, [
638
- { label: 'provider', value: selectedName, valueColor: C.value },
639
- { label: 'status', value: statusLabel(status), valueColor: statusDot(status).color },
640
- { label: 'last ok', value: fmtAgo(selectedHealth?.lastSuccessAt), valueColor: C.value },
641
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }));
642
- if (selectedHealth?.rateLimitExpiresAt && selectedHealth.rateLimitExpiresAt > Date.now()) {
643
- selectedLines.push(...buildBodyText(width, `Cooldown: ${fmtCooldown(selectedHealth.rateLimitExpiresAt)}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.warn));
644
- }
645
- if (selectedHealth?.lastErrorMessage) {
646
- selectedLines.push(...buildBodyText(width, `Last error: ${selectedHealth.lastErrorMessage}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.bad));
647
- }
648
- if (selectedAccount) {
649
- selectedLines.push(buildKeyValueLine(width, [
650
- { label: 'route', value: selectedAccount.activeRoute, valueColor: routeColor(selectedAccount.activeRoute) },
651
- { label: 'preferred', value: selectedAccount.preferredRoute, valueColor: C.dim },
652
- { label: 'freshness', value: selectedAccount.authFreshness, valueColor: freshnessColor(selectedAccount.authFreshness) },
653
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }));
654
- selectedLines.push(buildKeyValueLine(width, [
655
- { label: 'models', value: String(selectedAccount.modelCount), valueColor: C.value },
656
- { label: 'active', value: selectedAccount.active ? 'yes' : 'no', valueColor: selectedAccount.active ? C.good : C.dim },
657
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }));
658
- selectedLines.push(...buildBodyText(width, `Auth route: ${selectedAccount.activeRouteReason}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.dim));
659
- if (selectedAccount.fallbackRisk) {
660
- selectedLines.push(...buildBodyText(width, `Fallback: ${selectedAccount.fallbackRisk}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.warn));
661
- }
662
- if (selectedAccount.issues.length > 0) {
663
- selectedLines.push(...buildBodyText(width, `Issue: ${selectedAccount.issues[0]!}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.bad));
664
- }
665
- if (selectedAccount.recommendedActions.length > 0) {
666
- selectedLines.push(...buildBodyText(width, `Next: ${selectedAccount.recommendedActions[0]!}`, { ...DEFAULT_PANEL_PALETTE, header: C.title }, C.title));
667
- }
668
- }
532
+ if (this._view === 'routes') {
533
+ return this._renderRoutesView(width, height, postureSection, footerHint, palette, selectedName);
669
534
  }
535
+ return this._renderProvidersView(width, height, postureSection, footerHint, palette, providers, entriesById, snapshot.fallbackChain, selectedName);
536
+ }
670
537
 
671
- const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'Health posture', postureLines, { ...DEFAULT_PANEL_PALETTE, header: C.title }) };
672
- const domainsSection: PanelWorkspaceSection = { title: 'Repair Domains', lines: domainLines };
673
- const maintenanceSections = maintenanceLines.length > 0 ? [{ title: 'Session Maintenance', lines: maintenanceLines } satisfies PanelWorkspaceSection] : [];
674
- const selectedSections = selectedLines.length > 0 ? [{ lines: buildDetailBlock(width, 'Selected provider', selectedLines, { ...DEFAULT_PANEL_PALETTE, header: C.title }) } satisfies PanelWorkspaceSection] : [];
675
- const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
676
- intro,
677
- footerLines: [buildPanelLine(width, [[' j/k or Up/Down move live cooldowns refresh while active', C.dim]])],
678
- palette: { ...DEFAULT_PANEL_PALETTE, header: C.title },
679
- beforeSections: [postureSection, domainsSection, ...maintenanceSections],
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,
573
+ footerLines: [footerHint],
574
+ palette,
575
+ beforeSections: [postureSection, ...chainSections],
680
576
  section: {
681
577
  title: 'Providers',
682
- scrollableLines: providers.map((name, absolute) => {
683
- const health = this.providerHealthTracker.get(name);
684
- const status = health?.status ?? 'unknown';
685
- const latency = health?.lastLatencyMs !== undefined ? fmtMs(health.lastLatencyMs) : 'n/a';
686
- const latencyFg = health?.lastLatencyMs !== undefined ? latencyColor(health.lastLatencyMs) : C.dim;
687
- return buildPanelListRow(width, [
688
- { text: name.padEnd(16), fg: C.value },
689
- { text: statusLabel(status).padEnd(14), fg: statusDot(status).color },
690
- { text: ' lat ', fg: C.label },
691
- { text: latency.padEnd(8), fg: latencyFg },
692
- { text: ' ok ', fg: C.label },
693
- { text: fmtAgo(health?.lastSuccessAt).padEnd(10), fg: C.value },
694
- ], { ...DEFAULT_PANEL_PALETTE, header: C.title }, { selected: absolute === this._selectedIndex, selectedBg: '#111827' });
695
- }),
578
+ fixedLines: [buildProviderColumnHeader(width, C)],
579
+ scrollableLines: rows,
696
580
  selectedIndex: this._selectedIndex,
697
581
  scrollOffset: this._scrollOffset,
698
582
  guardRows: 1,
@@ -701,20 +585,94 @@ export class ProviderHealthPanel extends BasePanel {
701
585
  },
702
586
  afterSections: selectedSections,
703
587
  });
704
- this._scrollOffset = resolvedProvidersSection.scrollOffset;
705
- const sections: PanelWorkspaceSection[] = [
706
- postureSection,
707
- domainsSection,
708
- ...maintenanceSections,
709
- resolvedProvidersSection.section,
710
- ...selectedSections,
711
- ];
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
+
712
670
  return buildPanelWorkspace(width, height, {
713
671
  title: 'Health',
714
- intro,
715
- sections,
716
- footerLines: [buildPanelLine(width, [[' j/k or Up/Down move live cooldowns refresh while active', C.dim]])],
717
- palette: { ...DEFAULT_PANEL_PALETTE, header: C.title },
672
+ intro: INTRO,
673
+ sections: [postureSection, resolved.section, maintenanceSection],
674
+ footerLines: [footerHint],
675
+ palette,
718
676
  });
719
677
  }
720
678
  }