@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.
Files changed (60) hide show
  1. package/README.md +38 -15
  2. package/package.json +8 -6
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +40 -5
  5. package/src/cli.ts +14 -0
  6. package/src/config/schema.ts +1 -1
  7. package/src/demo/simulator.ts +1 -1
  8. package/src/plugins/agents/index.ts +1 -0
  9. package/src/plugins/lifecycle.ts +1 -1
  10. package/src/plugins/notification-bus.ts +27 -0
  11. package/src/plugins/notifications/terminal-bell.ts +80 -16
  12. package/src/plugins/notifications/visual-flash.ts +5 -18
  13. package/src/plugins/plugin-host.test.ts +197 -0
  14. package/src/plugins/plugin-host.ts +45 -1
  15. package/src/plugins/providers/anthropic.test.ts +202 -0
  16. package/src/plugins/providers/anthropic.ts +19 -0
  17. package/src/plugins/providers/antigravity.ts +16 -0
  18. package/src/plugins/providers/chutes.ts +16 -0
  19. package/src/plugins/providers/codex.ts +16 -0
  20. package/src/plugins/providers/gemini.ts +16 -0
  21. package/src/plugins/providers/github-copilot.ts +16 -0
  22. package/src/plugins/providers/minimax.ts +16 -0
  23. package/src/plugins/providers/openai-api.ts +52 -0
  24. package/src/plugins/providers/perplexity.ts +10 -1
  25. package/src/plugins/providers/zai.ts +16 -0
  26. package/src/pricing/fallback.ts +4 -0
  27. package/src/tui/App.tsx +22 -8
  28. package/src/tui/components/HelpOverlay.tsx +1 -1
  29. package/src/tui/components/LimitGauge.tsx +12 -4
  30. package/src/tui/components/NotificationFlash.tsx +141 -0
  31. package/src/tui/components/ProviderCard.tsx +38 -22
  32. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  33. package/src/tui/components/SessionsTable.tsx +54 -36
  34. package/src/tui/components/SettingsModal.tsx +20 -4
  35. package/src/tui/components/Skeleton.tsx +9 -6
  36. package/src/tui/components/SortOverlay.tsx +167 -0
  37. package/src/tui/components/Spinner.tsx +16 -11
  38. package/src/tui/components/Toast.tsx +1 -1
  39. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  40. package/src/tui/contexts/PluginContext.tsx +72 -10
  41. package/src/tui/contexts/ThemeContext.tsx +4 -1
  42. package/src/tui/createApp.tsx +2 -0
  43. package/src/tui/hooks/index.ts +1 -0
  44. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  45. package/src/tui/hooks/useAnimationTick.ts +93 -0
  46. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  47. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  48. package/src/tui/hooks/useNotificationBridge.ts +29 -0
  49. package/src/tui/hooks/usePulse.ts +13 -7
  50. package/src/tui/hooks/useValueFlash.ts +25 -33
  51. package/src/tui/index.tsx +4 -0
  52. package/src/tui/types/projectSort.ts +35 -0
  53. package/src/tui/types/sort.ts +61 -0
  54. package/src/tui/views/ProjectsView.tsx +104 -38
  55. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  56. package/src/tui/views/SettingsView.tsx +26 -8
  57. package/src/utils/__tests__/error-category.test.ts +147 -0
  58. package/src/utils/currency.ts +4 -2
  59. package/src/utils/error-category.ts +91 -0
  60. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -1,5 +1,4 @@
1
- > [!CAUTION]
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
  [![GitHub release](https://img.shields.io/github/v/release/tokentopapp/tokentop?style=flat-square)](https://github.com/tokentopapp/tokentop/releases)
17
16
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
18
17
  [![Bun](https://img.shields.io/badge/Bun-runtime-f9f1e1?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
18
+ [![Visitors](https://api.visitorbadge.io/api/visitors?path=https%3A%2F%2Fgithub.com%2Ftokentopapp%2Ftokentop&labelColor=%23697689&countColor=%23ba68c8&style=flat-square)](https://visitorbadge.io/status?path=https://github.com/tokentopapp/tokentop)
19
19
 
20
- [Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Keyboard Shortcuts](#keyboard-shortcuts) · [Configuration](#configuration) · [Plugins](#plugin-system)
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 **10 providers** from a single dashboard
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
- - **10 providers** — Anthropic, OpenAI, Google Gemini, GitHub Copilot, Codex, Perplexity, Antigravity, MiniMax, Zai, OpenCode Zen
47
- - **Session tracking** — See every coding agent session with model, tokens, cost, and duration
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** — Dark: Tokyo Night (default), Dracula, Nord, Catppuccin Mocha, Gruvbox Dark, One Dark, Rosé Pine, Kanagawa, OpenCode, Claude Code · Light: Catppuccin Latte, Gruvbox Light, GitHub Light, Solarized Light, Rosé Pine Dawn
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
- <!-- TODO: Add VHS recordings of each view
144
- > _Screenshots coming soon_
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.md) for installation, configuration, and development details.
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 # ESLint
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.5.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.84",
37
- "@opentui/react": "0.1.84",
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/agent-copilot-cli": "^1.0.0",
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
+ });
@@ -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 — session is estimated if any row is estimated
194
- const sessionRows = rows.filter((r) => r.sessionId === sessionId);
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();
@@ -82,7 +82,7 @@ export const DEFAULT_CONFIG: AppConfig = {
82
82
  },
83
83
  notifications: {
84
84
  toastsEnabled: true,
85
- soundEnabled: false,
85
+ soundEnabled: true,
86
86
  },
87
87
  budgets: {
88
88
  daily: null,
@@ -488,7 +488,7 @@ export class DemoSimulator {
488
488
  limitReached: newPercent > 95,
489
489
  limits: {
490
490
  primary: {
491
- ...existing.limits!.primary!,
491
+ ...existing.limits?.primary,
492
492
  usedPercent: newPercent,
493
493
  },
494
494
  },
@@ -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";
@@ -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!(newConfig, ctx)),
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 {