@tokentop/ttop 0.6.0 → 0.7.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 +33 -11
- package/package.json +4 -4
- package/src/agents/aggregator.test.ts +269 -1
- package/src/agents/aggregator.ts +196 -8
- package/src/agents/costing.test.ts +584 -0
- package/src/agents/costing.ts +144 -30
- package/src/agents/types.ts +7 -0
- package/src/config/schema.ts +1 -1
- package/src/demo/simulator.test.ts +92 -0
- package/src/demo/simulator.ts +579 -211
- 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-context-factory.ts +9 -1
- package/src/plugins/providers/anthropic.test.ts +145 -0
- package/src/plugins/providers/anthropic.ts +18 -12
- package/src/plugins/providers/chutes.ts +2 -0
- package/src/plugins/providers/github-copilot.ts +3 -0
- package/src/plugins/providers/minimax.ts +2 -0
- package/src/plugins/providers/openai-api.ts +4 -0
- package/src/plugins/providers/zai.ts +2 -0
- package/src/pricing/estimator.ts +7 -0
- package/src/pricing/index.ts +5 -0
- package/src/pricing/models-dev.test.ts +117 -0
- package/src/pricing/models-dev.ts +22 -0
- package/src/storage/persistence-service.test.ts +201 -0
- package/src/storage/persistence-service.ts +54 -5
- package/src/storage/repos/agentSessions.ts +49 -0
- package/src/tui/App.tsx +10 -4
- package/src/tui/components/NotificationFlash.tsx +141 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
- package/src/tui/components/SessionsTable.tsx +25 -7
- package/src/tui/components/SettingsModal.tsx +20 -4
- package/src/tui/components/Toast.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +59 -21
- package/src/tui/contexts/PluginContext.tsx +39 -10
- package/src/tui/hooks/useNotificationBridge.ts +29 -0
- package/src/tui/utils/scrollFollow.test.ts +71 -0
- package/src/tui/utils/scrollFollow.ts +12 -0
- package/src/tui/views/Dashboard.tsx +0 -6
- package/src/tui/views/RealTimeDashboard.tsx +105 -38
- package/src/tui/views/SettingsView.tsx +26 -8
- package/src/version.ts +1 -1
|
@@ -8,6 +8,12 @@ interface ModelsDevCost {
|
|
|
8
8
|
output: number;
|
|
9
9
|
cache_read?: number;
|
|
10
10
|
cache_write?: number;
|
|
11
|
+
context_over_200k?: {
|
|
12
|
+
input?: number;
|
|
13
|
+
output?: number;
|
|
14
|
+
cache_read?: number;
|
|
15
|
+
cache_write?: number;
|
|
16
|
+
};
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
interface ModelsDevModel {
|
|
@@ -88,6 +94,14 @@ export async function getModelPricing(
|
|
|
88
94
|
pricing.cacheWrite = model.cost.cache_write;
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
const lc = model.cost.context_over_200k;
|
|
98
|
+
if (lc) {
|
|
99
|
+
if (lc.input !== undefined) pricing.longContextInput = lc.input;
|
|
100
|
+
if (lc.output !== undefined) pricing.longContextOutput = lc.output;
|
|
101
|
+
if (lc.cache_read !== undefined) pricing.longContextCacheRead = lc.cache_read;
|
|
102
|
+
if (lc.cache_write !== undefined) pricing.longContextCacheWrite = lc.cache_write;
|
|
103
|
+
}
|
|
104
|
+
|
|
91
105
|
return pricing;
|
|
92
106
|
}
|
|
93
107
|
|
|
@@ -119,6 +133,14 @@ export async function getProviderModels(
|
|
|
119
133
|
pricing.cacheWrite = model.cost.cache_write;
|
|
120
134
|
}
|
|
121
135
|
|
|
136
|
+
const lc = model.cost.context_over_200k;
|
|
137
|
+
if (lc) {
|
|
138
|
+
if (lc.input !== undefined) pricing.longContextInput = lc.input;
|
|
139
|
+
if (lc.output !== undefined) pricing.longContextOutput = lc.output;
|
|
140
|
+
if (lc.cache_read !== undefined) pricing.longContextCacheRead = lc.cache_read;
|
|
141
|
+
if (lc.cache_write !== undefined) pricing.longContextCacheWrite = lc.cache_write;
|
|
142
|
+
}
|
|
143
|
+
|
|
122
144
|
result[modelId] = pricing;
|
|
123
145
|
}
|
|
124
146
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
AgentSessionSnapshotInsert,
|
|
4
|
+
AgentSessionStreamSnapshotRow,
|
|
5
|
+
AgentSessionUpsert,
|
|
6
|
+
UsageEventInsert,
|
|
7
|
+
} from "./types.ts";
|
|
8
|
+
|
|
9
|
+
mock.module("./db.ts", () => ({
|
|
10
|
+
isDatabaseInitialized: () => true,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
let upsertCalls: AgentSessionUpsert[] = [];
|
|
14
|
+
let snapshotCalls: Array<{
|
|
15
|
+
snapshot: AgentSessionSnapshotInsert;
|
|
16
|
+
streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[];
|
|
17
|
+
}> = [];
|
|
18
|
+
let usageEventCalls: UsageEventInsert[][] = [];
|
|
19
|
+
|
|
20
|
+
let seededSnapshots: Array<{
|
|
21
|
+
agentId: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
totalCostUsd: number;
|
|
25
|
+
requestCount: number;
|
|
26
|
+
totalInputTokens: number;
|
|
27
|
+
totalOutputTokens: number;
|
|
28
|
+
}> = [];
|
|
29
|
+
|
|
30
|
+
mock.module("./repos/agentSessions.ts", () => ({
|
|
31
|
+
getLatestSnapshotsForAllSessions: () => seededSnapshots,
|
|
32
|
+
getLatestStreamTotalsForAllSessions: () => [],
|
|
33
|
+
upsertAgentSession: (session: AgentSessionUpsert) => {
|
|
34
|
+
upsertCalls.push(session);
|
|
35
|
+
return upsertCalls.length;
|
|
36
|
+
},
|
|
37
|
+
insertAgentSessionSnapshot: (
|
|
38
|
+
snapshot: AgentSessionSnapshotInsert,
|
|
39
|
+
streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[],
|
|
40
|
+
) => {
|
|
41
|
+
snapshotCalls.push({ snapshot, streams });
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
mock.module("./repos/usageEvents.ts", () => ({
|
|
46
|
+
insertUsageEventBatch: (events: UsageEventInsert[]) => {
|
|
47
|
+
usageEventCalls.push(events);
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const { persistSessions, seedPreviousTotals } = await import("./persistence-service.ts");
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
upsertCalls = [];
|
|
55
|
+
snapshotCalls = [];
|
|
56
|
+
usageEventCalls = [];
|
|
57
|
+
seededSnapshots = [];
|
|
58
|
+
(globalThis as { __tokentopPersistence?: unknown }).__tokentopPersistence = undefined;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function makePersistData(overrides: {
|
|
62
|
+
agentId?: string;
|
|
63
|
+
sessionId?: string;
|
|
64
|
+
totalCostUsd?: number;
|
|
65
|
+
requestCount?: number;
|
|
66
|
+
totalInputTokens?: number;
|
|
67
|
+
totalOutputTokens?: number;
|
|
68
|
+
timestamp?: number;
|
|
69
|
+
}) {
|
|
70
|
+
const now = overrides.timestamp ?? Date.now();
|
|
71
|
+
return {
|
|
72
|
+
session: {
|
|
73
|
+
agentId: overrides.agentId ?? "claude-code",
|
|
74
|
+
sessionId: overrides.sessionId ?? "s1",
|
|
75
|
+
projectPath: null,
|
|
76
|
+
startedAt: null,
|
|
77
|
+
lastSeenAt: now,
|
|
78
|
+
},
|
|
79
|
+
snapshot: {
|
|
80
|
+
timestamp: now,
|
|
81
|
+
lastActivityAt: now,
|
|
82
|
+
status: null,
|
|
83
|
+
totalInputTokens: overrides.totalInputTokens ?? 1000,
|
|
84
|
+
totalOutputTokens: overrides.totalOutputTokens ?? 500,
|
|
85
|
+
totalCacheReadTokens: 0,
|
|
86
|
+
totalCacheWriteTokens: 0,
|
|
87
|
+
totalCostUsd: overrides.totalCostUsd ?? 1.5,
|
|
88
|
+
requestCount: overrides.requestCount ?? 10,
|
|
89
|
+
},
|
|
90
|
+
streams: [],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("seedPreviousTotals", () => {
|
|
95
|
+
test("skips persist for session whose fingerprint matches seeded DB snapshot", () => {
|
|
96
|
+
seededSnapshots = [
|
|
97
|
+
{
|
|
98
|
+
agentId: "claude-code",
|
|
99
|
+
sessionId: "s1",
|
|
100
|
+
timestamp: Date.now() - 60_000,
|
|
101
|
+
totalCostUsd: 1.5,
|
|
102
|
+
requestCount: 10,
|
|
103
|
+
totalInputTokens: 1000,
|
|
104
|
+
totalOutputTokens: 500,
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
seedPreviousTotals();
|
|
109
|
+
|
|
110
|
+
const persistedCount = persistSessions([
|
|
111
|
+
makePersistData({
|
|
112
|
+
agentId: "claude-code",
|
|
113
|
+
sessionId: "s1",
|
|
114
|
+
totalCostUsd: 1.5,
|
|
115
|
+
requestCount: 10,
|
|
116
|
+
totalInputTokens: 1000,
|
|
117
|
+
totalOutputTokens: 500,
|
|
118
|
+
}),
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
expect(persistedCount).toBe(0);
|
|
122
|
+
expect(upsertCalls).toHaveLength(0);
|
|
123
|
+
expect(snapshotCalls).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("persists session whose usage changed since seeded snapshot", () => {
|
|
127
|
+
seededSnapshots = [
|
|
128
|
+
{
|
|
129
|
+
agentId: "claude-code",
|
|
130
|
+
sessionId: "s1",
|
|
131
|
+
timestamp: Date.now() - 10 * 60 * 1000,
|
|
132
|
+
totalCostUsd: 1.5,
|
|
133
|
+
requestCount: 10,
|
|
134
|
+
totalInputTokens: 1000,
|
|
135
|
+
totalOutputTokens: 500,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
seedPreviousTotals();
|
|
140
|
+
|
|
141
|
+
const persistedCount = persistSessions([
|
|
142
|
+
makePersistData({
|
|
143
|
+
agentId: "claude-code",
|
|
144
|
+
sessionId: "s1",
|
|
145
|
+
totalCostUsd: 2.0,
|
|
146
|
+
requestCount: 12,
|
|
147
|
+
totalInputTokens: 1200,
|
|
148
|
+
totalOutputTokens: 600,
|
|
149
|
+
}),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
expect(persistedCount).toBe(1);
|
|
153
|
+
expect(upsertCalls).toHaveLength(1);
|
|
154
|
+
expect(snapshotCalls).toHaveLength(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("persists brand-new session not present in seed", () => {
|
|
158
|
+
seededSnapshots = [];
|
|
159
|
+
|
|
160
|
+
seedPreviousTotals();
|
|
161
|
+
|
|
162
|
+
const persistedCount = persistSessions([
|
|
163
|
+
makePersistData({ agentId: "claude-code", sessionId: "new-session" }),
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
expect(persistedCount).toBe(1);
|
|
167
|
+
expect(upsertCalls).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("skips thousands of unchanged sessions after seed (regression for startup freeze)", () => {
|
|
171
|
+
const baseTs = Date.now() - 60_000;
|
|
172
|
+
seededSnapshots = Array.from({ length: 5000 }, (_, i) => ({
|
|
173
|
+
agentId: "claude-code",
|
|
174
|
+
sessionId: `s${i}`,
|
|
175
|
+
timestamp: baseTs + i,
|
|
176
|
+
totalCostUsd: i * 0.01,
|
|
177
|
+
requestCount: i,
|
|
178
|
+
totalInputTokens: i * 100,
|
|
179
|
+
totalOutputTokens: i * 50,
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
seedPreviousTotals();
|
|
183
|
+
|
|
184
|
+
const persistData = seededSnapshots.map((s) =>
|
|
185
|
+
makePersistData({
|
|
186
|
+
agentId: s.agentId,
|
|
187
|
+
sessionId: s.sessionId,
|
|
188
|
+
totalCostUsd: s.totalCostUsd,
|
|
189
|
+
requestCount: s.requestCount,
|
|
190
|
+
totalInputTokens: s.totalInputTokens,
|
|
191
|
+
totalOutputTokens: s.totalOutputTokens,
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const persistedCount = persistSessions(persistData);
|
|
196
|
+
|
|
197
|
+
expect(persistedCount).toBe(0);
|
|
198
|
+
expect(upsertCalls).toHaveLength(0);
|
|
199
|
+
expect(snapshotCalls).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { isDatabaseInitialized } from "./db.ts";
|
|
17
17
|
import {
|
|
18
|
+
getLatestSnapshotsForAllSessions,
|
|
18
19
|
getLatestStreamTotalsForAllSessions,
|
|
19
20
|
insertAgentSessionSnapshot,
|
|
20
21
|
upsertAgentSession,
|
|
@@ -36,6 +37,12 @@ import { computeStreamDelta } from "./types.ts";
|
|
|
36
37
|
/** Minimum interval between DB writes for the same session */
|
|
37
38
|
const SNAPSHOT_INTERVAL_MS = 300_000; // 5 minutes
|
|
38
39
|
|
|
40
|
+
/** Remove tracking entries for sessions not written in this window */
|
|
41
|
+
const STALE_ENTRY_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
42
|
+
|
|
43
|
+
/** Run eviction at most this often */
|
|
44
|
+
const EVICTION_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
|
|
45
|
+
|
|
39
46
|
// ---------------------------------------------------------------------------
|
|
40
47
|
// Global state (survives bun --hot HMR and React remounts)
|
|
41
48
|
// ---------------------------------------------------------------------------
|
|
@@ -49,6 +56,8 @@ interface PersistenceState {
|
|
|
49
56
|
previousStreamTotals: Map<string, StreamTotals>;
|
|
50
57
|
/** Whether startup seeding has completed */
|
|
51
58
|
seeded: boolean;
|
|
59
|
+
/** Timestamp of last stale entry eviction run */
|
|
60
|
+
lastEvictionAt: number;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
declare global {
|
|
@@ -63,6 +72,7 @@ function getState(): PersistenceState {
|
|
|
63
72
|
sessionFingerprints: new Map(),
|
|
64
73
|
previousStreamTotals: new Map(),
|
|
65
74
|
seeded: false,
|
|
75
|
+
lastEvictionAt: 0,
|
|
66
76
|
};
|
|
67
77
|
}
|
|
68
78
|
return globalThis.__tokentopPersistence;
|
|
@@ -91,6 +101,27 @@ export interface SessionPersistData {
|
|
|
91
101
|
streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[];
|
|
92
102
|
}
|
|
93
103
|
|
|
104
|
+
function evictStaleEntries(state: PersistenceState, now: number): void {
|
|
105
|
+
if (now - state.lastEvictionAt < EVICTION_INTERVAL_MS) return;
|
|
106
|
+
state.lastEvictionAt = now;
|
|
107
|
+
|
|
108
|
+
const cutoff = now - STALE_ENTRY_MAX_AGE_MS;
|
|
109
|
+
for (const [key, ts] of state.lastWriteTimestamps) {
|
|
110
|
+
if (ts < cutoff) {
|
|
111
|
+
state.lastWriteTimestamps.delete(key);
|
|
112
|
+
state.sessionFingerprints.delete(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Evict stream totals whose parent session was evicted
|
|
117
|
+
for (const streamKey of state.previousStreamTotals.keys()) {
|
|
118
|
+
const sessionKey = streamKey.slice(0, streamKey.indexOf(":", streamKey.indexOf(":") + 1));
|
|
119
|
+
if (!state.lastWriteTimestamps.has(sessionKey)) {
|
|
120
|
+
state.previousStreamTotals.delete(streamKey);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
94
125
|
/**
|
|
95
126
|
* Persist session data to the database with per-session throttling.
|
|
96
127
|
*
|
|
@@ -105,6 +136,7 @@ export function persistSessions(sessions: SessionPersistData[]): number {
|
|
|
105
136
|
|
|
106
137
|
const state = getState();
|
|
107
138
|
const now = Date.now();
|
|
139
|
+
evictStaleEntries(state, now);
|
|
108
140
|
let persistedCount = 0;
|
|
109
141
|
|
|
110
142
|
for (const { session, snapshot, streams } of sessions) {
|
|
@@ -177,11 +209,6 @@ export function persistSessions(sessions: SessionPersistData[]): number {
|
|
|
177
209
|
return persistedCount;
|
|
178
210
|
}
|
|
179
211
|
|
|
180
|
-
/**
|
|
181
|
-
* Seed previous stream totals from the database.
|
|
182
|
-
* Called once at startup after DB initialization to ensure
|
|
183
|
-
* accurate delta computation from the first persistence call.
|
|
184
|
-
*/
|
|
185
212
|
export function seedPreviousTotals(): void {
|
|
186
213
|
const state = getState();
|
|
187
214
|
if (state.seeded) return;
|
|
@@ -199,6 +226,28 @@ export function seedPreviousTotals(): void {
|
|
|
199
226
|
requestCount: row.requestCount,
|
|
200
227
|
});
|
|
201
228
|
}
|
|
229
|
+
|
|
230
|
+
// Seeds Gate 1/2 so the first post-startup tick does not rewrite every
|
|
231
|
+
// historical session. computeFingerprint only reads cost/count/input/output,
|
|
232
|
+
// so the other fields are placeholders to satisfy the snapshot shape.
|
|
233
|
+
const latestSnapshots = getLatestSnapshotsForAllSessions();
|
|
234
|
+
for (const snap of latestSnapshots) {
|
|
235
|
+
const sessionKey = `${snap.agentId}:${snap.sessionId}`;
|
|
236
|
+
const fp = computeFingerprint({
|
|
237
|
+
timestamp: snap.timestamp,
|
|
238
|
+
lastActivityAt: snap.timestamp,
|
|
239
|
+
status: null,
|
|
240
|
+
totalInputTokens: snap.totalInputTokens,
|
|
241
|
+
totalOutputTokens: snap.totalOutputTokens,
|
|
242
|
+
totalCacheReadTokens: 0,
|
|
243
|
+
totalCacheWriteTokens: 0,
|
|
244
|
+
totalCostUsd: snap.totalCostUsd,
|
|
245
|
+
requestCount: snap.requestCount,
|
|
246
|
+
});
|
|
247
|
+
state.sessionFingerprints.set(sessionKey, fp);
|
|
248
|
+
state.lastWriteTimestamps.set(sessionKey, snap.timestamp);
|
|
249
|
+
}
|
|
250
|
+
|
|
202
251
|
state.seeded = true;
|
|
203
252
|
} catch (err) {
|
|
204
253
|
console.error("Failed to seed previous totals from DB:", err);
|
|
@@ -172,6 +172,55 @@ export interface LatestStreamTotals {
|
|
|
172
172
|
requestCount: number;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
export interface LatestSessionSnapshot {
|
|
176
|
+
agentId: string;
|
|
177
|
+
sessionId: string;
|
|
178
|
+
timestamp: number;
|
|
179
|
+
totalCostUsd: number;
|
|
180
|
+
requestCount: number;
|
|
181
|
+
totalInputTokens: number;
|
|
182
|
+
totalOutputTokens: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function getLatestSnapshotsForAllSessions(): LatestSessionSnapshot[] {
|
|
186
|
+
const db = getDatabase();
|
|
187
|
+
|
|
188
|
+
const rows = db
|
|
189
|
+
.prepare(`
|
|
190
|
+
SELECT
|
|
191
|
+
s.agent_id, s.session_id,
|
|
192
|
+
snap.timestamp,
|
|
193
|
+
snap.total_cost_usd, snap.request_count,
|
|
194
|
+
snap.total_input_tokens, snap.total_output_tokens
|
|
195
|
+
FROM agent_session_snapshots snap
|
|
196
|
+
JOIN agent_sessions s ON s.id = snap.agent_session_id
|
|
197
|
+
WHERE snap.id IN (
|
|
198
|
+
SELECT MAX(snap2.id)
|
|
199
|
+
FROM agent_session_snapshots snap2
|
|
200
|
+
GROUP BY snap2.agent_session_id
|
|
201
|
+
)
|
|
202
|
+
`)
|
|
203
|
+
.all() as Array<{
|
|
204
|
+
agent_id: string;
|
|
205
|
+
session_id: string;
|
|
206
|
+
timestamp: number;
|
|
207
|
+
total_cost_usd: number;
|
|
208
|
+
request_count: number;
|
|
209
|
+
total_input_tokens: number;
|
|
210
|
+
total_output_tokens: number;
|
|
211
|
+
}>;
|
|
212
|
+
|
|
213
|
+
return rows.map((r) => ({
|
|
214
|
+
agentId: r.agent_id,
|
|
215
|
+
sessionId: r.session_id,
|
|
216
|
+
timestamp: r.timestamp,
|
|
217
|
+
totalCostUsd: r.total_cost_usd,
|
|
218
|
+
requestCount: r.request_count,
|
|
219
|
+
totalInputTokens: r.total_input_tokens,
|
|
220
|
+
totalOutputTokens: r.total_output_tokens,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
175
224
|
export function getLatestStreamTotalsForAllSessions(): LatestStreamTotals[] {
|
|
176
225
|
const db = getDatabase();
|
|
177
226
|
|
package/src/tui/App.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { copyToClipboard } from "@/utils/clipboard.ts";
|
|
|
16
16
|
import { type CommandAction, CommandPalette } from "./components/CommandPalette.tsx";
|
|
17
17
|
import { DebugPanel } from "./components/DebugPanel.tsx";
|
|
18
18
|
import { Header } from "./components/Header.tsx";
|
|
19
|
+
import { NotificationFlash } from "./components/NotificationFlash.tsx";
|
|
19
20
|
import { SessionDetailsDrawer } from "./components/SessionDetailsDrawer.tsx";
|
|
20
21
|
import { SettingsModal } from "./components/SettingsModal.tsx";
|
|
21
22
|
import { StatusBar } from "./components/StatusBar.tsx";
|
|
@@ -35,6 +36,7 @@ import { RealTimeActivityProvider } from "./contexts/RealTimeActivityContext.tsx
|
|
|
35
36
|
import { StorageProvider } from "./contexts/StorageContext.tsx";
|
|
36
37
|
import { TimeWindowProvider } from "./contexts/TimeWindowContext.tsx";
|
|
37
38
|
import { ToastProvider, useToastContext } from "./contexts/ToastContext.tsx";
|
|
39
|
+
import { useNotificationBridge } from "./hooks/useNotificationBridge.ts";
|
|
38
40
|
import { useSafeRenderer } from "./hooks/useSafeRenderer.ts";
|
|
39
41
|
import { triggerShutdown } from "./shutdown.ts";
|
|
40
42
|
import { Dashboard } from "./views/Dashboard.tsx";
|
|
@@ -68,6 +70,8 @@ function AppContent() {
|
|
|
68
70
|
const { debugDataRef, activity, sparkData } = useDashboardRuntime();
|
|
69
71
|
const burstRecorderRef = { current: null as BurstRecorder | null };
|
|
70
72
|
|
|
73
|
+
useNotificationBridge();
|
|
74
|
+
|
|
71
75
|
const refreshInterval = config.refresh.pauseAutoRefresh ? 0 : config.refresh.intervalMs;
|
|
72
76
|
|
|
73
77
|
const [lastRefresh, setLastRefresh] = useState<number | null>(null);
|
|
@@ -241,12 +245,12 @@ function AppContent() {
|
|
|
241
245
|
]);
|
|
242
246
|
|
|
243
247
|
useKeyboard((key) => {
|
|
244
|
-
if (key.ctrl && key.name === "p") {
|
|
245
|
-
|
|
248
|
+
if ((key.ctrl && key.shift && key.name === "p") || (key.shift && key.name === "p")) {
|
|
249
|
+
handleBurstRecord();
|
|
246
250
|
return;
|
|
247
251
|
}
|
|
248
|
-
if (key.ctrl && key.
|
|
249
|
-
|
|
252
|
+
if (key.ctrl && key.name === "p") {
|
|
253
|
+
handleCaptureFrame();
|
|
250
254
|
return;
|
|
251
255
|
}
|
|
252
256
|
|
|
@@ -363,6 +367,8 @@ function AppContent() {
|
|
|
363
367
|
{isDrawerOpen && selectedSession && (
|
|
364
368
|
<SessionDetailsDrawer session={selectedSession} onClose={hideDrawer} />
|
|
365
369
|
)}
|
|
370
|
+
|
|
371
|
+
<NotificationFlash />
|
|
366
372
|
</box>
|
|
367
373
|
);
|
|
368
374
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { NotificationEvent } from "@tokentop/plugin-sdk";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
4
|
+
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
5
|
+
import { animationTick } from "../hooks/useAnimationTick.ts";
|
|
6
|
+
import { Z_INDEX } from "./ModalBackdrop.tsx";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Notification flash as an animated border glow.
|
|
10
|
+
*
|
|
11
|
+
* On notification:
|
|
12
|
+
* 0–15% Overbright burst → alert color (double border)
|
|
13
|
+
* 15–40% Alert color fading (double border)
|
|
14
|
+
* 40–100% Continuing fade → background (single border, then unmount)
|
|
15
|
+
*
|
|
16
|
+
* Uses the global 30 Hz animation tick for smooth interpolation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const GLOW_DURATION_MS = 500;
|
|
20
|
+
|
|
21
|
+
/** Bright, saturated alert colors — vivid regardless of theme */
|
|
22
|
+
const GLOW_COLORS: Record<NotificationEvent["severity"], string> = {
|
|
23
|
+
critical: "#ff3333",
|
|
24
|
+
warning: "#ffaa22",
|
|
25
|
+
info: "#3399ff",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ── Color math ──────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function parseHex(hex: string): [number, number, number] {
|
|
31
|
+
const h = hex.replace("#", "");
|
|
32
|
+
return [
|
|
33
|
+
Number.parseInt(h.slice(0, 2), 16),
|
|
34
|
+
Number.parseInt(h.slice(2, 4), 16),
|
|
35
|
+
Number.parseInt(h.slice(4, 6), 16),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toHex(r: number, g: number, b: number): string {
|
|
40
|
+
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
|
41
|
+
return `#${[r, g, b].map((v) => clamp(v).toString(16).padStart(2, "0")).join("")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Linearly interpolate two hex colors. t=0 → from, t=1 → to. */
|
|
45
|
+
function lerpColor(from: string, to: string, t: number): string {
|
|
46
|
+
const [r1, g1, b1] = parseHex(from);
|
|
47
|
+
const [r2, g2, b2] = parseHex(to);
|
|
48
|
+
return toHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Brighten a color by a factor (>1 = brighter). Clamps at 255. */
|
|
52
|
+
function brighten(hex: string, factor = 1.4): string {
|
|
53
|
+
const [r, g, b] = parseHex(hex);
|
|
54
|
+
return toHex(r * factor, g * factor, b * factor);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** easeOutCubic — fast attack, gentle tail */
|
|
58
|
+
function easeOutCubic(t: number): number {
|
|
59
|
+
return 1 - (1 - t) ** 3;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Component ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export function NotificationFlash() {
|
|
65
|
+
const colors = useColors();
|
|
66
|
+
const [flash, setFlash] = useState<{
|
|
67
|
+
severity: NotificationEvent["severity"];
|
|
68
|
+
startTime: number;
|
|
69
|
+
} | null>(null);
|
|
70
|
+
const [borderColor, setBorderColor] = useState<string | null>(null);
|
|
71
|
+
const [borderStyle, setBorderStyle] = useState<"double" | "single">("double");
|
|
72
|
+
const unsubRef = useRef<(() => void) | null>(null);
|
|
73
|
+
|
|
74
|
+
// Register flash handler on the notification bus
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
notificationBus.setFlashHandler((severity) => {
|
|
77
|
+
setFlash({ severity, startTime: Date.now() });
|
|
78
|
+
});
|
|
79
|
+
return () => notificationBus.setFlashHandler(null);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
// Drive the animation via the global 30 Hz tick
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!flash) {
|
|
85
|
+
setBorderColor(null);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
unsubRef.current?.();
|
|
90
|
+
|
|
91
|
+
const alertColor = GLOW_COLORS[flash.severity];
|
|
92
|
+
const brightColor = brighten(alertColor);
|
|
93
|
+
const bgColor = colors.background;
|
|
94
|
+
|
|
95
|
+
unsubRef.current = animationTick.subscribe((now) => {
|
|
96
|
+
const elapsed = now - flash.startTime;
|
|
97
|
+
const progress = Math.min(elapsed / GLOW_DURATION_MS, 1);
|
|
98
|
+
|
|
99
|
+
let currentColor: string;
|
|
100
|
+
if (progress < 0.15) {
|
|
101
|
+
// Burst phase: overbright → alert color
|
|
102
|
+
currentColor = lerpColor(brightColor, alertColor, progress / 0.15);
|
|
103
|
+
} else {
|
|
104
|
+
// Fade phase: alert → background (eased for gentle tail)
|
|
105
|
+
const fadeT = (progress - 0.15) / 0.85;
|
|
106
|
+
currentColor = lerpColor(alertColor, bgColor, easeOutCubic(fadeT));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setBorderColor(currentColor);
|
|
110
|
+
setBorderStyle(progress < 0.4 ? "double" : "single");
|
|
111
|
+
|
|
112
|
+
if (progress >= 1) {
|
|
113
|
+
unsubRef.current?.();
|
|
114
|
+
unsubRef.current = null;
|
|
115
|
+
setFlash(null);
|
|
116
|
+
setBorderColor(null);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
unsubRef.current?.();
|
|
122
|
+
unsubRef.current = null;
|
|
123
|
+
};
|
|
124
|
+
}, [flash, colors.background]);
|
|
125
|
+
|
|
126
|
+
if (!borderColor) return null;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<box
|
|
130
|
+
position="absolute"
|
|
131
|
+
left={0}
|
|
132
|
+
top={0}
|
|
133
|
+
width="100%"
|
|
134
|
+
height="100%"
|
|
135
|
+
zIndex={Z_INDEX.TOAST + 1}
|
|
136
|
+
border
|
|
137
|
+
borderStyle={borderStyle}
|
|
138
|
+
borderColor={borderColor}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -158,6 +158,16 @@ export function SessionDetailsDrawer({ session, onClose: _onClose }: SessionDeta
|
|
|
158
158
|
};
|
|
159
159
|
}, [session.streams]);
|
|
160
160
|
|
|
161
|
+
const hasAnyLongContext = session.streams.some((s) => s.hasLongContext);
|
|
162
|
+
const totalLongContextRequests = session.streams.reduce(
|
|
163
|
+
(sum, s) => sum + (s.longContextRequestCount ?? 0),
|
|
164
|
+
0,
|
|
165
|
+
);
|
|
166
|
+
const totalLongContextTokens = session.streams.reduce((sum, s) => {
|
|
167
|
+
if (!s.longContextTokens) return sum;
|
|
168
|
+
return sum + s.longContextTokens.input + s.longContextTokens.output;
|
|
169
|
+
}, 0);
|
|
170
|
+
|
|
161
171
|
const isMultiDay = session.lastActivityAt - session.startedAt > 24 * 60 * 60 * 1000;
|
|
162
172
|
const startTimeStr = formatTimelineLabel(session.startedAt, isMultiDay);
|
|
163
173
|
const endTimeStr = formatTimelineLabel(session.lastActivityAt, isMultiDay);
|
|
@@ -338,6 +348,18 @@ export function SessionDetailsDrawer({ session, onClose: _onClose }: SessionDeta
|
|
|
338
348
|
</text>
|
|
339
349
|
</box>
|
|
340
350
|
)}
|
|
351
|
+
|
|
352
|
+
{hasAnyLongContext && (
|
|
353
|
+
<box flexDirection="row" height={1} marginBottom={0}>
|
|
354
|
+
<text height={1} overflow="hidden">
|
|
355
|
+
<span fg={colors.warning}>↑ Long ctx: </span>
|
|
356
|
+
<span fg={colors.text}>{totalLongContextRequests} reqs</span>
|
|
357
|
+
<span fg={colors.textMuted}> · </span>
|
|
358
|
+
<span fg={colors.text}>{formatTokens(totalLongContextTokens)} tokens</span>
|
|
359
|
+
<span fg={colors.textMuted}> at tiered rate</span>
|
|
360
|
+
</text>
|
|
361
|
+
</box>
|
|
362
|
+
)}
|
|
341
363
|
</box>
|
|
342
364
|
|
|
343
365
|
<box
|
|
@@ -407,11 +429,17 @@ export function SessionDetailsDrawer({ session, onClose: _onClose }: SessionDeta
|
|
|
407
429
|
</text>
|
|
408
430
|
<box width={10} height={1} justifyContent="flex-end">
|
|
409
431
|
<text fg={colors.textMuted}>
|
|
410
|
-
{formatTokens(
|
|
432
|
+
{formatTokens(
|
|
433
|
+
stream.tokens.input +
|
|
434
|
+
stream.tokens.output +
|
|
435
|
+
(stream.longContextTokens?.input ?? 0) +
|
|
436
|
+
(stream.longContextTokens?.output ?? 0),
|
|
437
|
+
)}
|
|
411
438
|
</text>
|
|
412
439
|
</box>
|
|
413
440
|
<box width={10} height={1} justifyContent="flex-end">
|
|
414
441
|
<text fg={colors.success}>{formatCost(stream.costUsd)}</text>
|
|
442
|
+
<text fg={colors.textMuted}>{stream.hasLongContext ? "↑" : " "}</text>
|
|
415
443
|
</box>
|
|
416
444
|
</box>
|
|
417
445
|
);
|