Skip to content

Commit ace49cb

Browse files
Dennisclaude
andcommitted
feat: aggregate live mode for --day/--week/--month --live
Combines period flags with --live to show a live-updating aggregate dashboard of all sessions in the period. Watches the active session file and re-renders the full aggregate on each change. Completed sessions are cached to keep refreshes fast. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f6b1446 commit ace49cb

3 files changed

Lines changed: 250 additions & 6 deletions

File tree

src/formatter.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,134 @@ export function formatAggregate(analyses: SessionAnalysis[], label: string): str
471471
return lines.join('\n');
472472
}
473473

474+
// ── Aggregate live mode ──
475+
476+
export function formatAggregateLive(analyses: SessionAnalysis[], label: string): string {
477+
const lines: string[] = [];
478+
479+
lines.push('');
480+
lines.push(` ${chalk.bold('cctime')} \u00b7 ${chalk.bgRed.white.bold(' LIVE ')}${chalk.gray(' \u00b7 ' + label)}`);
481+
lines.push(` ${analyses.length} sessions`);
482+
483+
// Aggregate enhanced stats
484+
const totalEnhanced: EnhancedStats = { humanWait: 0, humanAway: 0, claudeThink: 0, toolExec: 0, subagent: 0, planning: 0 };
485+
let totalDuration = 0;
486+
let totalTokensIn = 0, totalTokensOut = 0;
487+
let totalCost = 0;
488+
const allModels: Record<string, number> = {};
489+
const allTools: Record<string, number> = {};
490+
const allToolLatencies = new Map<string, { totalMs: number; count: number }>();
491+
492+
for (const a of analyses) {
493+
totalDuration += a.durationMs;
494+
for (const phase of activePhaseOrder) {
495+
totalEnhanced[phase] += a.enhancedStats[phase];
496+
}
497+
totalEnhanced.humanAway += a.enhancedStats.humanAway;
498+
totalTokensIn += a.tokens.input + a.tokens.cacheRead + a.tokens.cacheCreation;
499+
totalTokensOut += a.tokens.output;
500+
totalCost += a.estimatedCostUsd;
501+
for (const [m, c] of Object.entries(a.models)) allModels[m] = (allModels[m] || 0) + c;
502+
for (const [t, c] of Object.entries(a.tools)) allTools[t] = (allTools[t] || 0) + c;
503+
for (const tl of a.toolLatencies) {
504+
const existing = allToolLatencies.get(tl.name) || { totalMs: 0, count: 0 };
505+
existing.totalMs += tl.totalMs;
506+
existing.count += tl.count;
507+
allToolLatencies.set(tl.name, existing);
508+
}
509+
}
510+
511+
const totalActive = Math.max(0, totalDuration - totalEnhanced.humanAway);
512+
513+
// Time breakdown
514+
lines.push('');
515+
lines.push(hr('Time Breakdown'));
516+
const awayAgg = totalEnhanced.humanAway > 0 ? chalk.gray(` (${formatDuration(totalEnhanced.humanAway)} away)`) : '';
517+
lines.push(` ${chalk.bold(formatDuration(totalActive))} active${chalk.gray(' of ' + formatDuration(totalDuration))}${awayAgg}`);
518+
lines.push('');
519+
520+
for (const phase of activePhaseOrder) {
521+
const ms = totalEnhanced[phase];
522+
if (ms === 0) continue;
523+
const fraction = totalActive > 0 ? ms / totalActive : 0;
524+
const pct = Math.round(fraction * 100);
525+
const bar = renderBar(fraction, eColors[phase]);
526+
const pLabel = eLabels[phase].padEnd(16);
527+
lines.push(` ${pLabel}${bar} ${formatDuration(ms).padStart(5)} (${String(pct).padStart(2)}%)`);
528+
}
529+
530+
// Tokens & Cost
531+
lines.push('');
532+
lines.push(hr('Tokens & Cost'));
533+
534+
const aggTotalTok = totalTokensIn + totalTokensOut;
535+
if (aggTotalTok > 0) {
536+
const inFrac = totalTokensIn / aggTotalTok;
537+
const inBar = Math.round(inFrac * BAR_WIDTH);
538+
const outBar = BAR_WIDTH - inBar;
539+
lines.push(` Tokens ${chalk.cyan('\u2588'.repeat(inBar))}${chalk.green('\u2588'.repeat(outBar))} ${chalk.cyan(formatTokens(totalTokensIn) + ' in')} ${chalk.green(formatTokens(totalTokensOut) + ' out')}`);
540+
}
541+
const avgCost = analyses.length > 0 ? totalCost / analyses.length : 0;
542+
lines.push(` Cost ~${formatCost(totalCost)} (${analyses.length} sessions, avg ${formatCost(avgCost)}/session)`);
543+
544+
// Models
545+
const modelEntries = Object.entries(allModels).sort((a, b) => b[1] - a[1]);
546+
if (modelEntries.length > 0) {
547+
lines.push('');
548+
lines.push(hr('Models'));
549+
const totalModelCalls = modelEntries.reduce((s, [, n]) => s + n, 0);
550+
for (const [name, count] of modelEntries) {
551+
const frac = count / totalModelCalls;
552+
const pct = Math.round(frac * 100);
553+
const bar = renderBar(frac, name === 'Opus' ? chalk.magenta : name === 'Haiku' ? chalk.green : chalk.blue);
554+
lines.push(` ${name.padEnd(8)} ${bar} ${String(pct).padStart(3)}% (${count} calls)`);
555+
}
556+
}
557+
558+
// Tools
559+
const toolEntries = Object.entries(allTools).sort((a, b) => b[1] - a[1]).slice(0, 8);
560+
if (toolEntries.length > 0) {
561+
lines.push('');
562+
lines.push(hr('Tools'));
563+
const maxCalls = toolEntries[0][1];
564+
for (const [name, count] of toolEntries) {
565+
const frac = count / maxCalls;
566+
const bar = renderBar(frac, chalk.yellow);
567+
const latData = allToolLatencies.get(name);
568+
const latStr = latData && latData.count > 0
569+
? chalk.gray(` avg ${formatLatency(latData.totalMs / latData.count)}`)
570+
: '';
571+
lines.push(` ${truncName(name)} ${bar} ${String(count).padStart(4)} calls${latStr}`);
572+
}
573+
}
574+
575+
// Activity heatmap
576+
lines.push('');
577+
lines.push(hr('Activity'));
578+
579+
const hourlyMs = new Array(24).fill(0);
580+
for (const a of analyses) {
581+
const startHour = new Date(a.startTime).getHours();
582+
const aMs = Math.max(0, a.durationMs - a.enhancedStats.humanAway);
583+
hourlyMs[startHour] += aMs;
584+
}
585+
const maxHourMs = Math.max(...hourlyMs, 1);
586+
const sparkChars = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
587+
const heatmap = hourlyMs.map(ms => {
588+
if (ms === 0) return chalk.gray('\u2581');
589+
const idx = Math.round((ms / maxHourMs) * (sparkChars.length - 1));
590+
return chalk.cyan(sparkChars[idx]);
591+
}).join('');
592+
lines.push(` ${heatmap}`);
593+
lines.push(chalk.gray(' 12a 3a 6a 9a 12p 3p 6p 9p'));
594+
595+
// Footer
596+
lines.push('');
597+
lines.push(chalk.gray(' Ctrl+C to exit'));
598+
lines.push('');
599+
return lines.join('\n');
600+
}
601+
474602
// ── Compact view (one line per session) ──
475603

