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>
287 lines
10 KiB
Dart
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(),
|
|
),
|
|
);
|
|
}
|
|
}
|