@tokentop/ttop 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- 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/demo/simulator.ts +1 -1
- package/src/plugins/agents/index.ts +1 -0
- package/src/plugins/lifecycle.ts +1 -1
- 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 +16 -8
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- 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/Skeleton.tsx +9 -6
- package/src/tui/components/SortOverlay.tsx +167 -0
- package/src/tui/components/Spinner.tsx +16 -11
- package/src/tui/contexts/AgentSessionContext.tsx +69 -16
- package/src/tui/contexts/PluginContext.tsx +64 -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/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/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
|
@@ -16,6 +16,7 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
|
|
|
16
16
|
[](https://github.com/tokentopapp/tokentop/releases)
|
|
17
17
|
[](https://www.typescriptlang.org/)
|
|
18
18
|
[](https://bun.sh)
|
|
19
|
+
[](https://visitorbadge.io/status?path=https://github.com/tokentopapp/tokentop)
|
|
19
20
|
|
|
20
21
|
[Features](#features) · [Install](#installation) · [Quick Start](#quick-start) · [Keyboard Shortcuts](#keyboard-shortcuts) · [Configuration](#configuration) · [Plugins](#plugin-system)
|
|
21
22
|
|
|
@@ -25,26 +26,26 @@ Real-time terminal monitoring of LLM token usage and spending across providers a
|
|
|
25
26
|
|
|
26
27
|
## Why tokentop?
|
|
27
28
|
|
|
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.
|
|
29
|
+
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
30
|
|
|
30
31
|
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
32
|
|
|
32
33
|
That's tokentop.
|
|
33
34
|
|
|
34
35
|
- See every session, every model, every dollar — **live**
|
|
35
|
-
- Track costs across **
|
|
36
|
+
- Track costs across **11 providers** from a single dashboard
|
|
36
37
|
- Set **budget alerts** so you don't wake up to a surprise bill
|
|
37
38
|
- Runs **100% locally** — your usage data never leaves your machine
|
|
38
39
|
|
|
39
40
|
## Demo
|
|
40
41
|
|
|
41
|
-
https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c
|
|
42
|
+
<https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c>
|
|
42
43
|
|
|
43
44
|
## Features
|
|
44
45
|
|
|
45
46
|
- **Real-time dashboard** — Live token counts, costs, burn rate, and activity sparklines
|
|
46
|
-
- **
|
|
47
|
-
- **Session tracking** — See every coding agent session with model, tokens, cost, and duration
|
|
47
|
+
- **11 providers** — Anthropic, OpenAI, Google Gemini, GitHub Copilot, Codex, Perplexity, Antigravity, MiniMax, Zai, OpenCode Zen, Chutes
|
|
48
|
+
- **Session tracking** — See every coding agent session with model, tokens, cost, and duration. Built-in support for Claude Code, OpenCode, Cursor, and Copilot CLI
|
|
48
49
|
- **Budget guardrails** — Daily, weekly, and monthly limits with visual warnings at limit percentages you set
|
|
49
50
|
- **Smart sidebar** — Adaptive panel that breaks down spending by model, project, or agent
|
|
50
51
|
- **Efficiency insights** — Cache leverage, output verbosity, and cost-per-request analysis to help you spend less
|
|
@@ -56,7 +57,7 @@ https://github.com/user-attachments/assets/0b0225c7-a137-454b-9b29-c858cdcbd86c
|
|
|
56
57
|
- **Plugin system** — Extend with custom providers, agents, themes, and notifications
|
|
57
58
|
- **Responsive layout** — Adapts to any terminal size; sidebar, KPI strip, header, and tables all reflow automatically from ultrawide to laptop-width
|
|
58
59
|
- **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
|
|
60
|
+
- **Zero config** — Auto-discovers credentials from Claude Code, OpenCode, Cursor, Copilot CLI, environment variables, and CLI auth files
|
|
60
61
|
|
|
61
62
|
## Installation
|
|
62
63
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokentop/ttop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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.86",
|
|
38
|
+
"@opentui/react": "0.1.86",
|
|
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/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
|
}
|