476604
export function formatCompact(analyses: SessionAnalysis[]): string {

src/index.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getLastSession, getTodaySessions, getWeekSessions, getMonthSessions, ge
77
import { parseSession } from './parser.js';
88
import { analyzeSession } from './analyzer.js';
99
import { formatSession, formatAggregate, formatCompact, formatCsv, formatMarkdown, formatJsonAggregate } from './formatter.js';
10-
import { startLiveMode } from './live.js';
10+
import { startLiveMode, startAggregateLiveMode } from './live.js';
1111
import type { SessionIndexEntry, SessionAnalysis } from './types.js';
1212

1313
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -146,8 +146,8 @@ program
146146
if (opts.color === true) process.env.FORCE_COLOR = '1';
147147

148148
try {
149-
// Live mode
150-
if (opts.live) {
149+
// Live mode — single session (no period flag)
150+
if (opts.live && !opts.day && !opts.all && !opts.week && !opts.month && !opts.since) {
151151
await startLiveMode(opts.project);
152152
return;
153153
}
@@ -209,6 +209,24 @@ program
209209
return;
210210
}
211211

212+
// Aggregate live mode — live-updating view of the period
213+
if (opts.live) {
214+
let getEntries: () => Promise<SessionIndexEntry[]>;
215+
if (opts.since) {
216+
const sinceMs = parseDate(opts.since);
217+
const untilMs = opts.until ? parseDate(opts.until) : undefined;
218+
getEntries = () => getSessionsSince(sinceMs, opts.project, untilMs);
219+
} else if (opts.month) {
220+
getEntries = () => getMonthSessions(opts.project);
221+
} else if (opts.week) {
222+
getEntries = () => getWeekSessions(opts.project);
223+
} else {
224+
getEntries = () => getTodaySessions(opts.project);
225+
}
226+
await startAggregateLiveMode(getEntries, label, opts.project);
227+
return;
228+
}
229+
212230
if (entries.length === 0) {
213231
console.error('No sessions found matching your filters.');
214232
console.error('Try running Claude Code first, or adjust --since/--project filters.');

src/live.ts

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { watch, type FSWatcher } from 'node:fs';
22
import { readdir, stat } from 'node:fs/promises';
33
import { homedir } from 'node:os';
44
import { join } from 'node:path';
5-
import { parseSessionFrom } from './parser.js';
5+
import { parseSession, parseSessionFrom } from './parser.js';
66
import { analyzeSession } from './analyzer.js';
7-
import { formatSessionLive } from './formatter.js';
8-
import type { SessionMessage } from './types.js';
7+
import { formatSessionLive, formatAggregateLive } from './formatter.js';
8+
import type { SessionMessage, SessionAnalysis, SessionIndexEntry } from './types.js';
99

1010
const ACTIVE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
1111
const DEBOUNCE_MS = 300;
@@ -150,3 +150,101 @@ export async function startLiveMode(projectFilter?: string): Promise<void> {
150150
process.on('SIGINT', cleanup);
151151
process.on('SIGTERM', cleanup);
152152
}
153+
154+
export async function startAggregateLiveMode(
155+
getEntries: () => Promise<SessionIndexEntry[]>,
156+
label: string,
157+
projectFilter?: string,
158+
): Promise<void> {
159+
// Find the active session to watch for changes
160+
const found = await findActiveSession(projectFilter);
161+
if (!found) {
162+
console.error('No active session found (modified within last 5 minutes).');
163+
console.error('Start a Claude Code session, then run cctime --live in another terminal.');
164+
process.exit(1);
165+
}
166+
const active = found;
167+
168+
let activeMessages: SessionMessage[] = [];
169+
let byteOffset = 0;
170+
let watcher: FSWatcher | null = null;
171+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
172+
let isRefreshing = false;
173+
174+
// Cache completed session analyses (keyed by sessionId)
175+
const completedCache = new Map<string, SessionAnalysis>();
176+
177+
async function refresh() {
178+
if (isRefreshing) return;
179+
isRefreshing = true;
180+
try {
181+
// Re-fetch entry list (picks up new sessions started during the day)
182+
const entries = await getEntries();
183+
184+
// Parse + analyze all non-active sessions (use cache for completed ones)
185+
const analyses: SessionAnalysis[] = [];
186+
for (const entry of entries) {
187+
if (entry.sessionId === active.sessionId) continue;
188+
const cached = completedCache.get(entry.sessionId);
189+
if (cached) {
190+
analyses.push(cached);
191+
} else {
192+
const messages = await parseSession(entry.fullPath);
193+
const analysis = analyzeSession(entry.sessionId, messages);
194+
if (analysis.summary === 'Untitled session' && entry.summary) {
195+
analysis.summary = entry.summary;
196+
}
197+
completedCache.set(entry.sessionId, analysis);
198+
analyses.push(analysis);
199+
}
200+
}
201+
202+
// Incrementally parse the active session
203+
const { messages: newMessages, bytesRead } = await parseSessionFrom(active.fullPath, byteOffset);
204+
if (newMessages.length > 0) {
205+
activeMessages.push(...newMessages);
206+
}
207+
byteOffset = bytesRead;
208+
209+
const activeAnalysis = analyzeSession(active.sessionId, activeMessages);
210+
analyses.push(activeAnalysis);
211+
212+
// Clear screen and redraw
213+
process.stdout.write('\x1b[2J\x1b[H');
214+
process.stdout.write(formatAggregateLive(analyses, label));
215+
} catch {
216+
// Skip on transient errors
217+
} finally {
218+
isRefreshing = false;
219+
}
220+
}
221+
222+
// Hide cursor
223+
process.stdout.write('\x1b[?25l');
224+
225+
// Initial render
226+
await refresh();
227+
228+
// Watch active session file for changes
229+
watcher = watch(active.fullPath, () => {
230+
if (debounceTimer) clearTimeout(debounceTimer);
231+
debounceTimer = setTimeout(refresh, DEBOUNCE_MS);
232+
});
233+
watcher.on('error', () => {
234+
// Ignore — periodic refresh will keep updating
235+
});
236+
237+
// Periodic refresh (picks up new sessions, updates wall-clock timers)
238+
const periodicTimer = setInterval(refresh, 5000);
239+
240+
function cleanup() {
241+
if (watcher) { watcher.close(); watcher = null; }
242+
if (debounceTimer) clearTimeout(debounceTimer);
243+
clearInterval(periodicTimer);
244+
process.stdout.write('\x1b[?25h');
245+
process.exit(0);
246+
}
247+
248+
process.on('SIGINT', cleanup);
249+
process.on('SIGTERM', cleanup);
250+
}

0 commit comments

Comments
 (0)