@pellux/goodvibes-tui 0.28.0 → 1.0.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 (144) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-line-cache.ts +432 -0
  5. package/src/core/conversation-rendering.ts +11 -3
  6. package/src/core/conversation.ts +50 -4
  7. package/src/input/commands/cost-runtime.ts +49 -0
  8. package/src/input/commands/operator-runtime.ts +5 -1
  9. package/src/input/commands.ts +2 -0
  10. package/src/input/handler-content-actions.ts +8 -3
  11. package/src/input/handler-feed-routes.ts +12 -35
  12. package/src/input/handler-feed.ts +4 -8
  13. package/src/input/handler-shortcuts.ts +51 -0
  14. package/src/input/handler.ts +43 -6
  15. package/src/input/input-history.ts +17 -4
  16. package/src/input/keybindings.ts +48 -8
  17. package/src/input/panel-integration-actions.ts +107 -1
  18. package/src/main.ts +19 -11
  19. package/src/panels/agent-inspector-panel.ts +125 -36
  20. package/src/panels/agent-inspector-shared.ts +144 -0
  21. package/src/panels/approval-panel.ts +67 -16
  22. package/src/panels/automation-control-panel.ts +368 -124
  23. package/src/panels/base-panel.ts +1 -1
  24. package/src/panels/builtin/agent.ts +28 -37
  25. package/src/panels/builtin/development.ts +40 -32
  26. package/src/panels/builtin/knowledge.ts +15 -6
  27. package/src/panels/builtin/operations.ts +178 -117
  28. package/src/panels/builtin/session.ts +35 -9
  29. package/src/panels/builtin/shared.ts +98 -6
  30. package/src/panels/cockpit-panel.ts +232 -73
  31. package/src/panels/communication-panel.ts +58 -20
  32. package/src/panels/confirm-state.ts +8 -1
  33. package/src/panels/control-plane-panel.ts +351 -118
  34. package/src/panels/cost-tracker-panel.ts +165 -7
  35. package/src/panels/debug-panel.ts +312 -159
  36. package/src/panels/diff-panel.ts +209 -57
  37. package/src/panels/docs-panel.ts +152 -66
  38. package/src/panels/eval-panel.ts +220 -42
  39. package/src/panels/file-explorer-panel.ts +202 -122
  40. package/src/panels/file-preview-panel.ts +132 -57
  41. package/src/panels/git-panel.ts +310 -129
  42. package/src/panels/hooks-panel.ts +150 -22
  43. package/src/panels/incident-review-panel.ts +223 -35
  44. package/src/panels/index.ts +0 -4
  45. package/src/panels/intelligence-panel.ts +212 -86
  46. package/src/panels/knowledge-graph-panel.ts +461 -101
  47. package/src/panels/local-auth-panel.ts +240 -28
  48. package/src/panels/marketplace-panel.ts +193 -26
  49. package/src/panels/memory-panel.ts +78 -77
  50. package/src/panels/ops-control-panel.ts +146 -29
  51. package/src/panels/ops-strategy-panel.ts +72 -4
  52. package/src/panels/orchestration-panel.ts +231 -69
  53. package/src/panels/panel-list-panel.ts +154 -131
  54. package/src/panels/panel-manager.ts +97 -9
  55. package/src/panels/plan-dashboard-panel.ts +333 -17
  56. package/src/panels/plugins-panel.ts +184 -29
  57. package/src/panels/policy-panel.ts +210 -38
  58. package/src/panels/polish-core.ts +7 -2
  59. package/src/panels/polish.ts +23 -4
  60. package/src/panels/project-planning-answer-actions.ts +134 -0
  61. package/src/panels/project-planning-panel.ts +62 -113
  62. package/src/panels/provider-health-panel.ts +434 -518
  63. package/src/panels/provider-health-routes.ts +203 -0
  64. package/src/panels/provider-health-tracker.ts +194 -6
  65. package/src/panels/provider-health-views.ts +560 -0
  66. package/src/panels/qr-panel.ts +116 -30
  67. package/src/panels/remote-panel.ts +114 -36
  68. package/src/panels/routes-panel.ts +63 -22
  69. package/src/panels/sandbox-panel.ts +174 -42
  70. package/src/panels/scrollable-list-panel.ts +19 -135
  71. package/src/panels/security-panel.ts +133 -33
  72. package/src/panels/services-panel.ts +116 -64
  73. package/src/panels/session-browser-panel.ts +73 -2
  74. package/src/panels/session-maintenance.ts +4 -122
  75. package/src/panels/settings-sync-panel.ts +335 -72
  76. package/src/panels/skills-panel.ts +157 -89
  77. package/src/panels/subscription-panel.ts +86 -33
  78. package/src/panels/symbol-outline-panel.ts +248 -108
  79. package/src/panels/system-messages-panel.ts +114 -13
  80. package/src/panels/tasks-panel.ts +326 -139
  81. package/src/panels/thinking-panel.ts +43 -10
  82. package/src/panels/token-budget-panel.ts +194 -18
  83. package/src/panels/tool-inspector-panel.ts +144 -34
  84. package/src/panels/types.ts +40 -4
  85. package/src/panels/work-plan-panel.ts +280 -17
  86. package/src/panels/worktree-panel.ts +175 -42
  87. package/src/panels/wrfc-panel.ts +116 -24
  88. package/src/renderer/agent-detail-modal.ts +37 -8
  89. package/src/renderer/code-block.ts +58 -28
  90. package/src/renderer/context-inspector.ts +3 -6
  91. package/src/renderer/conversation-overlays.ts +25 -11
  92. package/src/renderer/conversation-surface.ts +1 -21
  93. package/src/renderer/diff-view.ts +6 -4
  94. package/src/renderer/footer-tips.ts +41 -0
  95. package/src/renderer/fullscreen-primitives.ts +23 -17
  96. package/src/renderer/fullscreen-workspace.ts +2 -7
  97. package/src/renderer/help-overlay.ts +91 -14
  98. package/src/renderer/hint-grammar.ts +52 -0
  99. package/src/renderer/history-search-overlay.ts +10 -16
  100. package/src/renderer/layout.ts +7 -7
  101. package/src/renderer/markdown.ts +9 -1
  102. package/src/renderer/modal-factory.ts +23 -15
  103. package/src/renderer/model-picker-overlay.ts +8 -485
  104. package/src/renderer/model-workspace.ts +9 -24
  105. package/src/renderer/overlay-box.ts +23 -19
  106. package/src/renderer/process-indicator.ts +15 -25
  107. package/src/renderer/profile-picker-modal.ts +13 -14
  108. package/src/renderer/prompt-content-width.ts +16 -0
  109. package/src/renderer/search-overlay.ts +27 -6
  110. package/src/renderer/selection-modal-overlay.ts +3 -1
  111. package/src/renderer/semantic-diff.ts +1 -1
  112. package/src/renderer/session-picker-modal.ts +6 -4
  113. package/src/renderer/settings-modal-helpers.ts +10 -34
  114. package/src/renderer/settings-modal.ts +70 -10
  115. package/src/renderer/shell-surface.ts +54 -15
  116. package/src/renderer/status-glyphs.ts +11 -9
  117. package/src/renderer/surface-layout.ts +0 -12
  118. package/src/renderer/term-caps.ts +1 -1
  119. package/src/renderer/theme.ts +60 -3
  120. package/src/renderer/tool-call.ts +6 -20
  121. package/src/renderer/ui-factory.ts +48 -37
  122. package/src/renderer/ui-primitives.ts +41 -3
  123. package/src/runtime/bootstrap-shell.ts +35 -18
  124. package/src/runtime/diagnostics/panels/index.ts +0 -3
  125. package/src/runtime/render-scheduler.ts +80 -0
  126. package/src/shell/ui-openers.ts +14 -22
  127. package/src/utils/format-duration.ts +2 -2
  128. package/src/utils/splash-lines.ts +54 -5
  129. package/src/version.ts +1 -1
  130. package/src/work-plans/work-plan-store.ts +13 -0
  131. package/src/panels/agent-logs-panel.ts +0 -635
  132. package/src/panels/agent-logs-shared.ts +0 -129
  133. package/src/panels/context-visualizer-panel.ts +0 -238
  134. package/src/panels/forensics-panel.ts +0 -378
  135. package/src/panels/provider-account-snapshot.ts +0 -259
  136. package/src/panels/provider-accounts-panel.ts +0 -255
  137. package/src/panels/provider-stats-panel.ts +0 -391
  138. package/src/panels/schedule-panel.ts +0 -365
  139. package/src/panels/watchers-panel.ts +0 -213
  140. package/src/renderer/file-tree.ts +0 -153
  141. package/src/renderer/progress.ts +0 -99
  142. package/src/renderer/status-token.ts +0 -67
  143. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  144. package/src/runtime/diagnostics/panels/policy.ts +0 -177
