+
{formatExact(hoveredDay.stats.totalTokens)} tokens
diff --git a/src/client/index.tsx b/src/client/index.tsx
index 8fc7aee..95da3cc 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -53,52 +53,78 @@ export function apply(ctx: any) {
root.render();
- // 3. Observe DOM for in-session token usage dialog to attach "View Heatmap" button
+ // 3. Observe DOM for in-session token usage dialog: strictly single button
const observer = new MutationObserver(() => {
- // Find the open session stats dialog
- const statDialogs = document.querySelectorAll(
- 'div[role="dialog"][aria-label*="usage" i], div[role="dialog"][aria-label*="Token" i], [data-session-stats-usage]'
- );
+ // Find the dl with data-session-stats-usage
+ const usageList = document.querySelector('[data-session-stats-usage]');
+ if (!usageList) return;
- statDialogs.forEach((dialog) => {
- if (dialog.querySelector(".dsh-heatmap-trigger-btn")) return;
+ const dialogContainer = usageList.closest('div[role="dialog"]') || usageList.parentElement;
+ if (!dialogContainer) return;
- const button = document.createElement("button");
- button.className = "dsh-heatmap-trigger-btn";
- button.type = "button";
- button.innerHTML = "View 12-Month Activity Heatmap →";
- button.style.cssText = `
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- margin-top: 12px;
- padding: 8px 12px;
- background: var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12));
- border: 1px solid var(--dsw-alias-border-l2, #494949);
- border-radius: 8px;
- color: var(--dsw-alias-brand-primary, #6366f1);
- font-size: 12px;
- font-weight: 550;
- cursor: pointer;
- transition: background 0.15s ease, border-color 0.15s ease;
- `;
- button.onmouseenter = () => {
- button.style.background = "var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.2))";
- button.style.borderColor = "var(--dsw-alias-brand-primary, #6366f1)";
- };
- button.onmouseleave = () => {
- button.style.background = "var(--dsw-alias-interactive-bg-hover, rgba(128, 128, 128, 0.12))";
- button.style.borderColor = "var(--dsw-alias-border-l2, #494949)";
- };
- button.onclick = (e) => {
- e.preventDefault();
- e.stopPropagation();
+ // Strictly ensure only one button is ever attached
+ if (dialogContainer.querySelector(".dsh-heatmap-trigger-btn") || document.querySelector(".dsh-heatmap-trigger-btn")) {
+ return;
+ }
+
+ const button = document.createElement("button");
+ button.className = "dsh-heatmap-trigger-btn";
+ button.type = "button";
+ button.innerHTML = "View Token Usage Heatmap →";
+ button.style.cssText = `
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ margin-top: 12px;
+ padding: 8px 12px;
+ background: rgba(79, 70, 229, 0.12);
+ border: 1px solid rgba(79, 70, 229, 0.35);
+ border-radius: 8px;
+ color: var(--dsw-alias-brand-primary, #6366f1);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease;
+ `;
+ button.onmouseenter = () => {
+ button.style.background = "rgba(79, 70, 229, 0.22)";
+ button.style.borderColor = "var(--dsw-alias-brand-primary, #6366f1)";
+ };
+ button.onmouseleave = () => {
+ button.style.background = "rgba(79, 70, 229, 0.12)";
+ button.style.borderColor = "rgba(79, 70, 229, 0.35)";
+ };
+ button.onclick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Try navigating to Settings -> Token Heatmap
+ const settingsTrigger = document.querySelector(
+ 'button[aria-label*="Setting" i], [data-sidebar-settings] button, button.settings'
+ );
+
+ if (settingsTrigger) {
+ (settingsTrigger as HTMLElement).click();
+ setTimeout(() => {
+ const heatmapTab = Array.from(document.querySelectorAll("*")).find(
+ (el) =>
+ el.textContent?.trim() === "Token Heatmap" &&
+ (el.tagName === "BUTTON" || el.tagName === "DIV" || el.getAttribute("role") === "tab" || el.classList.contains("ant-list-item"))
+ );
+ if (heatmapTab) {
+ (heatmapTab as HTMLElement).click();
+ } else {
+ window.dispatchEvent(new CustomEvent("dsh:open-token-heatmap"));
+ }
+ }, 120);
+ } else {
+ // Open direct interactive modal
window.dispatchEvent(new CustomEvent("dsh:open-token-heatmap"));
- };
+ }
+ };
- dialog.appendChild(button);
- });
+ dialogContainer.appendChild(button);
});
observer.observe(document.body, { childList: true, subtree: true });
diff --git a/src/server/aggregator.ts b/src/server/aggregator.ts
index 54a6e63..dacdc9a 100644
--- a/src/server/aggregator.ts
+++ b/src/server/aggregator.ts
@@ -13,6 +13,18 @@ export interface DayStats {
level?: number; // 0..4
}
+export interface SessionSummary {
+ id: string;
+ title: string;
+ createdAt: number;
+ totalTokens: number;
+ inputTokens: number;
+ outputTokens: number;
+ cacheReadTokens: number;
+ turns: number;
+ days: Record;
+}
+
export interface SummaryStats {
totalTokens: number;
totalInputTokens: number;
@@ -29,13 +41,14 @@ export interface SummaryStats {
export interface HeatmapResponse {
summary: SummaryStats;
days: Record;
+ sessions: SessionSummary[];
startDate: string;
endDate: string;
}
interface CacheEntry {
mtimeMs: number;
- days: Record;
+ session: SessionSummary;
}
const fileCache = new Map();
@@ -58,53 +71,87 @@ function findSessionFiles(dir: string): string[] {
return results;
}
-async function parseSessionFile(filePath: string): Promise> {
+async function parseSessionFile(filePath: string): Promise {
try {
const stat = fs.statSync(filePath);
const cached = fileCache.get(filePath);
if (cached && cached.mtimeMs === stat.mtimeMs) {
- return cached.days;
+ return cached.session;
}
const isZstd = filePath.endsWith(".zstd");
const days: Record = {};
+ let sessionId = path.basename(path.dirname(filePath));
+ let title = "";
+ let createdAt = stat.birthtimeMs || stat.mtimeMs;
+ let totalTokens = 0;
+ let totalInputTokens = 0;
+ let totalOutputTokens = 0;
+ let totalCacheReadTokens = 0;
+ let totalTurns = 0;
await new Promise((resolve) => {
let buffer = "";
const handleLine = (line: string) => {
- if (!line.includes('"usage"')) return;
- try {
- const obj = JSON.parse(line);
- const time = obj.time || (obj.data && obj.data.time);
- const usage = obj.usage || (obj.data && obj.data.usage);
- if (time && usage) {
- const d = new Date(time);
- const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
- const input = Number(usage.inputTokens) || 0;
- const output = Number(usage.outputTokens) || 0;
- const cacheRead = Number(usage.cacheReadTokens) || 0;
- const cacheWrite = Number(usage.cacheWriteTokens) || 0;
- const total = input + output + cacheRead;
+ if (line.includes('"type":"session"')) {
+ try {
+ const obj = JSON.parse(line);
+ if (obj.id) sessionId = obj.id;
+ if (obj.createdAt) createdAt = obj.createdAt;
+ } catch {}
+ }
+ if (!title && line.includes('"session/title"')) {
+ try {
+ const obj = JSON.parse(line);
+ if (obj.data?.title) title = obj.data.title;
+ } catch {}
+ }
+ if (!title && line.includes('"subagent/descriptor"')) {
+ try {
+ const obj = JSON.parse(line);
+ if (obj.data?.label) title = obj.data.label;
+ } catch {}
+ }
+ if (line.includes('"usage"')) {
+ try {
+ const obj = JSON.parse(line);
+ const time = obj.time || (obj.data && obj.data.time);
+ const usage = obj.usage || (obj.data && obj.data.usage);
+ if (time && usage) {
+ const d = new Date(time);
+ const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+ const input = Number(usage.inputTokens) || 0;
+ const output = Number(usage.outputTokens) || 0;
+ const cacheRead = Number(usage.cacheReadTokens) || 0;
+ const cacheWrite = Number(usage.cacheWriteTokens) || 0;
+ const total = input + output + cacheRead;
- if (!days[dateKey]) {
- days[dateKey] = {
- totalTokens: 0,
- inputTokens: 0,
- outputTokens: 0,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- turns: 0,
- };
+ if (!days[dateKey]) {
+ days[dateKey] = {
+ totalTokens: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ turns: 0,
+ };
+ }
+ days[dateKey].totalTokens += total;
+ days[dateKey].inputTokens += input;
+ days[dateKey].outputTokens += output;
+ days[dateKey].cacheReadTokens += cacheRead;
+ days[dateKey].cacheWriteTokens += cacheWrite;
+ days[dateKey].turns += 1;
+
+ totalTokens += total;
+ totalInputTokens += input;
+ totalOutputTokens += output;
+ totalCacheReadTokens += cacheRead;
+ totalTurns += 1;
}
- days[dateKey].totalTokens += total;
- days[dateKey].inputTokens += input;
- days[dateKey].outputTokens += output;
- days[dateKey].cacheReadTokens += cacheRead;
- days[dateKey].cacheWriteTokens += cacheWrite;
- days[dateKey].turns += 1;
+ } catch {
+ // Ignore malformed lines
}
- } catch {
- // Ignore malformed lines
}
};
@@ -143,22 +190,37 @@ async function parseSessionFile(filePath: string): Promise {
const sessionsRoot = customSessionsRoot || path.join(os.homedir(), ".dsh", "sessions");
const files = findSessionFiles(sessionsRoot);
- const parsedResults = await Promise.all(files.map(parseSessionFile));
+ const parsedSessions = (await Promise.all(files.map(parseSessionFile))).filter(Boolean) as SessionSummary[];
+
+ // Sort sessions newest first
+ parsedSessions.sort((a, b) => b.createdAt - a.createdAt);
const days: Record = {};
- for (const fileDays of parsedResults) {
- for (const [date, stats] of Object.entries(fileDays)) {
+ for (const session of parsedSessions) {
+ for (const [date, stats] of Object.entries(session.days)) {
if (!days[date]) {
days[date] = {
totalTokens: 0,
@@ -178,7 +240,7 @@ export async function aggregateTokens(customSessionsRoot?: string): Promise d.totalTokens)
.filter((t) => t > 0)
@@ -187,13 +249,11 @@ export async function aggregateTokens(customSessionsRoot?: string): Promise= 4) {
q1 = positiveValues[Math.floor(positiveValues.length * 0.25)];
q2 = positiveValues[Math.floor(positiveValues.length * 0.50)];
q3 = positiveValues[Math.floor(positiveValues.length * 0.75)];
- q4 = positiveValues[Math.floor(positiveValues.length * 0.90)];
}
for (const day of Object.values(days)) {
@@ -238,7 +298,6 @@ export async function aggregateTokens(customSessionsRoot?: string): Promise 0 ? new Date(now) : (days[yesterdayKey]?.totalTokens ?? 0) > 0 ? new Date(cursor) : null;
if (streakDate) {
@@ -280,7 +339,7 @@ export async function aggregateTokens(customSessionsRoot?: string): Promise