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>
This commit is contained in:
2026-03-10 16:17:23 -04:00
commit 364877d376
56 changed files with 7169 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
class ExpandableCard extends StatefulWidget {
final Widget header;
final Widget child;
final bool initiallyExpanded;
final Color? backgroundColor;
final Color? borderColor;
const ExpandableCard({
super.key,
required this.header,
required this.child,
this.initiallyExpanded = false,
this.backgroundColor,
this.borderColor,
});
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard>
with SingleTickerProviderStateMixin {
late bool _expanded;
late AnimationController _controller;
late Animation<double> _rotation;
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
value: _expanded ? 1.0 : 0.0,
);
_rotation = Tween<double>(begin: 0, end: 0.25).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_expanded = !_expanded;
if (_expanded) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: widget.backgroundColor ?? AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: widget.borderColor ?? AppColors.surfaceBorder,
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
onTap: _toggle,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
RotationTransition(
turns: _rotation,
child: const Icon(
Icons.chevron_right,
size: 18,
color: AppColors.textSecondary,
),
),
const SizedBox(width: 8),
Expanded(child: widget.header),
],
),
),
),
ClipRect(
child: AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: widget.child,
),
crossFadeState: _expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200),
),
),
],
),
);
}
}
+336
View File
@@ -0,0 +1,336 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../theme/app_theme.dart';
class JsonTreeView extends StatelessWidget {
final dynamic data;
final int depth;
final bool initiallyExpanded;
const JsonTreeView({
super.key,
required this.data,
this.depth = 0,
this.initiallyExpanded = false,
});
@override
Widget build(BuildContext context) {
if (data is Map<String, dynamic>) {
return _MapNode(
map: data as Map<String, dynamic>,
depth: depth,
initiallyExpanded: initiallyExpanded || depth == 0,
);
} else if (data is List) {
return _ListNode(
list: data as List,
depth: depth,
initiallyExpanded: initiallyExpanded || depth == 0,
);
} else {
return _ValueNode(value: data, depth: depth);
}
}
}
class _MapNode extends StatefulWidget {
final Map<String, dynamic> map;
final int depth;
final bool initiallyExpanded;
const _MapNode({
required this.map,
required this.depth,
this.initiallyExpanded = false,
});
@override
State<_MapNode> createState() => _MapNodeState();
}
class _MapNodeState extends State<_MapNode> {
late bool _expanded;
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
if (widget.map.isEmpty) {
return Text('{}', style: _valueStyle);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_expanded ? Icons.expand_more : Icons.chevron_right,
size: 14,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
'{${widget.map.length} keys}',
style: _hintStyle,
),
if (widget.depth == 0) ...[
const SizedBox(width: 8),
_CopyButton(data: widget.map),
],
],
),
),
if (_expanded)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.map.entries.map((e) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${e.key}: ',
style: _keyStyle,
),
Expanded(
child: JsonTreeView(
data: e.value,
depth: widget.depth + 1,
),
),
],
),
);
}).toList(),
),
),
],
);
}
}
class _ListNode extends StatefulWidget {
final List list;
final int depth;
final bool initiallyExpanded;
const _ListNode({
required this.list,
required this.depth,
this.initiallyExpanded = false,
});
@override
State<_ListNode> createState() => _ListNodeState();
}
class _ListNodeState extends State<_ListNode> {
late bool _expanded;
@override
void initState() {
super.initState();
_expanded = widget.initiallyExpanded;
}
@override
Widget build(BuildContext context) {
if (widget.list.isEmpty) {
return Text('[]', style: _valueStyle);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_expanded ? Icons.expand_more : Icons.chevron_right,
size: 14,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
'[${widget.list.length} items]',
style: _hintStyle,
),
],
),
),
if (_expanded)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.list.asMap().entries.map((e) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${e.key}: ',
style: _indexStyle,
),
Expanded(
child: JsonTreeView(
data: e.value,
depth: widget.depth + 1,
),
),
],
),
);
}).toList(),
),
),
],
);
}
}
class _ValueNode extends StatelessWidget {
final dynamic value;
final int depth;
const _ValueNode({required this.value, required this.depth});
@override
Widget build(BuildContext context) {
if (value == null) {
return Text('null', style: _nullStyle);
}
if (value is bool) {
return Text('$value', style: _boolStyle);
}
if (value is num) {
return Text('$value', style: _numberStyle);
}
final str = value.toString();
if (str.length > 200) {
return _LongStringNode(text: str);
}
return SelectableText('"$str"', style: _stringStyle);
}
}
class _LongStringNode extends StatefulWidget {
final String text;
const _LongStringNode({required this.text});
@override
State<_LongStringNode> createState() => _LongStringNodeState();
}
class _LongStringNodeState extends State<_LongStringNode> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Text(
_expanded
? '"${widget.text}"'
: '"${widget.text.substring(0, 200)}..." (${widget.text.length} chars)',
style: _stringStyle,
),
),
],
);
}
}
class _CopyButton extends StatelessWidget {
final dynamic data;
const _CopyButton({required this.data});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
final json = const JsonEncoder.withIndent(' ').convert(data);
Clipboard.setData(ClipboardData(text: json));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Copied to clipboard'),
duration: Duration(seconds: 1),
),
);
},
child: const Icon(
Icons.copy,
size: 14,
color: AppColors.textMuted,
),
);
}
}
const _keyStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.user,
);
const _indexStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textMuted,
);
const _valueStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textSecondary,
);
const _stringStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: Color(0xFF10B981),
);
const _numberStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: Color(0xFFF59E0B),
);
const _boolStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: Color(0xFFA855F7),
);
const _nullStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textMuted,
fontStyle: FontStyle.italic,
);
const _hintStyle = TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
color: AppColors.textMuted,
);
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/session_provider.dart';
import '../../screens/agents/agents_screen.dart';
import '../../screens/home/home_screen.dart';
import '../../screens/timeline/timeline_screen.dart';
import '../../screens/tokens/tokens_screen.dart';
import '../../screens/toolbelt/toolbelt_screen.dart';
import 'sidebar.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
SidebarScreen _screen = SidebarScreen.home;
void _onScreenSelected(SidebarScreen screen) {
setState(() => _screen = screen);
}
void _onSessionLoaded() {
setState(() => _screen = SidebarScreen.timeline);
}
@override
Widget build(BuildContext context) {
final provider = context.watch<SessionProvider>();
final hasSession = provider.session != null;
return Row(
children: [
Sidebar(
selected: _screen,
onSelect: _onScreenSelected,
hasSession: hasSession,
),
Expanded(
child: _buildScreen(),
),
],
);
}
Widget _buildScreen() {
switch (_screen) {
case SidebarScreen.home:
return HomeScreen(onSessionLoaded: _onSessionLoaded);
case SidebarScreen.timeline:
return const TimelineScreen();
case SidebarScreen.agents:
return const AgentsScreen();
case SidebarScreen.toolbelt:
return const ToolbeltScreen();
case SidebarScreen.tokens:
return const TokensScreen();
}
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import '../../theme/app_theme.dart';
enum SidebarScreen { home, timeline, agents, toolbelt, tokens }
class Sidebar extends StatelessWidget {
final SidebarScreen selected;
final ValueChanged<SidebarScreen> onSelect;
final bool hasSession;
const Sidebar({
super.key,
required this.selected,
required this.onSelect,
required this.hasSession,
});
@override
Widget build(BuildContext context) {
return Container(
width: 220,
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(
right: BorderSide(color: AppColors.surfaceBorder, width: 1),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 40),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.assistant.withAlpha(30),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.terminal,
size: 16,
color: AppColors.assistant,
),
),
const SizedBox(width: 10),
const Text(
'Session Viewer',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
),
const SizedBox(height: 8),
_SidebarItem(
icon: Icons.folder_open,
label: 'Open Session',
isSelected: selected == SidebarScreen.home,
onTap: () => onSelect(SidebarScreen.home),
),
if (hasSession) ...[
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'SESSION',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.textMuted,
letterSpacing: 1.2,
),
),
),
_SidebarItem(
icon: Icons.timeline,
label: 'Timeline',
isSelected: selected == SidebarScreen.timeline,
onTap: () => onSelect(SidebarScreen.timeline),
),
_SidebarItem(
icon: Icons.smart_toy_outlined,
label: 'Agents',
isSelected: selected == SidebarScreen.agents,
onTap: () => onSelect(SidebarScreen.agents),
),
_SidebarItem(
icon: Icons.build_outlined,
label: 'Toolbelt',
isSelected: selected == SidebarScreen.toolbelt,
onTap: () => onSelect(SidebarScreen.toolbelt),
),
_SidebarItem(
icon: Icons.data_usage,
label: 'Token Usage',
isSelected: selected == SidebarScreen.tokens,
onTap: () => onSelect(SidebarScreen.tokens),
),
],
const Spacer(),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Claude Session Viewer v0.1',
style: TextStyle(
fontSize: 10,
color: AppColors.textMuted.withAlpha(128),
),
),
),
],
),
);
}
}
class _SidebarItem extends StatefulWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _SidebarItem({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
State<_SidebarItem> createState() => _SidebarItemState();
}
class _SidebarItemState extends State<_SidebarItem> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: widget.isSelected
? AppColors.assistant.withAlpha(20)
: _hovered
? AppColors.surfaceLight
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Icon(
widget.icon,
size: 18,
color: widget.isSelected
? AppColors.assistant
: AppColors.textSecondary,
),
const SizedBox(width: 10),
Text(
widget.label,
style: TextStyle(
fontSize: 13,
fontWeight:
widget.isSelected ? FontWeight.w600 : FontWeight.w400,
color: widget.isSelected
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
],
),
),
),
);
}
}