claude_session_viewer/lib/screens/tokens/tokens_screen.dart
Mathias Beaulieu-Duncan 364877d376 Initial commit: Claude Code session viewer (Flutter macOS)
A desktop app that parses Claude Code .jsonl session logs and provides
a rich UI for exploring conversations, tool usage, subagents, and token
consumption. Features include project browser with auto-discovery of
~/.claude/projects, conversation timeline with inline subagent expansion,
agents overview, toolbelt chart, and token usage dashboard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:17:23 -04:00

375 lines
13 KiB
Dart

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/log_entry.dart';
import '../../providers/session_provider.dart';
import '../../theme/app_theme.dart';
class TokensScreen extends StatelessWidget {
const TokensScreen({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<SessionProvider>();
final session = provider.session;
if (session == null) {
return const Center(
child: Text('No session loaded',
style: TextStyle(color: AppColors.textMuted)),
);
}
final usage = session.totalUsage;
final assistantEntries = session.allEntries
.whereType<AssistantEntry>()
.where((e) => e.usage != null)
.toList();
return Scaffold(
backgroundColor: AppColors.background,
body: ListView(
padding: const EdgeInsets.all(24),
children: [
const Text(
'Token Usage',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 24),
// Summary cards
Row(
children: [
Expanded(
child: _TokenCard(
label: 'Input Tokens',
value: usage.inputTokens,
color: AppColors.user,
icon: Icons.arrow_downward,
),
),
const SizedBox(width: 12),
Expanded(
child: _TokenCard(
label: 'Output Tokens',
value: usage.outputTokens,
color: AppColors.assistant,
icon: Icons.arrow_upward,
),
),
const SizedBox(width: 12),
Expanded(
child: _TokenCard(
label: 'Cache Created',
value: usage.cacheCreationInputTokens,
color: AppColors.tool,
icon: Icons.cached,
),
),
const SizedBox(width: 12),
Expanded(
child: _TokenCard(
label: 'Cache Read',
value: usage.cacheReadInputTokens,
color: AppColors.agent,
icon: Icons.speed,
),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.surfaceBorder),
),
child: Row(
children: [
const Text(
'Total Tokens: ',
style: TextStyle(
fontSize: 14, color: AppColors.textSecondary),
),
Text(
_formatTokens(usage.totalTokens),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const Spacer(),
if (session.duration != null) ...[
const Text(
'Session Duration: ',
style: TextStyle(
fontSize: 14, color: AppColors.textSecondary),
),
Text(
_formatDuration(session.duration!),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
],
],
),
),
const SizedBox(height: 24),
// Per-agent breakdown
if (session.agents.length > 1) ...[
const Text(
'TOKENS BY AGENT',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 1.2,
),
),
const SizedBox(height: 12),
Container(
height: 200,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.surfaceBorder),
),
child: Row(
children: [
Expanded(
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: session.agents
.where(
(a) => a.aggregatedUsage.totalTokens > 0)
.toList()
.asMap()
.entries
.map((e) {
final agent = e.value;
final color = AppColors.chartPalette[
e.key % AppColors.chartPalette.length];
return PieChartSectionData(
color: color,
value:
agent.aggregatedUsage.totalTokens.toDouble(),
title: '',
radius: 30,
);
}).toList(),
),
),
),
const SizedBox(width: 24),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: session.agents
.where(
(a) => a.aggregatedUsage.totalTokens > 0)
.toList()
.asMap()
.entries
.map((e) {
final agent = e.value;
final color = AppColors.chartPalette[
e.key % AppColors.chartPalette.length];
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
agent.name,
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
_formatTokens(
agent.aggregatedUsage.totalTokens),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
fontFamily: 'JetBrains Mono',
),
),
],
),
);
}).toList(),
),
),
],
),
),
const SizedBox(height: 24),
],
// Per-message table
const Text(
'PER-MESSAGE BREAKDOWN',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 1.2,
),
),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.surfaceBorder),
),
child: DataTable(
headingRowColor: WidgetStateProperty.all(AppColors.surfaceLight),
dataRowColor: WidgetStateProperty.all(Colors.transparent),
columnSpacing: 24,
headingTextStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
),
dataTextStyle: const TextStyle(
fontSize: 12,
color: AppColors.textPrimary,
fontFamily: 'JetBrains Mono',
),
columns: const [
DataColumn(label: Text('#')),
DataColumn(label: Text('Time')),
DataColumn(label: Text('Model')),
DataColumn(label: Text('Input'), numeric: true),
DataColumn(label: Text('Output'), numeric: true),
DataColumn(label: Text('Cache Create'), numeric: true),
DataColumn(label: Text('Cache Read'), numeric: true),
DataColumn(label: Text('Total'), numeric: true),
],
rows: assistantEntries.asMap().entries.map((e) {
final entry = e.value;
final u = entry.usage!;
return DataRow(cells: [
DataCell(Text('${e.key + 1}')),
DataCell(Text(entry.timestamp != null
? '${entry.timestamp!.hour.toString().padLeft(2, '0')}:${entry.timestamp!.minute.toString().padLeft(2, '0')}:${entry.timestamp!.second.toString().padLeft(2, '0')}'
: '-')),
DataCell(Text(entry.model ?? '-',
style: const TextStyle(fontSize: 10))),
DataCell(Text(_formatTokens(u.inputTokens))),
DataCell(Text(_formatTokens(u.outputTokens))),
DataCell(
Text(_formatTokens(u.cacheCreationInputTokens))),
DataCell(Text(_formatTokens(u.cacheReadInputTokens))),
DataCell(Text(
_formatTokens(u.totalTokens),
style: const TextStyle(fontWeight: FontWeight.w600),
)),
]);
}).toList(),
),
),
],
),
);
}
String _formatTokens(int tokens) {
if (tokens >= 1000000) return '${(tokens / 1000000).toStringAsFixed(1)}M';
if (tokens >= 1000) return '${(tokens / 1000).toStringAsFixed(1)}K';
return '$tokens';
}
String _formatDuration(Duration d) {
final minutes = d.inMinutes;
final seconds = d.inSeconds % 60;
if (minutes > 0) return '${minutes}m ${seconds}s';
return '${seconds}s';
}
}
class _TokenCard extends StatelessWidget {
final String label;
final int value;
final Color color;
final IconData icon;
const _TokenCard({
required this.label,
required this.value,
required this.color,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.surfaceBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(
label,
style:
const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
),
const SizedBox(height: 8),
Text(
_format(value),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: color,
fontFamily: 'JetBrains Mono',
),
),
],
),
);
}
String _format(int v) {
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)}M';
if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K';
return '$v';
}
}