@tokentop/ttop 0.5.0 → 0.6.1
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/README.md +38 -15
- package/package.json +8 -6
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +40 -5
- package/src/cli.ts +14 -0
- package/src/config/schema.ts +1 -1
- package/src/demo/simulator.ts +1 -1
- package/src/plugins/agents/index.ts +1 -0
- package/src/plugins/lifecycle.ts +1 -1
- package/src/plugins/notification-bus.ts +27 -0
- package/src/plugins/notifications/terminal-bell.ts +80 -16
- package/src/plugins/notifications/visual-flash.ts +5 -18
- package/src/plugins/plugin-host.test.ts +197 -0
- package/src/plugins/plugin-host.ts +45 -1
- package/src/plugins/providers/anthropic.test.ts +202 -0
- package/src/plugins/providers/anthropic.ts +19 -0
- package/src/plugins/providers/antigravity.ts +16 -0
- package/src/plugins/providers/chutes.ts +16 -0
- package/src/plugins/providers/codex.ts +16 -0
- package/src/plugins/providers/gemini.ts +16 -0
- package/src/plugins/providers/github-copilot.ts +16 -0
- package/src/plugins/providers/minimax.ts +16 -0
- package/src/plugins/providers/openai-api.ts +52 -0
- package/src/plugins/providers/perplexity.ts +10 -1
- package/src/plugins/providers/zai.ts +16 -0
- package/src/pricing/fallback.ts +4 -0
- package/src/tui/App.tsx +22 -8
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/NotificationFlash.tsx +141 -0
- package/src/tui/components/ProviderCard.tsx +38 -22
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionsTable.tsx +54 -36
- package/src/tui/components/SettingsModal.tsx +20 -4
- package/src/tui/components/Skeleton.tsx +9 -6
- package/src/tui/components/SortOverlay.tsx +167 -0
- package/src/tui/components/Spinner.tsx +16 -11
- package/src/tui/components/Toast.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +69 -16
- package/src/tui/contexts/PluginContext.tsx +72 -10
- package/src/tui/contexts/ThemeContext.tsx +4 -1
- package/src/tui/createApp.tsx +2 -0
- package/src/tui/hooks/index.ts +1 -0
- package/src/tui/hooks/useAnimatedValue.ts +29 -26
- package/src/tui/hooks/useAnimationTick.ts +93 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
- package/src/tui/hooks/useEntranceAnimation.ts +16 -10
- package/src/tui/hooks/useNotificationBridge.ts +29 -0
- package/src/tui/hooks/usePulse.ts +13 -7
- package/src/tui/hooks/useValueFlash.ts +25 -33
- package/src/tui/index.tsx +4 -0
- package/src/tui/types/projectSort.ts +35 -0
- package/src/tui/types/sort.ts +61 -0
- package/src/tui/views/ProjectsView.tsx +104 -38
- package/src/tui/views/RealTimeDashboard.tsx +91 -8
- package/src/tui/views/SettingsView.tsx +26 -8
- package/src/utils/__tests__/error-category.test.ts +147 -0
- package/src/utils/currency.ts +4 -2
- package/src/utils/error-category.ts +91 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
> [!
|
|
2
|
-
> **tokentop is under active development and not yet ready for general use.** APIs and configuration may change without notice. If you're interested, star the repo and check back soon.
|
|
1
|
+
> **Early release** — tokentop is functional and actively developed. Please [report issues](https://github.com/tokentopapp/tokentop/issues)!
|
|
3
2
|
|
|
4
3
|
<div align="center">
|
|
5
4
|
|
|
@@ -16,8 +15,11 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
|
|
|
16
15
|
[](https://github.com/tokentopapp/tokentop/releases)
|
|
17
16
|
[](https://www.typescriptlang.org/)
|
|
18
17
|
[](https://bun.sh)
|
|
18
|
+
[](https://visitorbadge.io/status?path=https://github.com/tokentopapp/tokentop)
|
|
19
19
|
|
|
20
|
-
[Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [
|
|
20
|
+
[Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Agents](#agents) · [Plugins](#plugin-system) · [Docs](https://tokentop.app/docs/getting-started/)
|
|
21
|
+
|
|
22
|
+
**[tokentop.app](https://tokentop.app)**
|
|
21
23
|
|
|
22
24
|
</div>
|
|
23
25
|
|
|
@@ -25,26 +27,26 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
|
|
|
25
27
|
|
|
26
28
|
## Why tokentop?
|
|
27
29
|
|
|
28
|
-
You're coding with AI agents all day. Claude Code, OpenCode, Cursor — they're burning through tokens and you have no idea how fast.
|
|
30
|
+
You're coding with AI agents all day. Claude Code, OpenCode, Cursor, Copilot CLI — they're burning through tokens and you have no idea how fast.
|
|
29
31
|
|
|
30
32
|
You check the Anthropic dashboard. It's hours behind. You check OpenAI. Different dashboard, different login. Want to see it all in one place? In real time? In your terminal, where you already live?
|
|
31
33
|
|
|
32
34
|
That's tokentop.
|
|
33
35
|
|
|
34
36
|
- See every session, every model, every dollar — **live**
|
|
35
|
-
- Track costs across **
|
|
37
|
+
- Track costs across **11 providers** from a single dashboard
|
|
36
38
|
- Set **budget alerts** so you don't wake up to a surprise bill
|
|
37
39
|
- Runs **100% locally** — your usage data never leaves your machine
|
|
38
40
|
|
|
39
41
|
## Demo
|
|
40
42
|
|
|
41
|
-
https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c
|
|
43
|
+
<https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c>
|
|
42
44
|
|
|
43
45
|
## Features
|
|
44
46
|
|
|
45
47
|
- **Real-time dashboard** — Live token counts, costs, burn rate, and activity sparklines
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
48
|
+
- **11 providers** — Anthropic, OpenAI, Google Gemini, GitHub Copilot, Codex, Perplexity, Antigravity, MiniMax, Zai, OpenCode Zen, Chutes
|
|
49
|
+
- **7 coding agents** — Claude Code, OpenCode, Cursor, Copilot CLI, Gemini CLI, Antigravity, and Windsurf. See every session with model, tokens, cost, and duration
|
|
48
50
|
- **Budget guardrails** — Daily, weekly, and monthly limits with visual warnings at limit percentages you set
|
|
49
51
|
- **Smart sidebar** — Adaptive panel that breaks down spending by model, project, or agent
|
|
50
52
|
- **Efficiency insights** — Cache leverage, output verbosity, and cost-per-request analysis to help you spend less
|
|
@@ -52,11 +54,11 @@ https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c
|
|
|
52
54
|
- **Projects view** — See which codebase is costing you the most
|
|
53
55
|
- **Provider limits** — Visual gauges showing how close you are to rate limits
|
|
54
56
|
- **Live pricing** — Fetches current model pricing from [models.dev](https://models.dev) with local caching
|
|
55
|
-
- **15 built-in themes** —
|
|
57
|
+
- **[15 built-in themes](https://tokentop.app/docs/themes/built-in/)** — Tokyo Night, Dracula, Nord, Catppuccin, Gruvbox, Rosé Pine, Kanagawa, and more. Dark and light variants. Create your own with the [theme plugin API](https://tokentop.app/docs/themes/custom-themes/)
|
|
56
58
|
- **Plugin system** — Extend with custom providers, agents, themes, and notifications
|
|
57
59
|
- **Responsive layout** — Adapts to any terminal size; sidebar, KPI strip, header, and tables all reflow automatically from ultrawide to laptop-width
|
|
58
60
|
- **Demo mode** — Explore the UI with synthetic data, no API keys needed
|
|
59
|
-
- **Zero config** — Auto-discovers credentials from Claude Code, environment variables, and CLI auth files
|
|
61
|
+
- **Zero config** — Auto-discovers credentials from Claude Code, OpenCode, Cursor, Copilot CLI, Gemini CLI, environment variables, and CLI auth files
|
|
60
62
|
|
|
61
63
|
## Installation
|
|
62
64
|
|
|
@@ -140,9 +142,21 @@ tokentop has 4 main views, switchable with `1`–`4`:
|
|
|
140
142
|
| `3` | **Trends** | ASCII step charts of cost over 7/30/90 days |
|
|
141
143
|
| `4` | **Projects** | Cost and token breakdown by local project/repo |
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
## Agents
|
|
146
|
+
|
|
147
|
+
tokentop tracks sessions from 7 coding agents. Each agent is a standalone plugin — built-in agents ship with the app, community agents install from npm.
|
|
148
|
+
|
|
149
|
+
| Agent | What it tracks | Plugin |
|
|
150
|
+
|-------|---------------|--------|
|
|
151
|
+
| [Claude Code](https://tokentop.app/docs/agents/claude-code/) | Sessions, OAuth tokens, multi-provider usage | [`@tokentop/agent-claude-code`](https://github.com/tokentopapp/agent-claude-code) |
|
|
152
|
+
| [OpenCode](https://tokentop.app/docs/agents/opencode/) | Sessions, OAuth credentials, multi-provider support | [`@tokentop/agent-opencode`](https://github.com/tokentopapp/agent-opencode) |
|
|
153
|
+
| [Cursor](https://tokentop.app/docs/agents/cursor/) | Sessions, CSV server log enrichment | [`@tokentop/agent-cursor`](https://github.com/tokentopapp/agent-cursor) |
|
|
154
|
+
| [Copilot CLI](https://tokentop.app/docs/agents/copilot-cli/) | Sessions, GitHub token auth | [`@tokentop/agent-copilot-cli`](https://github.com/tokentopapp/agent-copilot-cli) |
|
|
155
|
+
| [Gemini CLI](https://tokentop.app/docs/agents/gemini-cli/) | Sessions, Google OAuth | [`@tokentop/agent-gemini`](https://github.com/tokentopapp/agent-gemini) |
|
|
156
|
+
| [Antigravity](https://tokentop.app/docs/agents/antigravity/) | Sessions, Google OAuth | [`@tokentop/agent-gemini`](https://github.com/tokentopapp/agent-gemini) |
|
|
157
|
+
| [Windsurf](https://tokentop.app/docs/agents/windsurf/) | Sessions, Codeium auth | [`@tokentop/agent-windsurf`](https://github.com/tokentopapp/agent-windsurf) |
|
|
158
|
+
|
|
159
|
+
All agents are auto-discovered — if you have the tool installed, tokentop finds it. See the [agent docs](https://tokentop.app/docs/agents/) for details.
|
|
146
160
|
|
|
147
161
|
## Keyboard Shortcuts
|
|
148
162
|
|
|
@@ -218,7 +232,7 @@ tokentop is built on a plugin architecture with four extension points:
|
|
|
218
232
|
|
|
219
233
|
All plugins run in a **permission sandbox** — they must declare network, filesystem, and environment access upfront. Anyone can publish community plugins to npm — no org membership needed.
|
|
220
234
|
|
|
221
|
-
See the [Plugin Guide](docs/plugins
|
|
235
|
+
See the [Plugin Guide](https://tokentop.app/docs/plugins/overview/) for installation, configuration, and development details.
|
|
222
236
|
|
|
223
237
|
## How It Works
|
|
224
238
|
|
|
@@ -231,6 +245,15 @@ Costs are calculated from token counts and live pricing data from [models.dev](h
|
|
|
231
245
|
|
|
232
246
|
All data is stored in a **local SQLite database**. Nothing is sent anywhere. No telemetry, no analytics, no network calls except to the provider APIs you've already authenticated with.
|
|
233
247
|
|
|
248
|
+
## Alternatives
|
|
249
|
+
|
|
250
|
+
| Tool | Approach | Tokentop Difference |
|
|
251
|
+
|------|----------|---------------------|
|
|
252
|
+
| Provider dashboards | Web, hours behind | Real-time, in terminal |
|
|
253
|
+
| [tokentap](https://github.com/nicholasgasior/tokentap) | MitM proxy (abandoned) | Native API polling, no proxy |
|
|
254
|
+
| [toktop](https://github.com/jnsahaj/toktop) | Rust TUI, 2 providers | 11 providers, 7 agents, plugins |
|
|
255
|
+
| [sniffly](https://github.com/chiphuyen/sniffly) | Python dashboard | Terminal-native, multi-agent |
|
|
256
|
+
|
|
234
257
|
## Development
|
|
235
258
|
|
|
236
259
|
```bash
|
|
@@ -238,7 +261,7 @@ bun install # Install dependencies
|
|
|
238
261
|
bun run dev # Dev mode with hot reload
|
|
239
262
|
bun test # Run tests
|
|
240
263
|
bun run typecheck # TypeScript check
|
|
241
|
-
bun run lint #
|
|
264
|
+
bun run lint # Biome
|
|
242
265
|
```
|
|
243
266
|
|
|
244
267
|
### Demo mode for development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokentop/ttop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Real-time AI token usage monitor - htop for your API costs",
|
|
5
5
|
"author": "Nigel Bazzeghin <nbazzeghin@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,16 +30,18 @@
|
|
|
30
30
|
"test:watch": "bun test --watch",
|
|
31
31
|
"driver": "bun src/tui/driver/cli.ts",
|
|
32
32
|
"driver:help": "bun src/tui/driver/cli.ts --help",
|
|
33
|
-
"snapshot": "bun src/tui/debug/snapshot.tsx"
|
|
33
|
+
"snapshot": "bun src/tui/debug/snapshot.tsx",
|
|
34
|
+
"analytics": "bun .opencode/skills/analytics/scripts/analytics.ts"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@opentui/core": "0.1.
|
|
37
|
-
"@opentui/react": "0.1.
|
|
37
|
+
"@opentui/core": "0.1.87",
|
|
38
|
+
"@opentui/react": "0.1.87",
|
|
38
39
|
"@tokentop/agent-claude-code": "^1.1.0",
|
|
40
|
+
"@tokentop/agent-copilot-cli": "^1.0.0",
|
|
39
41
|
"@tokentop/agent-cursor": "^1.2.0",
|
|
42
|
+
"@tokentop/agent-gemini": "^1.0.0",
|
|
40
43
|
"@tokentop/agent-opencode": "^1.0.0",
|
|
41
|
-
"@tokentop/
|
|
42
|
-
"@tokentop/plugin-sdk": "^1.3.0",
|
|
44
|
+
"@tokentop/plugin-sdk": "^1.4.0",
|
|
43
45
|
"react": "^19.0.0",
|
|
44
46
|
"react-devtools-core": "^7.0.1",
|
|
45
47
|
"zod": "^4.0.0"
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { aggregateSessionUsage, deduplicateAggregates } from "./aggregator.ts";
|
|
3
|
+
import type { AgentSessionAggregate } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Helper: build a minimal AgentSessionAggregate for dedup tests
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
function makeAggregate(
|
|
9
|
+
overrides: Partial<AgentSessionAggregate> & Pick<AgentSessionAggregate, "sessionId" | "agentId">,
|
|
10
|
+
): AgentSessionAggregate {
|
|
11
|
+
return {
|
|
12
|
+
agentName: overrides.agentId,
|
|
13
|
+
startedAt: 0,
|
|
14
|
+
lastActivityAt: 0,
|
|
15
|
+
status: "idle",
|
|
16
|
+
totals: { input: 0, output: 0 },
|
|
17
|
+
totalCostUsd: 0,
|
|
18
|
+
requestCount: 0,
|
|
19
|
+
streams: [],
|
|
20
|
+
costInDay: 0,
|
|
21
|
+
costInWeek: 0,
|
|
22
|
+
costInMonth: 0,
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// deduplicateAggregates
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
describe("deduplicateAggregates", () => {
|
|
31
|
+
test("returns empty array for empty input", () => {
|
|
32
|
+
expect(deduplicateAggregates([])).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("passes through unique sessions unchanged", () => {
|
|
36
|
+
const a = makeAggregate({ sessionId: "s1", agentId: "antigravity" });
|
|
37
|
+
const b = makeAggregate({ sessionId: "s2", agentId: "antigravity" });
|
|
38
|
+
const c = makeAggregate({ sessionId: "s3", agentId: "gemini-cli" });
|
|
39
|
+
|
|
40
|
+
const result = deduplicateAggregates([a, b, c]);
|
|
41
|
+
expect(result).toHaveLength(3);
|
|
42
|
+
expect(result.map((r) => r.sessionId).sort()).toEqual(["s1", "s2", "s3"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("removes exact duplicates (same sessionId from two agents)", () => {
|
|
46
|
+
const fromAntigravity = makeAggregate({
|
|
47
|
+
sessionId: "dup-1",
|
|
48
|
+
agentId: "antigravity",
|
|
49
|
+
agentName: "Antigravity",
|
|
50
|
+
requestCount: 5,
|
|
51
|
+
streams: [
|
|
52
|
+
{
|
|
53
|
+
providerId: "google",
|
|
54
|
+
modelId: "gemini-3-flash",
|
|
55
|
+
tokens: { input: 100, output: 50 },
|
|
56
|
+
requestCount: 5,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
const fromGeminiCli = makeAggregate({
|
|
61
|
+
sessionId: "dup-1",
|
|
62
|
+
agentId: "gemini-cli",
|
|
63
|
+
agentName: "Gemini CLI",
|
|
64
|
+
requestCount: 5,
|
|
65
|
+
streams: [
|
|
66
|
+
{
|
|
67
|
+
providerId: "google",
|
|
68
|
+
modelId: "gemini-3-flash",
|
|
69
|
+
tokens: { input: 100, output: 50 },
|
|
70
|
+
requestCount: 5,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = deduplicateAggregates([fromAntigravity, fromGeminiCli]);
|
|
76
|
+
expect(result).toHaveLength(1);
|
|
77
|
+
// First one encountered wins when equal
|
|
78
|
+
expect(result[0]!.agentId).toBe("antigravity");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("keeps the aggregate with more streams", () => {
|
|
82
|
+
const fewer = makeAggregate({
|
|
83
|
+
sessionId: "dup-2",
|
|
84
|
+
agentId: "agent-a",
|
|
85
|
+
requestCount: 10,
|
|
86
|
+
streams: [
|
|
87
|
+
{
|
|
88
|
+
providerId: "google",
|
|
89
|
+
modelId: "gemini-3-flash",
|
|
90
|
+
tokens: { input: 100, output: 50 },
|
|
91
|
+
requestCount: 10,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
const more = makeAggregate({
|
|
96
|
+
sessionId: "dup-2",
|
|
97
|
+
agentId: "agent-b",
|
|
98
|
+
requestCount: 10,
|
|
99
|
+
streams: [
|
|
100
|
+
{
|
|
101
|
+
providerId: "google",
|
|
102
|
+
modelId: "gemini-3-flash",
|
|
103
|
+
tokens: { input: 80, output: 40 },
|
|
104
|
+
requestCount: 7,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
providerId: "google",
|
|
108
|
+
modelId: "gemini-2.5-pro",
|
|
109
|
+
tokens: { input: 20, output: 10 },
|
|
110
|
+
requestCount: 3,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Regardless of order, the one with more streams wins
|
|
116
|
+
expect(deduplicateAggregates([fewer, more])[0]!.agentId).toBe("agent-b");
|
|
117
|
+
expect(deduplicateAggregates([more, fewer])[0]!.agentId).toBe("agent-b");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("breaks stream-count tie by preferring more requests", () => {
|
|
121
|
+
const lessRequests = makeAggregate({
|
|
122
|
+
sessionId: "dup-3",
|
|
123
|
+
agentId: "agent-a",
|
|
124
|
+
requestCount: 3,
|
|
125
|
+
streams: [
|
|
126
|
+
{
|
|
127
|
+
providerId: "google",
|
|
128
|
+
modelId: "gemini-3-flash",
|
|
129
|
+
tokens: { input: 50, output: 25 },
|
|
130
|
+
requestCount: 3,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
const moreRequests = makeAggregate({
|
|
135
|
+
sessionId: "dup-3",
|
|
136
|
+
agentId: "agent-b",
|
|
137
|
+
requestCount: 7,
|
|
138
|
+
streams: [
|
|
139
|
+
{
|
|
140
|
+
providerId: "google",
|
|
141
|
+
modelId: "gemini-3-flash",
|
|
142
|
+
tokens: { input: 100, output: 50 },
|
|
143
|
+
requestCount: 7,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(deduplicateAggregates([lessRequests, moreRequests])[0]!.agentId).toBe("agent-b");
|
|
149
|
+
expect(deduplicateAggregates([moreRequests, lessRequests])[0]!.agentId).toBe("agent-b");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("keeps first encountered when streams and requests are equal", () => {
|
|
153
|
+
const first = makeAggregate({
|
|
154
|
+
sessionId: "dup-4",
|
|
155
|
+
agentId: "first",
|
|
156
|
+
requestCount: 5,
|
|
157
|
+
streams: [
|
|
158
|
+
{ providerId: "google", modelId: "m", tokens: { input: 1, output: 1 }, requestCount: 5 },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
const second = makeAggregate({
|
|
162
|
+
sessionId: "dup-4",
|
|
163
|
+
agentId: "second",
|
|
164
|
+
requestCount: 5,
|
|
165
|
+
streams: [
|
|
166
|
+
{ providerId: "google", modelId: "m", tokens: { input: 1, output: 1 }, requestCount: 5 },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(deduplicateAggregates([first, second])[0]!.agentId).toBe("first");
|
|
171
|
+
expect(deduplicateAggregates([second, first])[0]!.agentId).toBe("second");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("handles mix of unique and duplicate sessions", () => {
|
|
175
|
+
const unique1 = makeAggregate({ sessionId: "u1", agentId: "antigravity" });
|
|
176
|
+
const unique2 = makeAggregate({ sessionId: "u2", agentId: "opencode" });
|
|
177
|
+
const dupA = makeAggregate({
|
|
178
|
+
sessionId: "d1",
|
|
179
|
+
agentId: "antigravity",
|
|
180
|
+
requestCount: 5,
|
|
181
|
+
streams: [
|
|
182
|
+
{ providerId: "google", modelId: "m", tokens: { input: 1, output: 1 }, requestCount: 5 },
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
const dupB = makeAggregate({
|
|
186
|
+
sessionId: "d1",
|
|
187
|
+
agentId: "gemini-cli",
|
|
188
|
+
requestCount: 5,
|
|
189
|
+
streams: [
|
|
190
|
+
{ providerId: "google", modelId: "m", tokens: { input: 1, output: 1 }, requestCount: 5 },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = deduplicateAggregates([unique1, dupA, unique2, dupB]);
|
|
195
|
+
expect(result).toHaveLength(3);
|
|
196
|
+
expect(result.map((r) => r.sessionId).sort()).toEqual(["d1", "u1", "u2"]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// aggregateSessionUsage — basic sanity
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
describe("aggregateSessionUsage", () => {
|
|
204
|
+
const NOW = Date.now();
|
|
205
|
+
|
|
206
|
+
test("groups rows by sessionId", () => {
|
|
207
|
+
const result = aggregateSessionUsage({
|
|
208
|
+
agentId: "test",
|
|
209
|
+
agentName: "Test",
|
|
210
|
+
now: NOW,
|
|
211
|
+
rows: [
|
|
212
|
+
{
|
|
213
|
+
sessionId: "s1",
|
|
214
|
+
providerId: "google",
|
|
215
|
+
modelId: "m1",
|
|
216
|
+
tokens: { input: 10, output: 5 },
|
|
217
|
+
timestamp: NOW,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
sessionId: "s1",
|
|
221
|
+
providerId: "google",
|
|
222
|
+
modelId: "m1",
|
|
223
|
+
tokens: { input: 20, output: 10 },
|
|
224
|
+
timestamp: NOW - 1000,
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
sessionId: "s2",
|
|
228
|
+
providerId: "google",
|
|
229
|
+
modelId: "m1",
|
|
230
|
+
tokens: { input: 30, output: 15 },
|
|
231
|
+
timestamp: NOW - 2000,
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result).toHaveLength(2);
|
|
237
|
+
|
|
238
|
+
const s1 = result.find((r) => r.sessionId === "s1");
|
|
239
|
+
expect(s1).toBeDefined();
|
|
240
|
+
expect(s1!.requestCount).toBe(2);
|
|
241
|
+
expect(s1!.totals.input).toBe(30);
|
|
242
|
+
expect(s1!.totals.output).toBe(15);
|
|
243
|
+
|
|
244
|
+
const s2 = result.find((r) => r.sessionId === "s2");
|
|
245
|
+
expect(s2).toBeDefined();
|
|
246
|
+
expect(s2!.requestCount).toBe(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("splits different models into separate streams", () => {
|
|
250
|
+
const result = aggregateSessionUsage({
|
|
251
|
+
agentId: "test",
|
|
252
|
+
agentName: "Test",
|
|
253
|
+
now: NOW,
|
|
254
|
+
rows: [
|
|
255
|
+
{
|
|
256
|
+
sessionId: "s1",
|
|
257
|
+
providerId: "google",
|
|
258
|
+
modelId: "flash",
|
|
259
|
+
tokens: { input: 10, output: 5 },
|
|
260
|
+
timestamp: NOW,
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
sessionId: "s1",
|
|
264
|
+
providerId: "google",
|
|
265
|
+
modelId: "pro",
|
|
266
|
+
tokens: { input: 20, output: 10 },
|
|
267
|
+
timestamp: NOW,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(result).toHaveLength(1);
|
|
273
|
+
expect(result[0]!.streams).toHaveLength(2);
|
|
274
|
+
expect(result[0]!.requestCount).toBe(2);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("marks session active when within threshold", () => {
|
|
278
|
+
const result = aggregateSessionUsage({
|
|
279
|
+
agentId: "test",
|
|
280
|
+
agentName: "Test",
|
|
281
|
+
now: NOW,
|
|
282
|
+
activeThresholdMs: 60_000,
|
|
283
|
+
rows: [
|
|
284
|
+
{
|
|
285
|
+
sessionId: "active",
|
|
286
|
+
providerId: "g",
|
|
287
|
+
modelId: "m",
|
|
288
|
+
tokens: { input: 1, output: 1 },
|
|
289
|
+
timestamp: NOW - 10_000,
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
sessionId: "idle",
|
|
293
|
+
providerId: "g",
|
|
294
|
+
modelId: "m",
|
|
295
|
+
tokens: { input: 1, output: 1 },
|
|
296
|
+
timestamp: NOW - 120_000,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const active = result.find((r) => r.sessionId === "active");
|
|
302
|
+
const idle = result.find((r) => r.sessionId === "idle");
|
|
303
|
+
expect(active!.status).toBe("active");
|
|
304
|
+
expect(idle!.status).toBe("idle");
|
|
305
|
+
});
|
|
306
|
+
});
|
package/src/agents/aggregator.ts
CHANGED
|
@@ -85,6 +85,7 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
85
85
|
timestamps: number[];
|
|
86
86
|
sessionUpdatedAt?: number;
|
|
87
87
|
streamMap: Map<string, StreamAccumulator>;
|
|
88
|
+
hasEstimated: boolean;
|
|
88
89
|
}
|
|
89
90
|
>();
|
|
90
91
|
|
|
@@ -97,9 +98,11 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
97
98
|
timestamps: number[];
|
|
98
99
|
sessionUpdatedAt?: number;
|
|
99
100
|
streamMap: Map<string, StreamAccumulator>;
|
|
101
|
+
hasEstimated: boolean;
|
|
100
102
|
} = {
|
|
101
103
|
timestamps: [],
|
|
102
104
|
streamMap: new Map(),
|
|
105
|
+
hasEstimated: false,
|
|
103
106
|
};
|
|
104
107
|
if (row.sessionName) newSession.sessionName = row.sessionName;
|
|
105
108
|
if (row.projectPath) newSession.projectPath = row.projectPath;
|
|
@@ -107,7 +110,8 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
107
110
|
sessionMap.set(row.sessionId, newSession);
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
const session = sessionMap.get(row.sessionId)
|
|
113
|
+
const session = sessionMap.get(row.sessionId);
|
|
114
|
+
if (!session) continue;
|
|
111
115
|
|
|
112
116
|
session.timestamps.push(row.timestamp);
|
|
113
117
|
if (row.sessionName && !session.sessionName) {
|
|
@@ -145,6 +149,9 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
145
149
|
if (row.timestamp >= startOfDay) stream.windowed.dayTokens += msgTokens;
|
|
146
150
|
if (row.timestamp >= startOfWeek) stream.windowed.weekTokens += msgTokens;
|
|
147
151
|
if (row.timestamp >= startOfMonth) stream.windowed.monthTokens += msgTokens;
|
|
152
|
+
if ((row.metadata?.isEstimated as boolean) === true) {
|
|
153
|
+
session.hasEstimated = true;
|
|
154
|
+
}
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
const results: AgentSessionAggregate[] = [];
|
|
@@ -190,10 +197,8 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
190
197
|
if (session.sessionName) aggregate.sessionName = session.sessionName;
|
|
191
198
|
if (session.projectPath) aggregate.projectPath = session.projectPath;
|
|
192
199
|
|
|
193
|
-
// Propagate metadata —
|
|
194
|
-
|
|
195
|
-
const anyEstimated = sessionRows.some((r) => (r.metadata?.isEstimated as boolean) === true);
|
|
196
|
-
if (anyEstimated) {
|
|
200
|
+
// Propagate metadata — collected during the first pass (O(1) per session)
|
|
201
|
+
if (session.hasEstimated) {
|
|
197
202
|
aggregate.metadata = { isEstimated: true };
|
|
198
203
|
}
|
|
199
204
|
results.push(aggregate);
|
|
@@ -201,3 +206,33 @@ export function aggregateSessionUsage(options: AggregateOptions): AgentSessionAg
|
|
|
201
206
|
|
|
202
207
|
return results.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
|
|
203
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Deduplicate aggregates that share the same sessionId.
|
|
212
|
+
*
|
|
213
|
+
* This happens when multiple agent plugins scan the same directory
|
|
214
|
+
* (e.g. both agent-antigravity and agent-gemini-cli read ~/.gemini/tmp/).
|
|
215
|
+
* For each duplicate sessionId, the richer aggregate is kept:
|
|
216
|
+
* 1. More streams (more provider/model pairs)
|
|
217
|
+
* 2. If tied, more requests
|
|
218
|
+
* 3. If still tied, the first one encountered
|
|
219
|
+
*/
|
|
220
|
+
export function deduplicateAggregates(
|
|
221
|
+
aggregates: AgentSessionAggregate[],
|
|
222
|
+
): AgentSessionAggregate[] {
|
|
223
|
+
const deduped = new Map<string, AgentSessionAggregate>();
|
|
224
|
+
for (const agg of aggregates) {
|
|
225
|
+
const existing = deduped.get(agg.sessionId);
|
|
226
|
+
if (!existing) {
|
|
227
|
+
deduped.set(agg.sessionId, agg);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (
|
|
231
|
+
agg.streams.length > existing.streams.length ||
|
|
232
|
+
(agg.streams.length === existing.streams.length && agg.requestCount > existing.requestCount)
|
|
233
|
+
) {
|
|
234
|
+
deduped.set(agg.sessionId, agg);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return Array.from(deduped.values());
|
|
238
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,7 @@ Commands:
|
|
|
19
19
|
demo Run with demo data (no real providers)
|
|
20
20
|
|
|
21
21
|
Options:
|
|
22
|
+
-t, --theme <id> Launch with a specific theme (e.g. dracula, nord)
|
|
22
23
|
-r, --refresh <ms> Refresh interval in milliseconds (default: 60000)
|
|
23
24
|
-p, --plugin <path> Load a local plugin (file or directory, repeatable)
|
|
24
25
|
-d, --debug Enable debug mode (verbose logging)
|
|
@@ -69,6 +70,7 @@ async function main() {
|
|
|
69
70
|
let demoSeed: number | undefined;
|
|
70
71
|
let demoPreset: DemoPreset | undefined;
|
|
71
72
|
const cliPlugins: string[] = [];
|
|
73
|
+
let cliTheme: string | undefined;
|
|
72
74
|
|
|
73
75
|
// Handle demo subcommand
|
|
74
76
|
if (subcommand === "demo") {
|
|
@@ -96,6 +98,15 @@ async function main() {
|
|
|
96
98
|
debug = true;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
if (arg === "-t" || arg === "--theme") {
|
|
102
|
+
const themeId = commandArgs[++i];
|
|
103
|
+
if (!themeId) {
|
|
104
|
+
console.error("Error: --theme requires a theme ID (e.g. dracula, nord, tokyo-night)");
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
cliTheme = themeId;
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
if (arg === "-p" || arg === "--plugin") {
|
|
100
111
|
const pluginPath = commandArgs[++i];
|
|
101
112
|
if (!pluginPath) {
|
|
@@ -159,6 +170,9 @@ async function main() {
|
|
|
159
170
|
if (demoPreset !== undefined) {
|
|
160
171
|
launchOptions.demoPreset = demoPreset;
|
|
161
172
|
}
|
|
173
|
+
if (cliTheme !== undefined) {
|
|
174
|
+
launchOptions.cliTheme = cliTheme;
|
|
175
|
+
}
|
|
162
176
|
await startTui(launchOptions);
|
|
163
177
|
|
|
164
178
|
const handleSignal = () => void triggerShutdown();
|
package/src/config/schema.ts
CHANGED
package/src/demo/simulator.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as claudeCodeAgentPlugin } from "@tokentop/agent-claude-code";
|
|
2
2
|
export { default as copilotCliAgentPlugin } from "@tokentop/agent-copilot-cli";
|
|
3
3
|
export { default as cursorAgentPlugin } from "@tokentop/agent-cursor";
|
|
4
|
+
export { default as geminiAgentPlugin } from "@tokentop/agent-gemini";
|
|
4
5
|
export { default as opencodeAgentPlugin } from "@tokentop/agent-opencode";
|
package/src/plugins/lifecycle.ts
CHANGED
|
@@ -153,7 +153,7 @@ class PluginLifecycleManager {
|
|
|
153
153
|
const ctx = this.makeCtx(entry);
|
|
154
154
|
await safeInvoke(pluginId, "onConfigChange", () =>
|
|
155
155
|
runInPluginGuard(pluginId, plugin.permissions, () =>
|
|
156
|
-
Promise.resolve(plugin.onConfigChange
|
|
156
|
+
Promise.resolve(plugin.onConfigChange?.(newConfig, ctx)),
|
|
157
157
|
),
|
|
158
158
|
);
|
|
159
159
|
}
|
|
@@ -18,6 +18,16 @@ class NotificationBus {
|
|
|
18
18
|
private plugins: NotificationPlugin[] = [];
|
|
19
19
|
private recentEvents = new Map<string, DedupEntry>();
|
|
20
20
|
private pluginConfigs = new Map<string, Record<string, unknown>>();
|
|
21
|
+
private toastHandler: ((event: NotificationEvent) => void) | null = null;
|
|
22
|
+
private flashHandler: ((severity: NotificationEvent["severity"]) => void) | null = null;
|
|
23
|
+
|
|
24
|
+
setToastHandler(handler: ((event: NotificationEvent) => void) | null): void {
|
|
25
|
+
this.toastHandler = handler;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setFlashHandler(handler: ((severity: NotificationEvent["severity"]) => void) | null): void {
|
|
29
|
+
this.flashHandler = handler;
|
|
30
|
+
}
|
|
21
31
|
|
|
22
32
|
registerPlugins(plugins: NotificationPlugin[]): void {
|
|
23
33
|
this.plugins = plugins;
|
|
@@ -124,6 +134,23 @@ class NotificationBus {
|
|
|
124
134
|
);
|
|
125
135
|
}),
|
|
126
136
|
);
|
|
137
|
+
|
|
138
|
+
this.toastHandler?.(event);
|
|
139
|
+
this.flashHandler?.(event.severity);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async testNotification(): Promise<void> {
|
|
143
|
+
const event: NotificationEvent = {
|
|
144
|
+
type: "budget.thresholdCrossed",
|
|
145
|
+
severity: "warning",
|
|
146
|
+
title: "Test Notification",
|
|
147
|
+
message: "This is a test notification from tokentop.",
|
|
148
|
+
timestamp: Date.now(),
|
|
149
|
+
data: {},
|
|
150
|
+
};
|
|
151
|
+
// Bypass dedup for test
|
|
152
|
+
await this.emit("__test__", event);
|
|
153
|
+
this.recentEvents.delete("__test__");
|
|
127
154
|
}
|
|
128
155
|
|
|
129
156
|
private isDuplicate(key: string): boolean {
|