@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.
Files changed (51) hide show
  1. package/README.md +7 -6
  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/demo/simulator.ts +1 -1
  7. package/src/plugins/agents/index.ts +1 -0
  8. package/src/plugins/lifecycle.ts +1 -1
  9. package/src/plugins/plugin-host.test.ts +197 -0
  10. package/src/plugins/plugin-host.ts +45 -1
  11. package/src/plugins/providers/anthropic.test.ts +202 -0
  12. package/src/plugins/providers/anthropic.ts +19 -0
  13. package/src/plugins/providers/antigravity.ts +16 -0
  14. package/src/plugins/providers/chutes.ts +16 -0
  15. package/src/plugins/providers/codex.ts +16 -0
  16. package/src/plugins/providers/gemini.ts +16 -0
  17. package/src/plugins/providers/github-copilot.ts +16 -0
  18. package/src/plugins/providers/minimax.ts +16 -0
  19. package/src/plugins/providers/openai-api.ts +52 -0
  20. package/src/plugins/providers/perplexity.ts +10 -1
  21. package/src/plugins/providers/zai.ts +16 -0
  22. package/src/pricing/fallback.ts +4 -0
  23. package/src/tui/App.tsx +16 -8
  24. package/src/tui/components/HelpOverlay.tsx +1 -1
  25. package/src/tui/components/LimitGauge.tsx +12 -4
  26. package/src/tui/components/ProviderCard.tsx +38 -22
  27. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  28. package/src/tui/components/SessionsTable.tsx +54 -36
  29. package/src/tui/components/Skeleton.tsx +9 -6
  30. package/src/tui/components/SortOverlay.tsx +167 -0
  31. package/src/tui/components/Spinner.tsx +16 -11
  32. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  33. package/src/tui/contexts/PluginContext.tsx +64 -10
  34. package/src/tui/contexts/ThemeContext.tsx +4 -1
  35. package/src/tui/createApp.tsx +2 -0
  36. package/src/tui/hooks/index.ts +1 -0
  37. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  38. package/src/tui/hooks/useAnimationTick.ts +93 -0
  39. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  40. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  41. package/src/tui/hooks/usePulse.ts +13 -7
  42. package/src/tui/hooks/useValueFlash.ts +25 -33
  43. package/src/tui/index.tsx +4 -0
  44. package/src/tui/types/projectSort.ts +35 -0
  45. package/src/tui/types/sort.ts +61 -0
  46. package/src/tui/views/ProjectsView.tsx +104 -38
  47. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  48. package/src/utils/__tests__/error-category.test.ts +147 -0
  49. package/src/utils/currency.ts +4 -2
  50. package/src/utils/error-category.ts +91 -0
  51. 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
  [![GitHub release](https://img.shields.io/github/v/release/tokentopapp/tokentop?style=flat-square)](https://github.com/tokentopapp/tokentop/releases)
17
17
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
18
18
  [![Bun](https://img.shields.io/badge/Bun-runtime-f9f1e1?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
19
+ [![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
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 **10 providers** from a single dashboard
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
- - **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
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.5.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.84",
37
- "@opentui/react": "0.1.84",
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/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();
@@ -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
  }