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
+100
View File
@@ -0,0 +1,100 @@
import 'content_block.dart';
import 'log_entry.dart';
import 'token_usage.dart';
class AgentInfo {
final String id;
final String name;
final String? subagentType;
final String? description;
final String? prompt;
final String? model;
final List<LogEntry> messages;
final List<ToolUseBlock> toolsUsed;
final TokenUsage aggregatedUsage;
AgentInfo({
required this.id,
required this.name,
this.subagentType,
this.description,
this.prompt,
this.model,
required this.messages,
required this.toolsUsed,
required this.aggregatedUsage,
});
Set<String> get uniqueToolNames => toolsUsed.map((t) => t.name).toSet();
Map<String, int> get toolUsageCounts {
final counts = <String, int>{};
for (final tool in toolsUsed) {
counts[tool.name] = (counts[tool.name] ?? 0) + 1;
}
return counts;
}
int get messageCount => messages.length;
int get userMessageCount =>
messages.where((m) => m.type == 'user').length;
int get assistantMessageCount =>
messages.where((m) => m.type == 'assistant').length;
}
class SessionLog {
final String? sessionId;
final String? cwd;
final String? version;
final String? gitBranch;
final List<LogEntry> allEntries;
final List<LogEntry> mainConversation;
final List<AgentInfo> agents;
final AgentInfo mainAgent;
final TokenUsage totalUsage;
final Map<String, List<ToolUseBlock>> toolsByName;
final DateTime? startTime;
final DateTime? endTime;
/// Lookup subagent by ID (matches both full id and partial)
late final Map<String, AgentInfo> agentById = {
for (final a in agents) a.id: a,
};
SessionLog({
this.sessionId,
this.cwd,
this.version,
this.gitBranch,
required this.allEntries,
required this.mainConversation,
required this.agents,
required this.mainAgent,
required this.totalUsage,
required this.toolsByName,
this.startTime,
this.endTime,
});
/// Find an agent that matches a tool_use id (Agent tool calls use
/// the tool_use block id, subagent files use a shortened agentId)
AgentInfo? findAgentForToolUse(String toolUseId) {
// Direct match
if (agentById.containsKey(toolUseId)) return agentById[toolUseId];
// Partial match: subagent file ids are shortened (e.g. "a014e30b71de602bb")
for (final agent in agents) {
if (agent.id == 'main') continue;
if (toolUseId.contains(agent.id) || agent.id.contains(toolUseId)) {
return agent;
}
}
return null;
}
Duration? get duration {
if (startTime != null && endTime != null) {
return endTime!.difference(startTime!);
}
return null;
}
}
+116
View File
@@ -0,0 +1,116 @@
class ContentBlock {
final String type;
final Map<String, dynamic> raw;
ContentBlock({required this.type, required this.raw});
factory ContentBlock.fromJson(Map<String, dynamic> json) {
final type = json['type'] as String? ?? 'unknown';
switch (type) {
case 'text':
return TextBlock.fromJson(json);
case 'thinking':
return ThinkingBlock.fromJson(json);
case 'tool_use':
return ToolUseBlock.fromJson(json);
default:
return ContentBlock(type: type, raw: json);
}
}
}
class TextBlock extends ContentBlock {
final String text;
TextBlock({required this.text, required Map<String, dynamic> raw})
: super(type: 'text', raw: raw);
factory TextBlock.fromJson(Map<String, dynamic> json) {
return TextBlock(
text: json['text'] as String? ?? '',
raw: json,
);
}
}
class ThinkingBlock extends ContentBlock {
final String thinking;
final String? signature;
ThinkingBlock({
required this.thinking,
this.signature,
required Map<String, dynamic> raw,
}) : super(type: 'thinking', raw: raw);
factory ThinkingBlock.fromJson(Map<String, dynamic> json) {
return ThinkingBlock(
thinking: json['thinking'] as String? ?? '',
signature: json['signature'] as String?,
raw: json,
);
}
}
class ToolUseBlock extends ContentBlock {
final String id;
final String name;
final Map<String, dynamic> input;
ToolResultData? linkedResult;
ToolUseBlock({
required this.id,
required this.name,
required this.input,
this.linkedResult,
required Map<String, dynamic> raw,
}) : super(type: 'tool_use', raw: raw);
factory ToolUseBlock.fromJson(Map<String, dynamic> json) {
return ToolUseBlock(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
input: (json['input'] as Map<String, dynamic>?) ?? {},
raw: json,
);
}
bool get isAgentCall => name == 'Agent';
String? get subagentType => isAgentCall ? input['subagent_type'] as String? : null;
String? get agentDescription => isAgentCall ? input['description'] as String? : null;
String? get agentPrompt => isAgentCall ? input['prompt'] as String? : null;
}
class ToolResultData {
final String toolUseId;
final dynamic content;
final bool isError;
final Map<String, dynamic> raw;
ToolResultData({
required this.toolUseId,
this.content,
this.isError = false,
required this.raw,
});
String get textContent {
if (content is String) return content;
if (content is List) {
return (content as List)
.where((c) => c is Map && c['type'] == 'text')
.map((c) => c['text'] as String? ?? '')
.join('\n');
}
return content?.toString() ?? '';
}
factory ToolResultData.fromJson(Map<String, dynamic> json) {
return ToolResultData(
toolUseId: json['tool_use_id'] as String? ?? '',
content: json['content'],
isError: json['is_error'] as bool? ?? false,
raw: json,
);
}
}
+296
View File
@@ -0,0 +1,296 @@
import 'content_block.dart';
import 'token_usage.dart';
class LogEntry {
final String? uuid;
final String? parentUuid;
final String? sessionId;
final DateTime? timestamp;
final String? cwd;
final String? version;
final String? gitBranch;
final String type;
final bool isSidechain;
final Map<String, dynamic> raw;
LogEntry({
this.uuid,
this.parentUuid,
this.sessionId,
this.timestamp,
this.cwd,
this.version,
this.gitBranch,
required this.type,
this.isSidechain = false,
required this.raw,
});
factory LogEntry.fromJson(Map<String, dynamic> json) {
final type = json['type'] as String? ?? 'unknown';
switch (type) {
case 'user':
return UserEntry.fromJson(json);
case 'assistant':
return AssistantEntry.fromJson(json);
case 'system':
return SystemEntry.fromJson(json);
case 'progress':
return ProgressEntry.fromJson(json);
case 'file-history-snapshot':
return FileSnapshotEntry.fromJson(json);
default:
return _baseFromJson(json, type);
}
}
static LogEntry _baseFromJson(Map<String, dynamic> json, String type) {
return LogEntry(
uuid: json['uuid'] as String?,
parentUuid: json['parentUuid'] as String?,
sessionId: json['sessionId'] as String?,
timestamp: _parseTimestamp(json['timestamp']),
cwd: json['cwd'] as String?,
version: json['version'] as String?,
gitBranch: json['gitBranch'] as String?,
type: type,
isSidechain: json['isSidechain'] as bool? ?? false,
raw: json,
);
}
static DateTime? _parseTimestamp(dynamic ts) {
if (ts is String) return DateTime.tryParse(ts);
return null;
}
}
class UserEntry extends LogEntry {
final dynamic content;
UserEntry({
required this.content,
required super.uuid,
required super.parentUuid,
required super.sessionId,
required super.timestamp,
required super.cwd,
required super.version,
required super.gitBranch,
required super.isSidechain,
required super.raw,
}) : super(type: 'user');
String get promptText {
if (content is String) return content;
return '';
}
bool get isToolResult => content is List;
List<ToolResultData> get toolResults {
if (content is! List) return [];
return (content as List)
.where((c) => c is Map<String, dynamic> && c['type'] == 'tool_result')
.map((c) => ToolResultData.fromJson(c as Map<String, dynamic>))
.toList();
}
factory UserEntry.fromJson(Map<String, dynamic> json) {
final message = json['message'] as Map<String, dynamic>? ?? {};
return UserEntry(
content: message['content'],
uuid: json['uuid'] as String?,
parentUuid: json['parentUuid'] as String?,
sessionId: json['sessionId'] as String?,
timestamp: LogEntry._parseTimestamp(json['timestamp']),
cwd: json['cwd'] as String?,
version: json['version'] as String?,
gitBranch: json['gitBranch'] as String?,
isSidechain: json['isSidechain'] as bool? ?? false,
raw: json,
);
}
}
class AssistantEntry extends LogEntry {
final String? model;
final String? messageId;
final List<ContentBlock> contentBlocks;
final TokenUsage? usage;
final String? stopReason;
AssistantEntry({
this.model,
this.messageId,
required this.contentBlocks,
this.usage,
this.stopReason,
required super.uuid,
required super.parentUuid,
required super.sessionId,
required super.timestamp,
required super.cwd,
required super.version,
required super.gitBranch,
required super.isSidechain,
required super.raw,
}) : super(type: 'assistant');
List<TextBlock> get textBlocks =>
contentBlocks.whereType<TextBlock>().toList();
List<ThinkingBlock> get thinkingBlocks =>
contentBlocks.whereType<ThinkingBlock>().toList();
List<ToolUseBlock> get toolUseBlocks =>
contentBlocks.whereType<ToolUseBlock>().toList();
bool get hasText => textBlocks.isNotEmpty;
bool get hasThinking => thinkingBlocks.isNotEmpty;
bool get hasToolUse => toolUseBlocks.isNotEmpty;
factory AssistantEntry.fromJson(Map<String, dynamic> json) {
final message = json['message'] as Map<String, dynamic>? ?? {};
final contentList = message['content'] as List<dynamic>? ?? [];
final usageJson = message['usage'] as Map<String, dynamic>?;
return AssistantEntry(
model: message['model'] as String?,
messageId: message['id'] as String?,
contentBlocks: contentList
.whereType<Map<String, dynamic>>()
.map((c) => ContentBlock.fromJson(c))
.toList(),
usage: usageJson != null ? TokenUsage.fromJson(usageJson) : null,
stopReason: message['stop_reason'] as String?,
uuid: json['uuid'] as String?,
parentUuid: json['parentUuid'] as String?,
sessionId: json['sessionId'] as String?,
timestamp: LogEntry._parseTimestamp(json['timestamp']),
cwd: json['cwd'] as String?,
version: json['version'] as String?,
gitBranch: json['gitBranch'] as String?,
isSidechain: json['isSidechain'] as bool? ?? false,
raw: json,
);
}
}
class SystemEntry extends LogEntry {
final String? subtype;
final int? durationMs;
final String? slug;
SystemEntry({
this.subtype,
this.durationMs,
this.slug,
required super.uuid,
required super.parentUuid,
required super.sessionId,
required super.timestamp,
required super.cwd,
required super.version,
required super.gitBranch,
required super.isSidechain,
required super.raw,
}) : super(type: 'system');
factory SystemEntry.fromJson(Map<String, dynamic> json) {
return SystemEntry(
subtype: json['subtype'] as String?,
durationMs: json['durationMs'] as int?,
slug: json['slug'] as String?,
uuid: json['uuid'] as String?,
parentUuid: json['parentUuid'] as String?,
sessionId: json['sessionId'] as String?,
timestamp: LogEntry._parseTimestamp(json['timestamp']),
cwd: json['cwd'] as String?,
version: json['version'] as String?,
gitBranch: json['gitBranch'] as String?,
isSidechain: json['isSidechain'] as bool? ?? false,
raw: json,
);
}
}
class ProgressEntry extends LogEntry {
final String? slug;
final String? toolUseID;
final String? parentToolUseID;
final Map<String, dynamic> data;
ProgressEntry({
this.slug,
this.toolUseID,
this.parentToolUseID,
required this.data,
required super.uuid,
required super.parentUuid,
required super.sessionId,
required super.timestamp,
required super.cwd,
required super.version,
required super.gitBranch,
required super.isSidechain,
required super.raw,
}) : super(type: 'progress');
Map<String, dynamic>? get progressMessage =>
data['message'] as Map<String, dynamic>?;
factory ProgressEntry.fromJson(Map<String, dynamic> json) {
return ProgressEntry(
slug: json['slug'] as String?,
toolUseID: json['toolUseID'] as String?,
parentToolUseID: json['parentToolUseID'] as String?,
data: (json['data'] as Map<String, dynamic>?) ?? {},
uuid: json['uuid'] as String?,
parentUuid: json['parentUuid'] as String?,
sessionId: json['sessionId'] as String?,
timestamp: LogEntry._parseTimestamp(json['timestamp']),
cwd: json['cwd'] as String?,
version: json['version'] as String?,
gitBranch: json['gitBranch'] as String?,
isSidechain: json['isSidechain'] as bool? ?? false,
raw: json,
);
}
}
class FileSnapshotEntry extends LogEntry {
final String? messageId;
final Map<String, dynamic> snapshot;
final bool isSnapshotUpdate;
FileSnapshotEntry({
this.messageId,
required this.snapshot,
this.isSnapshotUpdate = false,
required super.uuid,
required super.parentUuid,
required super.sessionId,
required super.timestamp,
required super.cwd,
required super.version,
required super.gitBranch,
required super.isSidechain,
required super.raw,
}) : super(type: 'file-history-snapshot');
factory FileSnapshotEntry.fromJson(Map<String, dynamic> json) {
return FileSnapshotEntry(
messageId: json['messageId'] as String?,
snapshot: (json['snapshot'] as Map<String, dynamic>?) ?? {},
isSnapshotUpdate: json['isSnapshotUpdate'] as bool? ?? false,
uuid: json['uuid'] as String?,
parentUuid: json['parentUuid'] as String?,
sessionId: json['sessionId'] as String?,
timestamp: LogEntry._parseTimestamp(json['timestamp']),
cwd: json['cwd'] as String?,
version: json['version'] as String?,
gitBranch: json['gitBranch'] as String?,
isSidechain: json['isSidechain'] as bool? ?? false,
raw: json,
);
}
}
+35
View File
@@ -0,0 +1,35 @@
class TokenUsage {
final int inputTokens;
final int outputTokens;
final int cacheCreationInputTokens;
final int cacheReadInputTokens;
const TokenUsage({
this.inputTokens = 0,
this.outputTokens = 0,
this.cacheCreationInputTokens = 0,
this.cacheReadInputTokens = 0,
});
int get totalTokens =>
inputTokens + outputTokens + cacheCreationInputTokens + cacheReadInputTokens;
TokenUsage operator +(TokenUsage other) => TokenUsage(
inputTokens: inputTokens + other.inputTokens,
outputTokens: outputTokens + other.outputTokens,
cacheCreationInputTokens:
cacheCreationInputTokens + other.cacheCreationInputTokens,
cacheReadInputTokens:
cacheReadInputTokens + other.cacheReadInputTokens,
);
factory TokenUsage.fromJson(Map<String, dynamic> json) {
return TokenUsage(
inputTokens: json['input_tokens'] as int? ?? 0,
outputTokens: json['output_tokens'] as int? ?? 0,
cacheCreationInputTokens:
json['cache_creation_input_tokens'] as int? ?? 0,
cacheReadInputTokens: json['cache_read_input_tokens'] as int? ?? 0,
);
}
}