claude_session_viewer/lib/screens/toolbelt/toolbelt_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

287 lines
10 KiB
Dart

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/content_block.dart';
import '../../providers/session_provider.dart';
import '../../theme/app_theme.dart';
import '../../widgets/common/expandable_card.dart';
import '../../widgets/common/json_tree_view.dart';
class ToolbeltScreen extends StatelessWidget {
const ToolbeltScreen({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 toolsByName = session.toolsByName;
final sortedTools = toolsByName.entries.toList()
..sort((a, b) => b.value.length.compareTo(a.value.length));
return Scaffold(
backgroundColor: AppColors.background,
body: ListView(
padding: const EdgeInsets.all(24),
children: [
const Text(
'Toolbelt',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(
'${toolsByName.length} unique tools, ${toolsByName.values.fold<int>(0, (sum, list) => sum + list.length)} total invocations',
style:
const TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
const SizedBox(height: 24),
// Horizontal bar chart
if (sortedTools.isNotEmpty) ...[
Container(
height: (sortedTools.length.clamp(1, 15) * 40.0) + 32,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.surfaceBorder),
),
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: sortedTools.first.value.length.toDouble() * 1.15,
barGroups: sortedTools
.take(15)
.toList()
.asMap()
.entries
.map((e) {
return BarChartGroupData(
x: e.key,
barRods: [
BarChartRodData(
toY: e.value.value.length.toDouble(),
color: AppColors.chartPalette[
e.key % AppColors.chartPalette.length],
width: 22,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
],
showingTooltipIndicators: [0],
);
}).toList(),
titlesData: FlTitlesData(
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final idx = value.toInt();
if (idx >= sortedTools.length || idx >= 15) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
sortedTools[idx].key,
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
),
),
);
},
reservedSize: 24,
),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (value) => FlLine(
color: AppColors.surfaceBorder,
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
tooltipMargin: 4,
getTooltipColor: (_) => AppColors.surfaceLight,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${rod.toY.toInt()} calls',
const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
);
},
),
),
),
),
),
const SizedBox(height: 24),
],
// Tool details
for (final entry in sortedTools) ...[
_ToolDetailCard(
toolName: entry.key,
invocations: entry.value,
),
const SizedBox(height: 8),
],
],
),
);
}
}
class _ToolDetailCard extends StatelessWidget {
final String toolName;
final List<ToolUseBlock> invocations;
const _ToolDetailCard({
required this.toolName,
required this.invocations,
});
@override
Widget build(BuildContext context) {
return ExpandableCard(
backgroundColor: AppColors.toolBg,
borderColor: AppColors.tool.withAlpha(40),
header: Row(
children: [
const Icon(Icons.build_outlined, size: 14, color: AppColors.tool),
const SizedBox(width: 8),
Text(
toolName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.tool,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.tool.withAlpha(20),
borderRadius: BorderRadius.circular(3),
),
child: Text(
'${invocations.length} calls',
style: const TextStyle(fontSize: 10, color: AppColors.tool),
),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: invocations.asMap().entries.map((e) {
final block = e.value;
final hasResult = block.linkedResult != null;
final isError = block.linkedResult?.isError ?? false;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ExpandableCard(
backgroundColor: AppColors.surface,
borderColor: AppColors.surfaceBorder,
header: Row(
children: [
Text(
'Call #${e.key + 1}',
style: const TextStyle(
fontSize: 11, color: AppColors.textSecondary),
),
const Spacer(),
if (hasResult)
Icon(
isError
? Icons.error_outline
: Icons.check_circle_outline,
size: 14,
color: isError ? AppColors.error : AppColors.assistant,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'ARGUMENTS',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 1,
),
),
const SizedBox(height: 4),
JsonTreeView(data: block.input, initiallyExpanded: true),
if (hasResult) ...[
const SizedBox(height: 8),
Text(
isError ? 'ERROR' : 'RESULT',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: isError ? AppColors.error : AppColors.textMuted,
letterSpacing: 1,
),
),
const SizedBox(height: 4),
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: SelectableText(
block.linkedResult!.textContent,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
color: isError
? AppColors.error
: AppColors.textSecondary,
),
),
),
),
],
],
),
),
);
}).toList(),
),
);
}
}