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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user