@@ -1,255 +0,0 @@
1
- import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import { truncateDisplay } from '../utils/terminal-width.ts';
4
- import { ScrollableListPanel } from './scrollable-list-panel.ts';
5
- import {
6
- buildAlignedRow,
7
- buildDetailBlock,
8
- buildEmptyState,
9
- buildGuidanceLine,
10
- buildKeyboardHints,
11
- buildKeyValueLine,
12
- buildPanelLine,
13
- buildSummaryBlock,
14
- buildPanelWorkspace,
15
- DEFAULT_PANEL_PALETTE,
16
- resolvePrimaryScrollableSection,
17
- type PanelWorkspaceSection,
18
- } from './polish.ts';
19
- import {
20
- type ProviderAccountRecord,
21
- type ProviderAccountSnapshot,
22
- type ProviderAccountSnapshotQuery,
23
- } from './provider-account-snapshot.ts';
24
-
25
- export interface ProviderAccountsPanelDeps {
26
- readonly providerAccounts: ProviderAccountSnapshotQuery;
27
- }
28
-
29
- const C = {
30
- ...DEFAULT_PANEL_PALETTE,
31
- selectBg: '#1e293b',
32
- } as const;
33
-
34
- export class ProviderAccountsPanel extends ScrollableListPanel<ProviderAccountRecord> {
35
- private records: ProviderAccountRecord[] = [];
36
- private loading = false;
37
- private readonly providerAccounts: ProviderAccountSnapshotQuery;
38
-
39
- public constructor(deps: ProviderAccountsPanelDeps) {
40
- super('accounts', 'Accounts', 'Q', 'monitoring');
41
- this.showSelectionGutter = true; // I5: non-color selection affordance
42
- this.providerAccounts = deps.providerAccounts;
43
- void this.refresh();
44
- }
45
-
46
- public override onActivate(): void {
47
- super.onActivate();
48
- if (!this.loading) void this.refresh();
49
- }
50
-
51
- protected getItems(): readonly ProviderAccountRecord[] {
52
- return this.records;
53
- }
54
-
55
- private readonly listColumns = [
56
- { width: 16 },
57
- { width: 14 },
58
- { width: 8, align: 'right' as const },
59
- { width: 11 },
60
- { width: 8, align: 'right' as const },
61
- ];
62
-
63
- protected renderItem(item: ProviderAccountRecord, _index: number, selected: boolean, width: number): Line {
64
- const routeFg = item.activeRoute === 'subscription' ? C.info
65
- : item.activeRoute === 'api-key' ? C.warn
66
- : item.activeRoute === 'service-oauth' ? C.value : C.dim;
67
- const freshFg = item.authFreshness === 'expired' ? C.bad
68
- : item.authFreshness === 'expiring' || item.authFreshness === 'pending' ? C.warn : C.dim;
69
- return buildAlignedRow(
70
- width,
71
- [
72
- { text: item.providerId, fg: item.active ? C.good : C.value, bold: selected },
73
- { text: item.activeRoute, fg: routeFg },
74
- { text: `${item.modelCount} mdl`, fg: C.dim },
75
- { text: item.authFreshness, fg: freshFg },
76
- { text: item.issues.length > 0 ? `${item.issues.length} !` : 'ok', fg: item.issues.length > 0 ? C.bad : C.good },
77
- ],
78
- this.listColumns,
79
- { selected, selectedBg: C.selectBg, marker: '▸' },
80
- );
81
- }
82
-
83
- public handleInput(key: string): boolean {
84
- if (key === 'r') {
85
- void this.refresh();
86
- return true;
87
- }
88
- return super.handleInput(key);
89
- }
90
-
91
- private async refresh(): Promise<void> {
92
- this.loading = true;
93
- this.markDirty();
94
- const snapshot = await this.buildSnapshot();
95
- this.records = [...snapshot.providers];
96
- this.clampSelection();
97
- this.loading = false;
98
- this.markDirty();
99
- }
100
-
101
- private async buildSnapshot(): Promise<ProviderAccountSnapshot> {
102
- return this.providerAccounts.loadSnapshot();
103
- }
104
-
105
- public render(width: number, height: number): Line[] {
106
- this.needsRender = false;
107
- const intro = 'Provider auth routes, subscription posture, quota-window hints, and routing-safety notes.';
108
- const footerLines = [buildKeyboardHints(width, [
109
- { keys: 'Up/Down', label: 'select' },
110
- { keys: 'r', label: 'refresh' },
111
- { keys: '/accounts repair <provider>', label: 'recover routing' },
112
- ], C)];
113
- if (this.loading && this.records.length === 0) {
114
- const lines = buildPanelWorkspace(width, height, {
115
- title: 'Provider Account Control Room',
116
- intro,
117
- sections: [{ lines: [buildPanelLine(width, [[' Loading provider account posture...', C.info]])] }],
118
- palette: C,
119
- });
120
- while (lines.length < height) lines.push(createEmptyLine(width));
121
- return lines;
122
- }
123
- if (this.records.length === 0) {
124
- const lines = buildPanelWorkspace(width, height, {
125
- title: 'Provider Account Control Room',
126
- intro,
127
- sections: [{
128
- lines: buildEmptyState(width, ' No provider accounts discovered.', 'Configure API keys or subscriptions to populate account routing posture.', [{ command: '/provider', summary: 'review current provider and model posture' }], C),
129
- }],
130
- palette: C,
131
- });
132
- while (lines.length < height) lines.push(createEmptyLine(width));
133
- return lines;
134
- }
135
- const issueCount = this.records.reduce((sum, record) => sum + record.issues.length + (record.fallbackRisk ? 1 : 0), 0);
136
- const expiredCount = this.records.filter((record) => record.authFreshness === 'expired').length;
137
- const pendingCount = this.records.filter((record) => record.pendingLogin).length;
138
- const fallbackCount = this.records.filter((record) => Boolean(record.fallbackRisk)).length;
139
- const selected = this.records[this.selectedIndex]!;
140
- const postureLines: Line[] = [
141
- buildKeyValueLine(width, [
142
- { label: 'providers', value: String(this.records.length), valueColor: C.value },
143
- { label: 'expired auth', value: String(expiredCount), valueColor: expiredCount > 0 ? C.bad : C.good },
144
- { label: 'pending login', value: String(pendingCount), valueColor: pendingCount > 0 ? C.warn : C.dim },
145
- { label: 'fallback risk', value: String(fallbackCount), valueColor: fallbackCount > 0 ? C.warn : C.good },
146
- ], C),
147
- buildKeyValueLine(width, [
148
- { label: 'total issues', value: String(issueCount), valueColor: issueCount > 0 ? C.bad : C.good },
149
- { label: 'selected', value: selected.providerId, valueColor: C.info },
150
- { label: 'route', value: selected.activeRoute, valueColor: selected.activeRoute === 'subscription' ? C.info : selected.activeRoute === 'api-key' ? C.warn : C.value },
151
- { label: 'freshness', value: selected.authFreshness, valueColor: selected.authFreshness === 'expired' ? C.bad : selected.authFreshness === 'expiring' || selected.authFreshness === 'pending' ? C.warn : C.good },
152
- ], C),
153
- buildGuidanceLine(width, '/accounts repair <provider>', 'review routing safety, fallback cost, and provider-specific recovery steps', C),
154
- ];
155
- const detailRows: Line[] = [
156
- buildKeyValueLine(width, [
157
- { label: 'provider', value: selected.providerId, valueColor: C.value },
158
- { label: 'active route', value: selected.activeRoute, valueColor: selected.activeRoute === 'subscription' ? C.info : selected.activeRoute === 'api-key' ? C.warn : selected.activeRoute === 'service-oauth' ? C.value : C.bad },
159
- { label: 'preferred route', value: selected.preferredRoute, valueColor: C.dim },
160
- { label: 'freshness', value: selected.authFreshness, valueColor: selected.authFreshness === 'expired' ? C.bad : selected.authFreshness === 'expiring' || selected.authFreshness === 'pending' ? C.warn : C.good },
161
- ], C),
162
- buildKeyValueLine(width, [
163
- { label: 'configured', value: selected.configured ? 'yes' : 'no', valueColor: selected.configured ? C.good : C.bad },
164
- { label: 'oauth ready', value: selected.oauthReady ? 'yes' : 'no', valueColor: selected.oauthReady ? C.info : C.dim },
165
- { label: 'pending login', value: selected.pendingLogin ? 'yes' : 'no', valueColor: selected.pendingLogin ? C.warn : C.dim },
166
- ], C),
167
- buildPanelLine(width, [[truncateDisplay(` Active route reason: ${selected.activeRouteReason}`, width), C.dim]]),
168
- buildPanelLine(width, [[truncateDisplay(` Available routes: ${selected.availableRoutes.join(', ') || 'unconfigured'}`, width), C.dim]]),
169
- ];
170
- if (selected.expiresAt) {
171
- detailRows.push(buildPanelLine(width, [
172
- [' Expires: ', C.label],
173
- [new Date(selected.expiresAt).toISOString(), C.dim],
174
- [' Token: ', C.label],
175
- [selected.tokenType ?? 'n/a', C.value],
176
- ]));
177
- }
178
- if (selected.fallbackRisk) {
179
- detailRows.push(buildPanelLine(width, [[truncateDisplay(` fallback: ${selected.fallbackRisk}`, width), C.warn]]));
180
- }
181
- for (const route of selected.routeRecords) {
182
- detailRows.push(buildPanelLine(width, [[
183
- truncateDisplay(` route ${route.route}: ${route.usable ? 'usable' : 'blocked'} • ${route.freshness} • ${route.detail}`, width),
184
- route.usable ? C.dim : C.bad,
185
- ]]));
186
- for (const issue of route.issues) {
187
- detailRows.push(buildPanelLine(width, [[truncateDisplay(` issue: ${issue}`, width), C.bad]]));
188
- }
189
- }
190
- for (const windowHint of selected.usageWindows) {
191
- detailRows.push(buildPanelLine(width, [[truncateDisplay(` ${windowHint.label}: ${windowHint.detail}`, width), C.dim]]));
192
- }
193
- for (const issue of selected.issues) {
194
- detailRows.push(buildPanelLine(width, [[truncateDisplay(` issue: ${issue}`, width), C.bad]]));
195
- }
196
- for (const note of selected.notes) {
197
- detailRows.push(buildPanelLine(width, [[truncateDisplay(` note: ${note}`, width), C.info]]));
198
- }
199
- for (const action of selected.recommendedActions) {
200
- detailRows.push(buildPanelLine(width, [[truncateDisplay(` next: ${action}`, width), C.value]]));
201
- }
202
- if (selected.issues.length === 0 && selected.notes.length === 0 && selected.usageWindows.length === 0 && selected.recommendedActions.length === 0) {
203
- detailRows.push(buildPanelLine(width, [[' No active account warnings for this provider.', C.dim]]));
204
- }
205
- const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'Provider posture', postureLines, C) };
206
- const detailsSection: PanelWorkspaceSection = { lines: buildDetailBlock(width, 'Selected provider', detailRows, C) };
207
- const rawProviderLines: Line[] = this.records.map((record, absolute) =>
208
- this.renderItem(record, absolute, absolute === this.selectedIndex, width),
209
- );
210
- const columnHeader = buildAlignedRow(
211
- width,
212
- [
213
- { text: 'provider', fg: C.label, bold: true },
214
- { text: 'route', fg: C.label, bold: true },
215
- { text: 'models', fg: C.label, bold: true },
216
- { text: 'auth', fg: C.label, bold: true },
217
- { text: 'issues', fg: C.label, bold: true },
218
- ],
219
- this.listColumns,
220
- { marker: '▸' },
221
- );
222
- const resolvedProvidersSection = resolvePrimaryScrollableSection(width, height, {
223
- intro,
224
- footerLines,
225
- palette: C,
226
- beforeSections: [postureSection],
227
- section: {
228
- title: 'Providers',
229
- fixedLines: [columnHeader],
230
- scrollableLines: rawProviderLines,
231
- selectedIndex: this.selectedIndex,
232
- scrollOffset: this.scrollStart,
233
- guardRows: 1,
234
- minRows: 4,
235
- appendWindowSummary: { dimColor: C.dim },
236
- },
237
- afterSections: [detailsSection],
238
- });
239
- this.scrollStart = resolvedProvidersSection.scrollOffset;
240
- const sections: PanelWorkspaceSection[] = [
241
- postureSection,
242
- resolvedProvidersSection.section,
243
- detailsSection,
244
- ];
245
- const lines = buildPanelWorkspace(width, height, {
246
- title: 'Provider Account Control Room',
247
- intro,
248
- sections,
249
- footerLines,
250
- palette: C,
251
- });
252
- while (lines.length < height) lines.push(createEmptyLine(width));
253
- return lines.slice(0, height);
254
- }
255
- }
@@ -1,391 +0,0 @@
1
- import { BasePanel } from './base-panel.ts';
2
- import type { Line } from '../types/grid.ts';
3
- import type { ProviderEvent, TurnEvent } from '@/runtime/index.ts';
4
- import type { UiEventFeed } from '../runtime/ui-events.ts';
5
- import type { UiProvidersSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
6
- import {
7
- buildEmptyState,
8
- buildKeyboardHints,
9
- buildKeyValueLine,
10
- buildPanelLine,
11
- buildStyledPanelLine,
12
- buildPanelWorkspace,
13
- DEFAULT_PANEL_PALETTE,
14
- extendPalette,
15
- type PanelWorkspaceSection,
16
- } from './polish.ts';
17
- import { truncateDisplay } from '../utils/terminal-width.ts';
18
- import { formatLatencyMs } from '../utils/format-duration.ts';
19
-
20
- // ---------------------------------------------------------------------------
21
- // Constants
22
- // ---------------------------------------------------------------------------
23
-
24
- // Domain-specific tones not covered by the base palette (latency/token greys).
25
- const C = extendPalette(DEFAULT_PANEL_PALETTE, {
26
- muted2: '#6b7280', // secondary dim grey for unit labels
27
- sep: '#374151', // inline separator grey
28
- tokenDim: '#64748b', // token-count grey
29
- p95: '#a78bfa', // p95 latency accent
30
- latYellow: '#eab308', // mid-latency warning
31
- });
32
-
33
- const SPARKLINE_CHARS = '._-:=+*#';
34
- const LATENCY_RING_SIZE = 20;
35
-
36
- /** Latency thresholds in ms for color-coding. */
37
- const LATENCY_GREEN = 500;
38
- const LATENCY_YELLOW = 2000;
39
-
40
- // ---------------------------------------------------------------------------
41
- // Types
42
- // ---------------------------------------------------------------------------
43
-
44
- interface ProviderRecord {
45
- /** Provider name (e.g. 'anthropic', 'openai'). */
46
- name: string;
47
- /** Currently active model ID for this provider (last seen). */
48
- lastModelId: string;
49
- /** Ring buffer of per-request latencies in ms (most-recent last). */
50
- latencies: number[];
51
- /** Total request count. */
52
- requests: number;
53
- /** Error count. */
54
- errors: number;
55
- /** Total input + output tokens summed across all requests. */
56
- totalTokens: number;
57
- }
58
-
59
- // ---------------------------------------------------------------------------
60
- // ProviderStatsPanel
61
- // ---------------------------------------------------------------------------
62
-
63
- export class ProviderStatsPanel extends BasePanel {
64
- /** Per-provider metrics keyed by provider name. */
65
- private records: Map<string, ProviderRecord> = new Map();
66
-
67
- /** Timestamp (ms) recorded at turn:start — used to compute turn latency. */
68
- private _turnStartMs: number | null = null;
69
-
70
- /** Timestamp for the current streaming LLM call start. */
71
- private _streamStartMs: number | null = null;
72
-
73
- /** Unsubscribe functions for event listeners. */
74
- private _unsubs: Array<() => void> = [];
75
-
76
- constructor(
77
- private readonly turnEvents: UiEventFeed<TurnEvent>,
78
- private readonly providerEvents: UiEventFeed<ProviderEvent>,
79
- private readonly requestRender: () => void = () => {},
80
- private readonly providers: UiReadModel<UiProvidersSnapshot>,
81
- ) {
82
- super('providers', 'Providers', 'R', 'monitoring');
83
- this._subscribe();
84
- }
85
-
86
- // -------------------------------------------------------------------------
87
- // Event subscription
88
- // -------------------------------------------------------------------------
89
-
90
- private _subscribe(): void {
91
- // Record when a turn starts so we can compute latency later
92
- this._unsubs.push(
93
- this.turnEvents.on('TURN_SUBMITTED', () => {
94
- this._turnStartMs = Date.now();
95
- }),
96
- );
97
-
98
- // Per-streaming-call timing (each iteration of the agentic loop)
99
- this._unsubs.push(
100
- this.turnEvents.on('STREAM_START', () => {
101
- this._streamStartMs = Date.now();
102
- }),
103
- );
104
-
105
- // After each LLM response (streamed or not), record metrics for the
106
- // current provider call inside the turn loop.
107
- this._unsubs.push(
108
- this.turnEvents.on('LLM_RESPONSE_RECEIVED', (env) => {
109
- const now = Date.now();
110
- const latencyMs = this._streamStartMs !== null
111
- ? now - this._streamStartMs
112
- : this._turnStartMs !== null
113
- ? now - this._turnStartMs
114
- : 0;
115
- // Reset stream start — ready for next iteration in the agentic loop
116
- this._streamStartMs = null;
117
- this._recordRequest(
118
- env.provider,
119
- env.model,
120
- latencyMs,
121
- false,
122
- env.inputTokens
123
- + env.outputTokens
124
- + (env.cacheReadTokens ?? 0)
125
- + (env.cacheWriteTokens ?? 0),
126
- );
127
-
128
- this.markDirty();
129
- this.requestRender();
130
- }),
131
- );
132
-
133
- // On error, record a failed request
134
- this._unsubs.push(
135
- this.turnEvents.on('TURN_ERROR', () => {
136
- this._turnStartMs = null;
137
- this._streamStartMs = null;
138
- this._recordRequest('unknown', 'unknown', 0, true, 0);
139
-
140
- this.markDirty();
141
- this.requestRender();
142
- }),
143
- );
144
-
145
- // Re-render when providers change (new custom providers loaded)
146
- this._unsubs.push(
147
- this.providerEvents.on('PROVIDERS_CHANGED', () => {
148
- this.markDirty();
149
- this.requestRender();
150
- }),
151
- );
152
- }
153
-
154
- // -------------------------------------------------------------------------
155
- // Metric recording
156
- // -------------------------------------------------------------------------
157
-
158
- private _recordRequest(
159
- providerName: string,
160
- modelId: string,
161
- latencyMs: number,
162
- isError: boolean,
163
- tokens: number,
164
- ): void {
165
- let rec = this.records.get(providerName);
166
- if (!rec) {
167
- rec = {
168
- name: providerName,
169
- lastModelId: modelId,
170
- latencies: [],
171
- requests: 0,
172
- errors: 0,
173
- totalTokens: 0,
174
- };
175
- this.records.set(providerName, rec);
176
- }
177
-
178
- rec.lastModelId = modelId;
179
- rec.requests++;
180
- if (isError) rec.errors++;
181
- rec.totalTokens += tokens;
182
-
183
- if (latencyMs > 0) {
184
- rec.latencies.push(latencyMs);
185
- // Keep only the most recent LATENCY_RING_SIZE samples
186
- if (rec.latencies.length > LATENCY_RING_SIZE) {
187
- rec.latencies.shift();
188
- }
189
- }
190
- }
191
-
192
- // -------------------------------------------------------------------------
193
- // Lifecycle
194
- // -------------------------------------------------------------------------
195
-
196
- override onDestroy(): void {
197
- for (const unsub of this._unsubs) unsub();
198
- this._unsubs = [];
199
- }
200
-
201
- // -------------------------------------------------------------------------
202
- // Rendering
203
- // -------------------------------------------------------------------------
204
-
205
- override render(width: number, height: number): Line[] {
206
- const knownProviders = [...this.providers.getSnapshot().providerIds];
207
-
208
- if (knownProviders.length === 0) {
209
- return buildPanelWorkspace(width, height, {
210
- title: ' Provider Stats',
211
- intro: 'Per-provider request performance, latency distribution, error pressure, and session totals.',
212
- sections: [
213
- {
214
- lines: buildEmptyState(
215
- width,
216
- ' No providers registered',
217
- 'Load or configure a provider to begin collecting per-provider latency and error metrics.',
218
- [
219
- { command: '/provider', summary: 'review current provider and model selection' },
220
- { command: '/subscription', summary: 'sign in to a subscription-backed provider' },
221
- ],
222
- DEFAULT_PANEL_PALETTE,
223
- ),
224
- },
225
- ],
226
- palette: DEFAULT_PANEL_PALETTE,
227
- });
228
- }
229
-
230
- const totalReq = [...this.records.values()].reduce((sum, rec) => sum + rec.requests, 0);
231
- const totalErr = [...this.records.values()].reduce((sum, rec) => sum + rec.errors, 0);
232
- const totalTok = [...this.records.values()].reduce((sum, rec) => sum + rec.totalTokens, 0);
233
- const allLatencies = [...this.records.values()].flatMap((rec) => rec.latencies);
234
- const providerSections: PanelWorkspaceSection[] = [
235
- {
236
- title: 'Session',
237
- lines: [
238
- buildKeyValueLine(width, [
239
- { label: 'Providers', value: String(knownProviders.length) },
240
- { label: 'Requests', value: String(totalReq), valueColor: DEFAULT_PANEL_PALETTE.info },
241
- { label: 'Errors', value: String(totalErr), valueColor: totalErr > 0 ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.good },
242
- { label: 'Tokens', value: String(totalTok), valueColor: DEFAULT_PANEL_PALETTE.value },
243
- ], DEFAULT_PANEL_PALETTE),
244
- buildKeyValueLine(width, [
245
- { label: 'Avg Latency', value: this._fmtMs(this._avg(allLatencies)), valueColor: this._latencyColor(this._avg(allLatencies)) },
246
- { label: 'P95', value: this._fmtMs(this._p95(allLatencies)), valueColor: DEFAULT_PANEL_PALETTE.warn },
247
- ], DEFAULT_PANEL_PALETTE),
248
- ],
249
- },
250
- ];
251
-
252
- for (const provName of knownProviders) {
253
- const rec = this.records.get(provName);
254
- providerSections.push({
255
- title: provName,
256
- lines: this._buildProviderRows(provName, rec, width),
257
- });
258
- }
259
-
260
- return buildPanelWorkspace(width, height, {
261
- title: ' Provider Stats',
262
- intro: 'Per-provider request performance, latency distribution, error pressure, and session totals.',
263
- sections: providerSections,
264
- footerLines: [
265
- buildPanelLine(width, [
266
- [' sparkline ', C.muted2],
267
- ['low', C.good],
268
- [' → ', C.muted2],
269
- ['high latency', C.bad],
270
- ]),
271
- buildKeyboardHints(width, [
272
- { keys: '/provider', label: 'switch provider/model' },
273
- { keys: '/health', label: 'live provider health' },
274
- ], DEFAULT_PANEL_PALETTE),
275
- ],
276
- palette: DEFAULT_PANEL_PALETTE,
277
- });
278
- }
279
-
280
- // -------------------------------------------------------------------------
281
- // Line builders
282
- // -------------------------------------------------------------------------
283
-
284
- private _buildProviderRows(
285
- provName: string,
286
- rec: ProviderRecord | undefined,
287
- width: number,
288
- ): Line[] {
289
- const rows: Line[] = [];
290
-
291
- // Determine health
292
- const hasErrors = rec !== undefined && rec.errors > 0;
293
- const dotColor = hasErrors ? C.bad : C.good;
294
-
295
- // Model ID (truncated)
296
- const modelId = rec?.lastModelId ?? 'n/a';
297
- const modelDisplay = truncateDisplay(modelId, 30);
298
-
299
- // Header row: * provider model
300
- // Build as segments to avoid multi-byte char indexing issues
301
- const headerLine = buildStyledPanelLine(width, [
302
- { text: ' ', fg: C.label },
303
- { text: '●', fg: dotColor },
304
- { text: ' ', fg: C.label },
305
- { text: `${truncateDisplay(provName, 14).padEnd(14)} `, fg: C.value, bold: true },
306
- { text: modelDisplay, fg: C.accent },
307
- ]);
308
-
309
- rows.push(headerLine);
310
-
311
- if (rec === undefined || rec.requests === 0) {
312
- rows.push(buildStyledPanelLine(width, [
313
- { text: ' No requests yet.', fg: C.muted2 },
314
- ]));
315
- } else {
316
- const avgLatency = this._avg(rec.latencies);
317
- const p95Latency = this._p95(rec.latencies);
318
- const errRate = rec.requests > 0 ? (rec.errors / rec.requests) * 100 : 0;
319
- const sparkline = this._sparkline(rec.latencies);
320
-
321
- const latFg = avgLatency < LATENCY_GREEN
322
- ? C.good
323
- : avgLatency < LATENCY_YELLOW
324
- ? C.latYellow
325
- : C.bad;
326
-
327
- const segments = [
328
- { text: ' avg ', fg: C.muted2 },
329
- { text: this._fmtMs(avgLatency).padStart(6), fg: latFg, bold: true },
330
- { text: ' p95 ', fg: C.muted2 },
331
- { text: this._fmtMs(p95Latency).padStart(6), fg: C.p95 },
332
- { text: ' ', fg: C.sep },
333
- { text: sparkline, fg: latFg },
334
- { text: ' err ', fg: C.muted2 },
335
- { text: `${errRate.toFixed(0).padStart(3)}%`, fg: errRate > 0 ? C.bad : C.good },
336
- { text: ` ${rec.requests.toString().padStart(4)}r`, fg: C.label },
337
- ] as const;
338
- const tokenSegment = rec.totalTokens > 0
339
- ? [{ text: ` ${rec.totalTokens.toString().padStart(6)}tok`, fg: C.tokenDim }]
340
- : [];
341
- rows.push(buildStyledPanelLine(width, [...segments, ...tokenSegment]));
342
- }
343
-
344
- return rows;
345
- }
346
-
347
- // -------------------------------------------------------------------------
348
- // Utilities
349
- // -------------------------------------------------------------------------
350
-
351
- private _avg(arr: number[]): number {
352
- if (arr.length === 0) return 0;
353
- return arr.reduce((s, v) => s + v, 0) / arr.length;
354
- }
355
-
356
- private _p95(arr: number[]): number {
357
- if (arr.length === 0) return 0;
358
- const sorted = [...arr].sort((a, b) => a - b);
359
- const idx = Math.floor(sorted.length * 0.95);
360
- return sorted[Math.min(idx, sorted.length - 1)] ?? 0;
361
- }
362
-
363
- private _sparkline(latencies: number[]): string {
364
- if (latencies.length === 0) return ' '.repeat(LATENCY_RING_SIZE);
365
- const vals = latencies.slice(-LATENCY_RING_SIZE);
366
- const minV = Math.min(...vals);
367
- const maxV = Math.max(...vals);
368
- const range = maxV - minV || 1;
369
- const spark: string[] = vals.map((v) => {
370
- const idx = Math.min(
371
- SPARKLINE_CHARS.length - 1,
372
- Math.floor(((v - minV) / range) * (SPARKLINE_CHARS.length - 1)),
373
- );
374
- return SPARKLINE_CHARS[idx] ?? '.';
375
- });
376
- // Pad left to always be LATENCY_RING_SIZE wide
377
- while (spark.length < LATENCY_RING_SIZE) spark.unshift(' ');
378
- return spark.join('');
379
- }
380
-
381
- private _fmtMs(ms: number): string {
382
- return formatLatencyMs(ms);
383
- }
384
-
385
- private _latencyColor(ms: number): string {
386
- if (ms <= 0) return DEFAULT_PANEL_PALETTE.dim;
387
- if (ms < LATENCY_GREEN) return DEFAULT_PANEL_PALETTE.good;
388
- if (ms < LATENCY_YELLOW) return DEFAULT_PANEL_PALETTE.warn;
389
- return DEFAULT_PANEL_PALETTE.bad;
390
- }
391
- }