@tokentop/ttop 0.2.2
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.
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/bin/ttop.js +49 -0
- package/package.json +68 -0
- package/src/agents/aggregator.ts +176 -0
- package/src/agents/costing.ts +74 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/types.ts +71 -0
- package/src/cli.ts +167 -0
- package/src/config/schema.test.ts +166 -0
- package/src/config/schema.ts +165 -0
- package/src/demo/simulator.ts +745 -0
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +186 -0
- package/src/plugins/lifecycle.ts +177 -0
- package/src/plugins/loader.test.ts +195 -0
- package/src/plugins/loader.ts +282 -0
- package/src/plugins/notification-bus.ts +153 -0
- package/src/plugins/notifications/index.ts +2 -0
- package/src/plugins/notifications/terminal-bell.ts +66 -0
- package/src/plugins/notifications/visual-flash.ts +57 -0
- package/src/plugins/npm-installer.ts +164 -0
- package/src/plugins/plugin-context-factory.ts +67 -0
- package/src/plugins/plugin-host.ts +205 -0
- package/src/plugins/providers/anthropic.ts +325 -0
- package/src/plugins/providers/antigravity.ts +309 -0
- package/src/plugins/providers/codex.ts +250 -0
- package/src/plugins/providers/gemini.ts +391 -0
- package/src/plugins/providers/github-copilot.ts +194 -0
- package/src/plugins/providers/index.ts +12 -0
- package/src/plugins/providers/minimax.ts +223 -0
- package/src/plugins/providers/openai-api.ts +250 -0
- package/src/plugins/providers/opencode-zen.ts +125 -0
- package/src/plugins/providers/perplexity.ts +177 -0
- package/src/plugins/providers/zai.ts +225 -0
- package/src/plugins/registry.ts +251 -0
- package/src/plugins/sandbox-guard.test.ts +191 -0
- package/src/plugins/sandbox-guard.ts +155 -0
- package/src/plugins/sandbox.ts +138 -0
- package/src/plugins/themes/catppuccin-latte.ts +45 -0
- package/src/plugins/themes/catppuccin-mocha.ts +45 -0
- package/src/plugins/themes/claude-code.ts +45 -0
- package/src/plugins/themes/dracula.ts +45 -0
- package/src/plugins/themes/github-light.ts +45 -0
- package/src/plugins/themes/gruvbox-dark.ts +45 -0
- package/src/plugins/themes/gruvbox-light.ts +45 -0
- package/src/plugins/themes/index.ts +15 -0
- package/src/plugins/themes/kanagawa.ts +45 -0
- package/src/plugins/themes/nord.ts +45 -0
- package/src/plugins/themes/one-dark.ts +45 -0
- package/src/plugins/themes/opencode.ts +47 -0
- package/src/plugins/themes/rose-pine-dawn.ts +45 -0
- package/src/plugins/themes/rose-pine.ts +45 -0
- package/src/plugins/themes/solarized-light.ts +45 -0
- package/src/plugins/themes/tokyo-night.ts +49 -0
- package/src/plugins/types/agent.ts +108 -0
- package/src/plugins/types/base.ts +267 -0
- package/src/plugins/types/index.ts +19 -0
- package/src/plugins/types/notification.ts +51 -0
- package/src/plugins/types/provider.ts +233 -0
- package/src/plugins/types/theme.ts +70 -0
- package/src/plugins/update-checker.ts +177 -0
- package/src/pricing/estimator.test.ts +181 -0
- package/src/pricing/estimator.ts +80 -0
- package/src/pricing/fallback.test.ts +71 -0
- package/src/pricing/fallback.ts +62 -0
- package/src/pricing/index.ts +66 -0
- package/src/pricing/models-dev.ts +130 -0
- package/src/storage/database.ts +613 -0
- package/src/storage/db.ts +64 -0
- package/src/storage/index.ts +66 -0
- package/src/storage/migrations/index.ts +189 -0
- package/src/storage/paths.ts +24 -0
- package/src/storage/repos/agentSessions.ts +210 -0
- package/src/storage/repos/providerSnapshots.ts +116 -0
- package/src/storage/repos/usageEvents.ts +334 -0
- package/src/storage/types.ts +288 -0
- package/src/tui/App.tsx +381 -0
- package/src/tui/components/CommandPalette.tsx +166 -0
- package/src/tui/components/DebugConsole.tsx +161 -0
- package/src/tui/components/DebugPanel.tsx +660 -0
- package/src/tui/components/FooterHints.tsx +22 -0
- package/src/tui/components/GhostProviderCard.tsx +45 -0
- package/src/tui/components/Header.tsx +125 -0
- package/src/tui/components/HelpOverlay.tsx +45 -0
- package/src/tui/components/InlineGauge.tsx +34 -0
- package/src/tui/components/InlineSparkline.tsx +78 -0
- package/src/tui/components/KpiStrip.tsx +246 -0
- package/src/tui/components/LimitGauge.tsx +186 -0
- package/src/tui/components/ModalBackdrop.tsx +42 -0
- package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
- package/src/tui/components/ProviderCard.tsx +309 -0
- package/src/tui/components/ProviderDetailPanel.tsx +237 -0
- package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
- package/src/tui/components/ProvidersList.tsx +380 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
- package/src/tui/components/SessionsTable.tsx +401 -0
- package/src/tui/components/SettingsModal.tsx +587 -0
- package/src/tui/components/SidebarBreakdown.tsx +87 -0
- package/src/tui/components/Skeleton.tsx +102 -0
- package/src/tui/components/SmartSidebar.tsx +644 -0
- package/src/tui/components/Sparkline.tsx +180 -0
- package/src/tui/components/Spinner.tsx +36 -0
- package/src/tui/components/StatusBar.tsx +96 -0
- package/src/tui/components/ThemePicker.tsx +234 -0
- package/src/tui/components/Toast.tsx +61 -0
- package/src/tui/components/UsageGauge.tsx +80 -0
- package/src/tui/components/index.ts +5 -0
- package/src/tui/contexts/AgentSessionContext.tsx +307 -0
- package/src/tui/contexts/ConfigContext.tsx +141 -0
- package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
- package/src/tui/contexts/DemoModeContext.tsx +60 -0
- package/src/tui/contexts/DrawerContext.tsx +42 -0
- package/src/tui/contexts/InputContext.tsx +30 -0
- package/src/tui/contexts/LogContext.tsx +150 -0
- package/src/tui/contexts/PluginContext.tsx +424 -0
- package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
- package/src/tui/contexts/StorageContext.tsx +233 -0
- package/src/tui/contexts/ThemeContext.tsx +95 -0
- package/src/tui/contexts/TimeWindowContext.tsx +158 -0
- package/src/tui/contexts/ToastContext.tsx +40 -0
- package/src/tui/contexts/index.ts +5 -0
- package/src/tui/createApp.tsx +47 -0
- package/src/tui/debug/captureFrame.ts +262 -0
- package/src/tui/debug/snapshot.tsx +644 -0
- package/src/tui/driver/assertions.ts +279 -0
- package/src/tui/driver/cli.ts +717 -0
- package/src/tui/driver/coverage.ts +204 -0
- package/src/tui/driver/demo-snapshot.ts +66 -0
- package/src/tui/driver/diff.ts +166 -0
- package/src/tui/driver/driver.ts +270 -0
- package/src/tui/driver/index.ts +53 -0
- package/src/tui/driver/recorder.ts +303 -0
- package/src/tui/driver/test-workflow.ts +115 -0
- package/src/tui/driver/test.ts +47 -0
- package/src/tui/hooks/index.ts +20 -0
- package/src/tui/hooks/useAnimatedValue.ts +113 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
- package/src/tui/hooks/useDashboardState.ts +116 -0
- package/src/tui/hooks/useEmaActivity.ts +223 -0
- package/src/tui/hooks/useEntranceAnimation.ts +62 -0
- package/src/tui/hooks/useExitAnimation.ts +137 -0
- package/src/tui/hooks/usePulse.ts +138 -0
- package/src/tui/hooks/useSafeRenderer.ts +24 -0
- package/src/tui/hooks/useValueFlash.ts +111 -0
- package/src/tui/index.tsx +50 -0
- package/src/tui/utils/providerColor.ts +32 -0
- package/src/tui/views/Dashboard.tsx +340 -0
- package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
- package/src/tui/views/ProjectsView.tsx +1277 -0
- package/src/tui/views/RealTimeDashboard.tsx +393 -0
- package/src/tui/views/SettingsView.tsx +465 -0
- package/src/tui/views/index.ts +2 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/opentui-test-utils.d.ts +18 -0
- package/src/utils/clipboard.ts +118 -0
- package/src/utils/currency.test.ts +106 -0
- package/src/utils/currency.ts +79 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useTerminalDimensions } from '@opentui/react';
|
|
3
|
+
import { useColors } from '../contexts/ThemeContext.tsx';
|
|
4
|
+
import { useConfig } from '../contexts/ConfigContext.tsx';
|
|
5
|
+
import { useTimeWindow } from '../contexts/TimeWindowContext.tsx';
|
|
6
|
+
import { usePulse } from '../hooks/usePulse.ts';
|
|
7
|
+
import type { AgentSessionAggregate, AgentSessionStream } from '../../agents/types.ts';
|
|
8
|
+
|
|
9
|
+
export type DriverDimension = 'model' | 'project' | 'agent';
|
|
10
|
+
|
|
11
|
+
export type SidebarMode = 'full' | 'compact' | 'minimal' | 'micro' | 'hidden';
|
|
12
|
+
|
|
13
|
+
export interface DriverStats {
|
|
14
|
+
id: string;
|
|
15
|
+
providerId?: string;
|
|
16
|
+
displayName: string;
|
|
17
|
+
cost: number;
|
|
18
|
+
costShare: number;
|
|
19
|
+
tokens: number;
|
|
20
|
+
delta5m: number;
|
|
21
|
+
isHot: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SmartSidebarProps {
|
|
25
|
+
sessions: AgentSessionAggregate[];
|
|
26
|
+
budgetCost: number;
|
|
27
|
+
focusedPanel: 'sessions' | 'sidebar' | 'limits';
|
|
28
|
+
dimension: DriverDimension;
|
|
29
|
+
selectedDriverIndex: number;
|
|
30
|
+
activeDriverFilter: string | null;
|
|
31
|
+
getProviderColor: (id: string) => string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function interpolatePulseColor(intensity: number, baseColor: string, dimColor: string): string {
|
|
35
|
+
const dimAmount = 0.4;
|
|
36
|
+
const t = intensity * dimAmount;
|
|
37
|
+
|
|
38
|
+
const parseHex = (hex: string) => {
|
|
39
|
+
const h = hex.replace('#', '');
|
|
40
|
+
return {
|
|
41
|
+
r: parseInt(h.substring(0, 2), 16),
|
|
42
|
+
g: parseInt(h.substring(2, 4), 16),
|
|
43
|
+
b: parseInt(h.substring(4, 6), 16),
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const base = parseHex(baseColor);
|
|
48
|
+
const dim = parseHex(dimColor);
|
|
49
|
+
|
|
50
|
+
const r = Math.round(base.r + (dim.r - base.r) * t);
|
|
51
|
+
const g = Math.round(base.g + (dim.g - base.g) * t);
|
|
52
|
+
const b = Math.round(base.b + (dim.b - base.b) * t);
|
|
53
|
+
|
|
54
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatCurrency(val: number): string {
|
|
58
|
+
if (val >= 1000) return `$${(val / 1000).toFixed(1)}k`;
|
|
59
|
+
if (val >= 100) return `$${Math.round(val)}`;
|
|
60
|
+
if (val >= 10) return `$${val.toFixed(1)}`;
|
|
61
|
+
return `$${val.toFixed(2)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Always shows 2 decimal places for budget displays where precision matters */
|
|
65
|
+
function formatBudget(val: number): string {
|
|
66
|
+
if (val >= 1000) return `$${(val / 1000).toFixed(2)}k`;
|
|
67
|
+
return `$${val.toFixed(2)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatDelta(val: number): string {
|
|
71
|
+
if (val < 0.01) return '';
|
|
72
|
+
if (val >= 10) return `+$${val.toFixed(1)}`;
|
|
73
|
+
return `+$${val.toFixed(2)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatPercent(val: number): string {
|
|
77
|
+
if (val < 1) return '<1%';
|
|
78
|
+
return `${Math.round(val)}%`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function truncateWithEllipsis(str: string, maxLen: number): string {
|
|
82
|
+
if (str.length <= maxLen) return str;
|
|
83
|
+
return str.slice(0, maxLen - 1) + '…';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getSidebarMode(width: number): SidebarMode {
|
|
87
|
+
if (width >= 140) return 'full';
|
|
88
|
+
if (width >= 105) return 'compact';
|
|
89
|
+
if (width >= 95) return 'minimal';
|
|
90
|
+
if (width >= 85) return 'micro';
|
|
91
|
+
return 'hidden';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getSidebarWidth(mode: SidebarMode, terminalWidth: number): number {
|
|
95
|
+
if (mode === 'micro') return 28;
|
|
96
|
+
if (mode === 'minimal') return 32;
|
|
97
|
+
if (mode === 'compact') return 36;
|
|
98
|
+
if (terminalWidth >= 160) return 44;
|
|
99
|
+
if (terminalWidth >= 140) return 40;
|
|
100
|
+
return 38;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractRepoName(projectPath: string | undefined | null): string {
|
|
104
|
+
if (!projectPath) return 'unknown';
|
|
105
|
+
const normalized = projectPath.replace(/\\/g, '/');
|
|
106
|
+
return normalized.split('/').filter(Boolean).pop() ?? 'unknown';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function makeProgressBar(percent: number, width: number, filledChar = '█', emptyChar = '·'): string {
|
|
110
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
111
|
+
const filledWidth = Math.round((clamped / 100) * width);
|
|
112
|
+
return filledChar.repeat(filledWidth) + emptyChar.repeat(width - filledWidth);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeDivider(width: number, char = '─'): string {
|
|
116
|
+
return char.repeat(Math.max(0, width - 2));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface BudgetSectionProps {
|
|
120
|
+
totalCost: number;
|
|
121
|
+
budgetCost: number;
|
|
122
|
+
mode: SidebarMode;
|
|
123
|
+
sidebarWidth: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function BudgetSection({ totalCost, budgetCost, mode, sidebarWidth }: BudgetSectionProps) {
|
|
127
|
+
const colors = useColors();
|
|
128
|
+
const { config } = useConfig();
|
|
129
|
+
const { budgetType, budgetTypeLabel, budgetLock } = useTimeWindow();
|
|
130
|
+
|
|
131
|
+
const activeBudget = budgetType === 'daily' ? config.budgets.daily
|
|
132
|
+
: budgetType === 'weekly' ? config.budgets.weekly
|
|
133
|
+
: budgetType === 'monthly' ? config.budgets.monthly
|
|
134
|
+
: null;
|
|
135
|
+
|
|
136
|
+
const hasBudgetLimit = activeBudget !== null && budgetType !== 'none';
|
|
137
|
+
const budgetUsedPercent = hasBudgetLimit ? (budgetCost / activeBudget) * 100 : 0;
|
|
138
|
+
|
|
139
|
+
const isCritical = hasBudgetLimit && budgetUsedPercent >= config.alerts.criticalPercent;
|
|
140
|
+
const isWarning = hasBudgetLimit && budgetUsedPercent >= config.alerts.warningPercent && !isCritical;
|
|
141
|
+
|
|
142
|
+
const criticalPulseStep = usePulse({ enabled: isCritical, intervalMs: 80 });
|
|
143
|
+
const warningPulseStep = usePulse({ enabled: isWarning, intervalMs: 200 });
|
|
144
|
+
|
|
145
|
+
const pulseIntensity = isCritical
|
|
146
|
+
? Math.abs(Math.sin(criticalPulseStep * 0.3))
|
|
147
|
+
: isWarning
|
|
148
|
+
? Math.abs(Math.sin(warningPulseStep * 0.25))
|
|
149
|
+
: 0;
|
|
150
|
+
|
|
151
|
+
const getStatusColor = () => {
|
|
152
|
+
if (isCritical) {
|
|
153
|
+
return interpolatePulseColor(pulseIntensity, colors.error, colors.background);
|
|
154
|
+
}
|
|
155
|
+
if (isWarning) {
|
|
156
|
+
return interpolatePulseColor(pulseIntensity, colors.warning, colors.background);
|
|
157
|
+
}
|
|
158
|
+
return colors.success;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const statusColor = hasBudgetLimit ? getStatusColor() : colors.text;
|
|
162
|
+
const innerWidth = sidebarWidth - 4;
|
|
163
|
+
const barWidth = Math.max(10, innerWidth - 6);
|
|
164
|
+
const bar = makeProgressBar(budgetUsedPercent, barWidth);
|
|
165
|
+
|
|
166
|
+
const isLocked = budgetLock !== 'sync';
|
|
167
|
+
const lockSuffix = isLocked ? ' [locked]' : '';
|
|
168
|
+
const headerLabel = budgetType === 'none' ? 'TOTAL COST' : `${budgetTypeLabel.toUpperCase()} BUDGET${lockSuffix}`;
|
|
169
|
+
|
|
170
|
+
if (budgetType === 'none') {
|
|
171
|
+
if (mode === 'micro') {
|
|
172
|
+
return (
|
|
173
|
+
<box flexDirection="row" height={1} paddingX={1}>
|
|
174
|
+
<text fg={colors.textMuted} height={1}>Total: </text>
|
|
175
|
+
<text fg={colors.text} height={1}><strong>{formatCurrency(totalCost)}</strong></text>
|
|
176
|
+
</box>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<box flexDirection="column" paddingX={1}>
|
|
182
|
+
<text fg={colors.textMuted} height={1}><strong>{headerLabel}</strong></text>
|
|
183
|
+
<text fg={colors.text} height={1}><strong>{formatCurrency(totalCost)}</strong></text>
|
|
184
|
+
</box>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!activeBudget) {
|
|
189
|
+
if (mode === 'micro') {
|
|
190
|
+
return (
|
|
191
|
+
<box flexDirection="row" height={1} paddingX={1}>
|
|
192
|
+
<text fg={colors.textMuted} height={1}>No {budgetTypeLabel.toLowerCase()} budget</text>
|
|
193
|
+
</box>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<box flexDirection="column" paddingX={1}>
|
|
199
|
+
<text fg={colors.textMuted} height={1}><strong>{headerLabel}</strong></text>
|
|
200
|
+
<text fg={colors.textSubtle} height={1}>Not set</text>
|
|
201
|
+
<text fg={colors.textSubtle} height={1}>(, to configure)</text>
|
|
202
|
+
</box>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (mode === 'micro') {
|
|
207
|
+
return (
|
|
208
|
+
<box flexDirection="column" paddingX={1}>
|
|
209
|
+
<box flexDirection="row" height={1}>
|
|
210
|
+
<text fg={colors.textMuted} height={1}>{budgetTypeLabel} </text>
|
|
211
|
+
<text fg={statusColor} height={1}><strong>{formatPercent(budgetUsedPercent)}</strong></text>
|
|
212
|
+
<text fg={colors.textMuted} height={1}> {formatBudget(budgetCost)}/{formatBudget(activeBudget)}</text>
|
|
213
|
+
</box>
|
|
214
|
+
</box>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<box flexDirection="column" paddingX={1}>
|
|
220
|
+
<box flexDirection="row" height={1}>
|
|
221
|
+
<text fg={colors.textMuted}><strong>{headerLabel}</strong></text>
|
|
222
|
+
<text flexGrow={1}></text>
|
|
223
|
+
<text fg={statusColor}><strong>{formatPercent(budgetUsedPercent)}</strong></text>
|
|
224
|
+
</box>
|
|
225
|
+
|
|
226
|
+
<box flexDirection="row" height={1}>
|
|
227
|
+
<text fg={statusColor}>{bar}</text>
|
|
228
|
+
</box>
|
|
229
|
+
|
|
230
|
+
<box flexDirection="row" height={1}>
|
|
231
|
+
<text fg={colors.text}><strong>{formatBudget(budgetCost)}</strong></text>
|
|
232
|
+
<text fg={colors.textMuted}>/{formatBudget(activeBudget)}</text>
|
|
233
|
+
</box>
|
|
234
|
+
</box>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface TopDriversSectionProps {
|
|
239
|
+
drivers: DriverStats[];
|
|
240
|
+
dimension: DriverDimension;
|
|
241
|
+
selectedIndex: number;
|
|
242
|
+
activeFilter: string | null;
|
|
243
|
+
isFocused: boolean;
|
|
244
|
+
mode: SidebarMode;
|
|
245
|
+
sidebarWidth: number;
|
|
246
|
+
maxDrivers: number;
|
|
247
|
+
getProviderColor: (id: string) => string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const DIMENSION_LABELS: Record<DriverDimension, string> = {
|
|
251
|
+
model: 'MODELS',
|
|
252
|
+
project: 'PROJECTS',
|
|
253
|
+
agent: 'AGENTS',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
function TopDriversSection({
|
|
257
|
+
drivers,
|
|
258
|
+
dimension,
|
|
259
|
+
selectedIndex,
|
|
260
|
+
activeFilter,
|
|
261
|
+
isFocused,
|
|
262
|
+
mode,
|
|
263
|
+
sidebarWidth,
|
|
264
|
+
maxDrivers,
|
|
265
|
+
getProviderColor,
|
|
266
|
+
}: TopDriversSectionProps) {
|
|
267
|
+
const colors = useColors();
|
|
268
|
+
|
|
269
|
+
const displayDrivers = drivers.slice(0, maxDrivers);
|
|
270
|
+
const innerWidth = sidebarWidth - 4;
|
|
271
|
+
|
|
272
|
+
const showBars = mode === 'full' || mode === 'compact';
|
|
273
|
+
const showDelta = mode === 'full';
|
|
274
|
+
const showPercent = mode !== 'minimal';
|
|
275
|
+
|
|
276
|
+
const prefixWidth = 2;
|
|
277
|
+
const nameWidth = mode === 'full' ? 14 : mode === 'compact' ? 12 : 10;
|
|
278
|
+
const deltaWidth = showDelta ? 7 : 0;
|
|
279
|
+
const percentWidth = showPercent ? 5 : 0;
|
|
280
|
+
const barWidth = showBars ? Math.max(8, innerWidth - prefixWidth - nameWidth - deltaWidth - percentWidth - 2) : 0;
|
|
281
|
+
|
|
282
|
+
const dimensionToggle = ['m', 'p', 'a'].map(k => {
|
|
283
|
+
const isActive = (k === 'm' && dimension === 'model') ||
|
|
284
|
+
(k === 'p' && dimension === 'project') ||
|
|
285
|
+
(k === 'a' && dimension === 'agent');
|
|
286
|
+
return isActive ? k.toUpperCase() : k;
|
|
287
|
+
}).join('/');
|
|
288
|
+
|
|
289
|
+
return (
|
|
290
|
+
<box flexDirection="column" paddingX={1}>
|
|
291
|
+
<box flexDirection="row" height={1}>
|
|
292
|
+
<text fg={colors.textMuted}><strong>TOP {DIMENSION_LABELS[dimension]}</strong></text>
|
|
293
|
+
<text flexGrow={1}></text>
|
|
294
|
+
<text fg={colors.textSubtle}>[{dimensionToggle}]</text>
|
|
295
|
+
</box>
|
|
296
|
+
|
|
297
|
+
{displayDrivers.length === 0 ? (
|
|
298
|
+
<text fg={colors.textSubtle} height={1}>No data</text>
|
|
299
|
+
) : (
|
|
300
|
+
displayDrivers.map((driver, idx) => {
|
|
301
|
+
const isSelected = isFocused && idx === selectedIndex;
|
|
302
|
+
const isFiltered = activeFilter === driver.id;
|
|
303
|
+
|
|
304
|
+
const hotPrefix = driver.isHot ? '▲' : ' ';
|
|
305
|
+
const selectPrefix = isSelected ? '▸' : isFiltered ? '●' : ' ';
|
|
306
|
+
const displayName = truncateWithEllipsis(driver.displayName, nameWidth);
|
|
307
|
+
const paddedName = displayName.padEnd(nameWidth);
|
|
308
|
+
const delta = showDelta ? formatDelta(driver.delta5m).padStart(deltaWidth) : '';
|
|
309
|
+
const percent = showPercent ? formatPercent(driver.costShare).padStart(percentWidth) : '';
|
|
310
|
+
const bar = showBars ? makeProgressBar(driver.costShare, barWidth) : '';
|
|
311
|
+
|
|
312
|
+
const textColor = isSelected ? colors.text : colors.textSubtle;
|
|
313
|
+
const nameColor = isSelected ? colors.primary : getProviderColor(driver.providerId ?? driver.id);
|
|
314
|
+
|
|
315
|
+
if (showBars) {
|
|
316
|
+
return (
|
|
317
|
+
<box key={driver.id} flexDirection="column" height={2}>
|
|
318
|
+
<box flexDirection="row" height={1}>
|
|
319
|
+
<text fg={driver.isHot ? colors.warning : colors.textSubtle}>{hotPrefix}</text>
|
|
320
|
+
<text fg={textColor}>{selectPrefix}</text>
|
|
321
|
+
<text fg={nameColor}>{paddedName}</text>
|
|
322
|
+
{showDelta && <text fg={colors.success}>{delta}</text>}
|
|
323
|
+
{showPercent && <text fg={colors.textMuted}>{percent}</text>}
|
|
324
|
+
</box>
|
|
325
|
+
<box flexDirection="row" height={1}>
|
|
326
|
+
<text fg={isSelected ? colors.primary : colors.textSubtle}> {bar}</text>
|
|
327
|
+
</box>
|
|
328
|
+
</box>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<box key={driver.id} flexDirection="row" height={1}>
|
|
334
|
+
<text fg={driver.isHot ? colors.warning : colors.textSubtle}>{hotPrefix}</text>
|
|
335
|
+
<text fg={textColor}>{selectPrefix}</text>
|
|
336
|
+
<text fg={nameColor}>{paddedName}</text>
|
|
337
|
+
<text fg={colors.text}> {formatCurrency(driver.cost)}</text>
|
|
338
|
+
</box>
|
|
339
|
+
);
|
|
340
|
+
})
|
|
341
|
+
)}
|
|
342
|
+
|
|
343
|
+
{isFocused && displayDrivers.length > 0 && (
|
|
344
|
+
<text fg={colors.textSubtle} height={1}>↑↓ select Enter filter</text>
|
|
345
|
+
)}
|
|
346
|
+
</box>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface DividerProps {
|
|
351
|
+
width: number;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function Divider({ width }: DividerProps) {
|
|
355
|
+
const colors = useColors();
|
|
356
|
+
return (
|
|
357
|
+
<box paddingX={1} height={1}>
|
|
358
|
+
<text fg={colors.border} height={1}>{makeDivider(width)}</text>
|
|
359
|
+
</box>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
interface EfficiencyInsight {
|
|
364
|
+
type: 'cache' | 'verbosity' | 'chatty' | 'fallback';
|
|
365
|
+
primaryLabel: string;
|
|
366
|
+
primaryValue: string;
|
|
367
|
+
actionLine: string;
|
|
368
|
+
secondaryLabel?: string;
|
|
369
|
+
isWarning: boolean;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
interface EfficiencySectionProps {
|
|
373
|
+
sessions: AgentSessionAggregate[];
|
|
374
|
+
mode: SidebarMode;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function EfficiencySection({ sessions, mode }: EfficiencySectionProps) {
|
|
378
|
+
const colors = useColors();
|
|
379
|
+
|
|
380
|
+
const metrics = useMemo(() => {
|
|
381
|
+
let totalInput = 0, totalOutput = 0, totalCacheRead = 0;
|
|
382
|
+
let totalCost = 0, totalRequests = 0;
|
|
383
|
+
let hasCacheData = false;
|
|
384
|
+
|
|
385
|
+
sessions.forEach(s => {
|
|
386
|
+
s.streams.forEach((st: AgentSessionStream) => {
|
|
387
|
+
totalInput += st.tokens.input;
|
|
388
|
+
totalOutput += st.tokens.output;
|
|
389
|
+
totalCacheRead += st.tokens.cacheRead ?? 0;
|
|
390
|
+
totalCost += st.costUsd ?? 0;
|
|
391
|
+
totalRequests += st.requestCount;
|
|
392
|
+
if (st.tokens.cacheRead != null && st.tokens.cacheRead > 0) hasCacheData = true;
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const cacheHitRate = (totalInput + totalCacheRead) > 0
|
|
397
|
+
? (totalCacheRead / (totalInput + totalCacheRead)) * 100 : 0;
|
|
398
|
+
const outputShare = (totalInput + totalOutput) > 0
|
|
399
|
+
? (totalOutput / (totalInput + totalOutput)) * 100 : 0;
|
|
400
|
+
const costPerReq = totalRequests > 0 ? totalCost / totalRequests : 0;
|
|
401
|
+
|
|
402
|
+
return { totalInput, totalOutput, totalCacheRead, totalCost, totalRequests, hasCacheData, cacheHitRate, outputShare, costPerReq };
|
|
403
|
+
}, [sessions]);
|
|
404
|
+
|
|
405
|
+
const insight: EfficiencyInsight = useMemo(() => {
|
|
406
|
+
// Priority 1: Cache Leverage
|
|
407
|
+
if (metrics.hasCacheData && metrics.totalInput > 1000 && metrics.cacheHitRate < 80) {
|
|
408
|
+
return {
|
|
409
|
+
type: 'cache',
|
|
410
|
+
primaryLabel: 'Cache hit:',
|
|
411
|
+
primaryValue: `${Math.round(metrics.cacheHitRate)}%`,
|
|
412
|
+
actionLine: 'Keep sessions open; avoid resets',
|
|
413
|
+
secondaryLabel: `$/req: ${formatCurrency(metrics.costPerReq)} Out: ${Math.round(metrics.outputShare)}%`,
|
|
414
|
+
isWarning: metrics.cacheHitRate < 20,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
// Priority 2: Output Verbosity
|
|
418
|
+
if (metrics.totalInput + metrics.totalOutput > 1000 && metrics.outputShare > 65) {
|
|
419
|
+
return {
|
|
420
|
+
type: 'verbosity',
|
|
421
|
+
primaryLabel: 'Output share:',
|
|
422
|
+
primaryValue: `${Math.round(metrics.outputShare)}%`,
|
|
423
|
+
actionLine: 'Ask for diffs; less commentary',
|
|
424
|
+
secondaryLabel: `$/req: ${formatCurrency(metrics.costPerReq)}`,
|
|
425
|
+
isWarning: metrics.outputShare > 75,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
// Priority 3: Chatty Usage
|
|
429
|
+
if (metrics.totalRequests > 10 && metrics.costPerReq > 0.15) {
|
|
430
|
+
return {
|
|
431
|
+
type: 'chatty',
|
|
432
|
+
primaryLabel: '$/req:',
|
|
433
|
+
primaryValue: formatCurrency(metrics.costPerReq),
|
|
434
|
+
actionLine: 'Batch asks; fewer back-and-forth',
|
|
435
|
+
secondaryLabel: `${metrics.totalRequests} reqs`,
|
|
436
|
+
isWarning: metrics.costPerReq > 0.25,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
// Priority 4: Fallback
|
|
440
|
+
return {
|
|
441
|
+
type: 'fallback',
|
|
442
|
+
primaryLabel: `$/req: ${formatCurrency(metrics.costPerReq)}`,
|
|
443
|
+
primaryValue: `Out: ${Math.round(metrics.outputShare)}%`,
|
|
444
|
+
actionLine: 'Prefer patches; reduce re-asking',
|
|
445
|
+
isWarning: false,
|
|
446
|
+
};
|
|
447
|
+
}, [metrics]);
|
|
448
|
+
|
|
449
|
+
const isFull = mode === 'full';
|
|
450
|
+
const metricColor = insight.isWarning ? colors.warning : colors.text;
|
|
451
|
+
|
|
452
|
+
if (insight.type === 'fallback') {
|
|
453
|
+
const cacheLabel = metrics.hasCacheData
|
|
454
|
+
? `Cache: ${Math.round(metrics.cacheHitRate)}%`
|
|
455
|
+
: 'Cache: n/a';
|
|
456
|
+
|
|
457
|
+
return (
|
|
458
|
+
<box flexDirection="column" paddingX={1}>
|
|
459
|
+
<text fg={colors.textMuted} height={1}><strong>EFFICIENCY</strong></text>
|
|
460
|
+
<box flexDirection="row" height={1}>
|
|
461
|
+
<text fg={colors.text} height={1}>{insight.primaryLabel} {insight.primaryValue}</text>
|
|
462
|
+
</box>
|
|
463
|
+
<text fg={colors.textSubtle} height={1}>{'-> '}{insight.actionLine}</text>
|
|
464
|
+
{isFull && (
|
|
465
|
+
<text fg={colors.textMuted} height={1}>Reqs: {metrics.totalRequests} {cacheLabel}</text>
|
|
466
|
+
)}
|
|
467
|
+
</box>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<box flexDirection="column" paddingX={1}>
|
|
473
|
+
<text fg={colors.textMuted} height={1}><strong>EFFICIENCY</strong></text>
|
|
474
|
+
<box flexDirection="row" height={1}>
|
|
475
|
+
<text fg={colors.textMuted} height={1}>{insight.primaryLabel} </text>
|
|
476
|
+
<text fg={metricColor} height={1}>{insight.primaryValue}</text>
|
|
477
|
+
</box>
|
|
478
|
+
<text fg={colors.textSubtle} height={1}>{'-> '}{insight.actionLine}</text>
|
|
479
|
+
{isFull && insight.secondaryLabel && (
|
|
480
|
+
<text fg={colors.textMuted} height={1}>{insight.secondaryLabel}</text>
|
|
481
|
+
)}
|
|
482
|
+
</box>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function SmartSidebar({
|
|
487
|
+
sessions,
|
|
488
|
+
budgetCost,
|
|
489
|
+
focusedPanel,
|
|
490
|
+
dimension,
|
|
491
|
+
selectedDriverIndex,
|
|
492
|
+
activeDriverFilter,
|
|
493
|
+
getProviderColor,
|
|
494
|
+
}: SmartSidebarProps) {
|
|
495
|
+
const colors = useColors();
|
|
496
|
+
const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
|
|
497
|
+
|
|
498
|
+
const mode = getSidebarMode(terminalWidth);
|
|
499
|
+
const sidebarWidth = getSidebarWidth(mode, terminalWidth);
|
|
500
|
+
const isFocused = focusedPanel === 'sidebar';
|
|
501
|
+
|
|
502
|
+
const showBars = mode === 'full' || mode === 'compact';
|
|
503
|
+
const driverRowHeight = showBars ? 2 : 1;
|
|
504
|
+
|
|
505
|
+
const sidebarInnerHeight = terminalHeight - 24;
|
|
506
|
+
const budgetRows = 3;
|
|
507
|
+
const dividerRows = 1;
|
|
508
|
+
const driversHeaderRow = 1;
|
|
509
|
+
const efficiencyRows = 4;
|
|
510
|
+
|
|
511
|
+
const minHeightForEfficiency = budgetRows + dividerRows + driversHeaderRow + (2 * driverRowHeight) + dividerRows + efficiencyRows;
|
|
512
|
+
const showEfficiency = sidebarInnerHeight >= minHeightForEfficiency;
|
|
513
|
+
|
|
514
|
+
const reservedRows = budgetRows + dividerRows + driversHeaderRow + (showEfficiency ? dividerRows + efficiencyRows : 0);
|
|
515
|
+
const availableForDrivers = Math.max(0, sidebarInnerHeight - reservedRows);
|
|
516
|
+
const maxDrivers = Math.max(1, Math.min(10, Math.floor(availableForDrivers / driverRowHeight)));
|
|
517
|
+
|
|
518
|
+
const totalCost = useMemo(() =>
|
|
519
|
+
sessions.reduce((sum, s) => sum + (s.totalCostUsd ?? 0), 0),
|
|
520
|
+
[sessions]
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const drivers = useMemo((): DriverStats[] => {
|
|
524
|
+
const stats: Record<string, { cost: number; tokens: number; delta5m: number }> = {};
|
|
525
|
+
const providerForKey: Record<string, string> = {};
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
const fiveMinAgo = now - 5 * 60 * 1000;
|
|
528
|
+
|
|
529
|
+
sessions.forEach(s => {
|
|
530
|
+
s.streams.forEach((st: AgentSessionStream) => {
|
|
531
|
+
let key: string;
|
|
532
|
+
switch (dimension) {
|
|
533
|
+
case 'model':
|
|
534
|
+
key = st.modelId;
|
|
535
|
+
if (!providerForKey[key]) providerForKey[key] = st.providerId;
|
|
536
|
+
break;
|
|
537
|
+
case 'project':
|
|
538
|
+
key = extractRepoName(s.projectPath);
|
|
539
|
+
break;
|
|
540
|
+
case 'agent':
|
|
541
|
+
key = s.agentName;
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!stats[key]) {
|
|
546
|
+
stats[key] = { cost: 0, tokens: 0, delta5m: 0 };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const cost = st.costUsd ?? 0;
|
|
550
|
+
const entry = stats[key]!;
|
|
551
|
+
entry.cost += cost;
|
|
552
|
+
entry.tokens += st.tokens.input + st.tokens.output;
|
|
553
|
+
|
|
554
|
+
if (s.lastActivityAt >= fiveMinAgo) {
|
|
555
|
+
const sessionDuration = Math.max(1, s.lastActivityAt - s.startedAt);
|
|
556
|
+
const recentWindow = Math.min(5 * 60 * 1000, now - fiveMinAgo);
|
|
557
|
+
const recentRatio = recentWindow / sessionDuration;
|
|
558
|
+
entry.delta5m += cost * Math.min(recentRatio, 1) * 0.5;
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
return Object.entries(stats)
|
|
564
|
+
.map(([id, data]): DriverStats => {
|
|
565
|
+
const displayName = dimension === 'model'
|
|
566
|
+
? (id.split('/').pop() ?? id)
|
|
567
|
+
: id;
|
|
568
|
+
return {
|
|
569
|
+
id,
|
|
570
|
+
...(providerForKey[id] != null ? { providerId: providerForKey[id] } : {}),
|
|
571
|
+
displayName,
|
|
572
|
+
cost: data.cost,
|
|
573
|
+
costShare: totalCost > 0 ? (data.cost / totalCost) * 100 : 0,
|
|
574
|
+
tokens: data.tokens,
|
|
575
|
+
delta5m: data.delta5m,
|
|
576
|
+
isHot: data.delta5m > 0.10,
|
|
577
|
+
};
|
|
578
|
+
})
|
|
579
|
+
.sort((a, b) => b.cost - a.cost);
|
|
580
|
+
}, [sessions, dimension, totalCost]);
|
|
581
|
+
|
|
582
|
+
if (mode === 'hidden') {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return (
|
|
587
|
+
<box
|
|
588
|
+
flexDirection="column"
|
|
589
|
+
width={sidebarWidth}
|
|
590
|
+
border
|
|
591
|
+
borderStyle={isFocused ? 'double' : 'single'}
|
|
592
|
+
borderColor={isFocused ? colors.primary : colors.border}
|
|
593
|
+
overflow="hidden"
|
|
594
|
+
>
|
|
595
|
+
<box flexDirection="column" flexGrow={1}>
|
|
596
|
+
<BudgetSection
|
|
597
|
+
totalCost={totalCost}
|
|
598
|
+
budgetCost={budgetCost}
|
|
599
|
+
mode={mode}
|
|
600
|
+
sidebarWidth={sidebarWidth}
|
|
601
|
+
/>
|
|
602
|
+
|
|
603
|
+
{mode !== 'micro' && <Divider width={sidebarWidth} />}
|
|
604
|
+
|
|
605
|
+
{mode !== 'micro' && (
|
|
606
|
+
<TopDriversSection
|
|
607
|
+
drivers={drivers}
|
|
608
|
+
dimension={dimension}
|
|
609
|
+
selectedIndex={selectedDriverIndex}
|
|
610
|
+
activeFilter={activeDriverFilter}
|
|
611
|
+
isFocused={isFocused}
|
|
612
|
+
mode={mode}
|
|
613
|
+
sidebarWidth={sidebarWidth}
|
|
614
|
+
maxDrivers={maxDrivers}
|
|
615
|
+
getProviderColor={getProviderColor}
|
|
616
|
+
/>
|
|
617
|
+
)}
|
|
618
|
+
|
|
619
|
+
{mode === 'micro' && drivers[0] && (
|
|
620
|
+
<box flexDirection="row" paddingX={1} height={1}>
|
|
621
|
+
<text fg={colors.textMuted} height={1}>Top: </text>
|
|
622
|
+
<text fg={getProviderColor(drivers[0].providerId ?? drivers[0].id)} height={1}>
|
|
623
|
+
{truncateWithEllipsis(drivers[0].displayName, 10)}
|
|
624
|
+
</text>
|
|
625
|
+
<text fg={colors.text} height={1}> {formatCurrency(drivers[0].cost)}</text>
|
|
626
|
+
{drivers[0].isHot && <text fg={colors.warning} height={1}>▲</text>}
|
|
627
|
+
</box>
|
|
628
|
+
)}
|
|
629
|
+
</box>
|
|
630
|
+
|
|
631
|
+
{showEfficiency && mode !== 'micro' && (
|
|
632
|
+
<box flexDirection="column">
|
|
633
|
+
<Divider width={sidebarWidth} />
|
|
634
|
+
<EfficiencySection
|
|
635
|
+
sessions={sessions}
|
|
636
|
+
mode={mode}
|
|
637
|
+
/>
|
|
638
|
+
</box>
|
|
639
|
+
)}
|
|
640
|
+
</box>
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export { getSidebarMode };
|