claude_session_viewer/lib/screens/tokens/tokens_screen.dart
Mathias Beaulieu-Duncan 659dade82d perf: Phase 1 critical performance fixes + production macOS build
Performance:
- Move JSON parsing to background isolate via Isolate.run() (eliminates 2-4s UI freeze)
- Cache filteredEntries with key-based invalidation (eliminates O(n) recomputation)
- Debounce search queries at 300ms (prevents cascade rebuilds on keystroke)
- Flatten timeline from 2-level turn×response Column to single virtualized ListView.builder
- Add RepaintBoundary per timeline item (isolates repaints during scroll)
- Use context.select for granular rebuilds instead of top-level context.watch
- Lazy ExpandableCard: child not built until first expand (replaces AnimatedCrossFade)
- Use IndexedStack in AppShell (preserves screen state across tab switches)

Fixes:
- Collect parse errors instead of silently swallowing them
- Show parse error count in timeline filter bar
- Fix overflow in tokens screen pie chart legend

Build:
- Configure Developer ID signing with hardened runtime for production distribution
- Enable secure timestamps for notarization
- Update app name to Claude Session Viewer
- Signed, notarized, stapled DMG distribution
2026-04-07 13:32:13 -04:00

377 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: SingleChildScrollView(
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';
}
}