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>
375 lines
13 KiB
Dart
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';
|
|
}
|
|
}
|