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>
297 lines
8.7 KiB
Dart
297 lines
8.7 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|