commit 364877d3763f134eb33f80cd80bc5e415f8b964b Author: Mathias Beaulieu-Duncan Date: Tue Mar 10 16:17:23 2026 -0400 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..e1ab853 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + - platform: macos + create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..e8d2376 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'providers/session_provider.dart'; +import 'theme/app_theme.dart'; +import 'widgets/navigation/app_shell.dart'; + +void main() { + runApp(const ClaudeSessionViewerApp()); +} + +class ClaudeSessionViewerApp extends StatelessWidget { + const ClaudeSessionViewerApp({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => SessionProvider(), + child: MaterialApp( + title: 'Claude Session Viewer', + debugShowCheckedModeBanner: false, + theme: AppTheme.dark, + home: const Scaffold( + backgroundColor: AppColors.background, + body: AppShell(), + ), + ), + ); + } +} diff --git a/lib/models/agent_info.dart b/lib/models/agent_info.dart new file mode 100644 index 0000000..99f1ba3 --- /dev/null +++ b/lib/models/agent_info.dart @@ -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 messages; + final List 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 get uniqueToolNames => toolsUsed.map((t) => t.name).toSet(); + + Map get toolUsageCounts { + final counts = {}; + 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 allEntries; + final List mainConversation; + final List agents; + final AgentInfo mainAgent; + final TokenUsage totalUsage; + final Map> toolsByName; + final DateTime? startTime; + final DateTime? endTime; + + /// Lookup subagent by ID (matches both full id and partial) + late final Map 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; + } +} diff --git a/lib/models/content_block.dart b/lib/models/content_block.dart new file mode 100644 index 0000000..db96e65 --- /dev/null +++ b/lib/models/content_block.dart @@ -0,0 +1,116 @@ +class ContentBlock { + final String type; + final Map raw; + + ContentBlock({required this.type, required this.raw}); + + factory ContentBlock.fromJson(Map 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 raw}) + : super(type: 'text', raw: raw); + + factory TextBlock.fromJson(Map 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 raw, + }) : super(type: 'thinking', raw: raw); + + factory ThinkingBlock.fromJson(Map 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 input; + ToolResultData? linkedResult; + + ToolUseBlock({ + required this.id, + required this.name, + required this.input, + this.linkedResult, + required Map raw, + }) : super(type: 'tool_use', raw: raw); + + factory ToolUseBlock.fromJson(Map json) { + return ToolUseBlock( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + input: (json['input'] as Map?) ?? {}, + 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 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 json) { + return ToolResultData( + toolUseId: json['tool_use_id'] as String? ?? '', + content: json['content'], + isError: json['is_error'] as bool? ?? false, + raw: json, + ); + } +} diff --git a/lib/models/log_entry.dart b/lib/models/log_entry.dart new file mode 100644 index 0000000..7937f8b --- /dev/null +++ b/lib/models/log_entry.dart @@ -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 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 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 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 get toolResults { + if (content is! List) return []; + return (content as List) + .where((c) => c is Map && c['type'] == 'tool_result') + .map((c) => ToolResultData.fromJson(c as Map)) + .toList(); + } + + factory UserEntry.fromJson(Map json) { + final message = json['message'] as Map? ?? {}; + 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 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 get textBlocks => + contentBlocks.whereType().toList(); + List get thinkingBlocks => + contentBlocks.whereType().toList(); + List get toolUseBlocks => + contentBlocks.whereType().toList(); + + bool get hasText => textBlocks.isNotEmpty; + bool get hasThinking => thinkingBlocks.isNotEmpty; + bool get hasToolUse => toolUseBlocks.isNotEmpty; + + factory AssistantEntry.fromJson(Map json) { + final message = json['message'] as Map? ?? {}; + final contentList = message['content'] as List? ?? []; + final usageJson = message['usage'] as Map?; + + return AssistantEntry( + model: message['model'] as String?, + messageId: message['id'] as String?, + contentBlocks: contentList + .whereType>() + .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 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 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? get progressMessage => + data['message'] as Map?; + + factory ProgressEntry.fromJson(Map json) { + return ProgressEntry( + slug: json['slug'] as String?, + toolUseID: json['toolUseID'] as String?, + parentToolUseID: json['parentToolUseID'] as String?, + data: (json['data'] as Map?) ?? {}, + 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 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 json) { + return FileSnapshotEntry( + messageId: json['messageId'] as String?, + snapshot: (json['snapshot'] as Map?) ?? {}, + 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, + ); + } +} diff --git a/lib/models/token_usage.dart b/lib/models/token_usage.dart new file mode 100644 index 0000000..0fd6ab2 --- /dev/null +++ b/lib/models/token_usage.dart @@ -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 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, + ); + } +} diff --git a/lib/providers/session_provider.dart b/lib/providers/session_provider.dart new file mode 100644 index 0000000..4f7b47b --- /dev/null +++ b/lib/providers/session_provider.dart @@ -0,0 +1,117 @@ +import 'package:flutter/foundation.dart'; +import '../models/agent_info.dart'; +import '../models/log_entry.dart'; +import '../services/jsonl_parser.dart'; + +class SessionProvider with ChangeNotifier { + final JsonlParser _parser = JsonlParser(); + + SessionLog? _session; + bool _isLoading = false; + String? _error; + String? _filePath; + + // Filter state + String? _selectedAgentId; + Set _visibleTypes = {'user', 'assistant', 'system'}; + String _searchQuery = ''; + + SessionLog? get session => _session; + bool get isLoading => _isLoading; + String? get error => _error; + String? get filePath => _filePath; + String? get selectedAgentId => _selectedAgentId; + Set get visibleTypes => _visibleTypes; + String get searchQuery => _searchQuery; + + AgentInfo? get selectedAgent { + if (_session == null || _selectedAgentId == null) return null; + return _session!.agents + .where((a) => a.id == _selectedAgentId) + .firstOrNull; + } + + List get filteredEntries { + if (_session == null) return []; + + List entries; + if (_selectedAgentId != null && _selectedAgentId != 'main') { + final agent = selectedAgent; + entries = agent?.messages ?? []; + } else { + entries = _session!.mainConversation; + } + + return entries.where((e) { + if (!_visibleTypes.contains(e.type)) return false; + if (_searchQuery.isNotEmpty) { + return _entryMatchesSearch(e, _searchQuery.toLowerCase()); + } + return true; + }).toList(); + } + + bool _entryMatchesSearch(LogEntry entry, String query) { + if (entry is UserEntry) { + return entry.promptText.toLowerCase().contains(query); + } + if (entry is AssistantEntry) { + for (final block in entry.textBlocks) { + if (block.text.toLowerCase().contains(query)) return true; + } + for (final block in entry.toolUseBlocks) { + if (block.name.toLowerCase().contains(query)) return true; + if (block.input.toString().toLowerCase().contains(query)) return true; + } + } + return false; + } + + Future loadSession(String filePath) async { + _isLoading = true; + _error = null; + _filePath = filePath; + _selectedAgentId = null; + _searchQuery = ''; + notifyListeners(); + + try { + _session = await _parser.parseFile(filePath); + _error = null; + } catch (e) { + _error = e.toString(); + _session = null; + } + + _isLoading = false; + notifyListeners(); + } + + void selectAgent(String? agentId) { + _selectedAgentId = agentId; + notifyListeners(); + } + + void toggleTypeFilter(String type) { + if (_visibleTypes.contains(type)) { + _visibleTypes = Set.from(_visibleTypes)..remove(type); + } else { + _visibleTypes = Set.from(_visibleTypes)..add(type); + } + notifyListeners(); + } + + void setSearchQuery(String query) { + _searchQuery = query; + notifyListeners(); + } + + void clearSession() { + _session = null; + _filePath = null; + _error = null; + _selectedAgentId = null; + _searchQuery = ''; + notifyListeners(); + } +} diff --git a/lib/screens/agents/agents_screen.dart b/lib/screens/agents/agents_screen.dart new file mode 100644 index 0000000..01303f0 --- /dev/null +++ b/lib/screens/agents/agents_screen.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/agent_info.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/common/expandable_card.dart'; + +class AgentsScreen extends StatelessWidget { + const AgentsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final session = provider.session; + if (session == null) { + return const Center( + child: Text('No session loaded', + style: TextStyle(color: AppColors.textMuted)), + ); + } + + return Scaffold( + backgroundColor: AppColors.background, + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text( + 'Agents Overview', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + '${session.agents.length} agent(s) used in this session', + style: const TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + const SizedBox(height: 24), + + // Summary cards + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _SummaryCard( + label: 'Total Agents', + value: '${session.agents.length}', + icon: Icons.smart_toy_outlined, + color: AppColors.agent, + ), + _SummaryCard( + label: 'Total Tools Used', + value: '${session.toolsByName.length}', + icon: Icons.build_outlined, + color: AppColors.tool, + ), + _SummaryCard( + label: 'Total Tokens', + value: _formatTokens(session.totalUsage.totalTokens), + icon: Icons.data_usage, + color: AppColors.assistant, + ), + ], + ), + const SizedBox(height: 24), + + // Agent cards + for (final agent in session.agents) ...[ + _AgentCard(agent: agent), + const SizedBox(height: 12), + ], + ], + ), + ); + } + + String _formatTokens(int tokens) { + if (tokens >= 1000000) return '${(tokens / 1000000).toStringAsFixed(1)}M'; + if (tokens >= 1000) return '${(tokens / 1000).toStringAsFixed(1)}K'; + return '$tokens'; + } +} + +class _SummaryCard extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + + const _SummaryCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 200, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.surfaceBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(height: 12), + Text( + value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + ], + ), + ); + } +} + +class _AgentCard extends StatelessWidget { + final AgentInfo agent; + + const _AgentCard({required this.agent}); + + @override + Widget build(BuildContext context) { + final isMain = agent.id == 'main'; + final color = isMain ? AppColors.assistant : AppColors.agent; + + return ExpandableCard( + initiallyExpanded: isMain, + backgroundColor: isMain ? AppColors.assistantBg : AppColors.agentBg, + borderColor: color.withAlpha(40), + header: Row( + children: [ + Icon( + isMain ? Icons.smart_toy : Icons.smart_toy_outlined, + size: 16, + color: color, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + agent.name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: color, + ), + ), + if (agent.subagentType != null) + Text( + agent.subagentType!, + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + if (agent.model != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + agent.model!, + style: const TextStyle( + fontSize: 10, + color: AppColors.textSecondary, + fontFamily: 'JetBrains Mono'), + ), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Stats row + Row( + children: [ + _StatChip('Messages', '${agent.messageCount}'), + const SizedBox(width: 8), + _StatChip('Tool Calls', '${agent.toolsUsed.length}'), + const SizedBox(width: 8), + _StatChip( + 'Input Tokens', + _formatTokens(agent.aggregatedUsage.inputTokens), + ), + const SizedBox(width: 8), + _StatChip( + 'Output Tokens', + _formatTokens(agent.aggregatedUsage.outputTokens), + ), + ], + ), + const SizedBox(height: 12), + + // Description + if (agent.description != null) ...[ + const Text( + 'DESCRIPTION', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 1, + ), + ), + const SizedBox(height: 4), + Text( + agent.description!, + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + ), + const SizedBox(height: 12), + ], + + // Prompt + if (agent.prompt != null) ...[ + ExpandableCard( + backgroundColor: AppColors.surface, + borderColor: AppColors.surfaceBorder, + header: const Text( + 'Agent Prompt', + style: TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + child: SelectableText( + agent.prompt!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + const SizedBox(height: 8), + ], + + // Toolbelt + if (agent.uniqueToolNames.isNotEmpty) ...[ + const Text( + 'TOOLBELT', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 1, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: agent.toolUsageCounts.entries.map((e) { + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.toolBg, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: AppColors.tool.withAlpha(40)), + ), + child: Text( + '${e.key} (${e.value})', + style: const TextStyle( + fontSize: 11, + color: AppColors.tool, + fontFamily: 'JetBrains Mono', + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ); + } + + String _formatTokens(int tokens) { + if (tokens >= 1000000) return '${(tokens / 1000000).toStringAsFixed(1)}M'; + if (tokens >= 1000) return '${(tokens / 1000).toStringAsFixed(1)}K'; + return '$tokens'; + } +} + +class _StatChip extends StatelessWidget { + final String label; + final String value; + + const _StatChip(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$label: ', + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + Text( + value, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart new file mode 100644 index 0000000..28cece3 --- /dev/null +++ b/lib/screens/home/home_screen.dart @@ -0,0 +1,870 @@ +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; + +// ─── Data models ───────────────────────────────────────────── + +class _Project { + final String dirPath; + final String rawDirName; + final String displayName; + final String fullPath; // the actual filesystem path it maps to + final List<_SessionFile> sessions; + final DateTime lastModified; + + _Project({ + required this.dirPath, + required this.rawDirName, + required this.displayName, + required this.fullPath, + required this.sessions, + required this.lastModified, + }); + + int get sessionCount => sessions.length; +} + +class _SessionFile { + final File file; + final FileStat stat; + final String sessionId; + + _SessionFile({ + required this.file, + required this.stat, + required this.sessionId, + }); +} + +// ─── Color helpers for project icons ───────────────────────── + +const _projectColors = [ + Color(0xFF3B82F6), + Color(0xFF10B981), + Color(0xFFA855F7), + Color(0xFFF59E0B), + Color(0xFFEF4444), + Color(0xFF06B6D4), + Color(0xFFF97316), + Color(0xFF8B5CF6), + Color(0xFFEC4899), + Color(0xFF14B8A6), +]; + +Color _colorForProject(String name) { + final hash = name.codeUnits.fold(0, (prev, c) => prev + c); + return _projectColors[hash % _projectColors.length]; +} + +const _projectIcons = [ + Icons.folder_outlined, + Icons.code, + Icons.terminal, + Icons.cloud_outlined, + Icons.devices_outlined, + Icons.science_outlined, + Icons.hub_outlined, + Icons.inventory_2_outlined, + Icons.web_outlined, + Icons.phone_iphone_outlined, +]; + +IconData _iconForProject(String name) { + final lower = name.toLowerCase(); + if (lower.contains('flutter') || lower.contains('app')) return Icons.phone_iphone_outlined; + if (lower.contains('api') || lower.contains('backend')) return Icons.cloud_outlined; + if (lower.contains('docker') || lower.contains('helm') || lower.contains('kubernetes') || lower.contains('talos')) return Icons.dns_outlined; + if (lower.contains('web') || lower.contains('frontend')) return Icons.web_outlined; + if (lower.contains('agent') || lower.contains('claude') || lower.contains('auto')) return Icons.smart_toy_outlined; + if (lower.contains('dashboard') || lower.contains('cortex')) return Icons.dashboard_outlined; + if (lower.contains('doc')) return Icons.description_outlined; + final hash = name.codeUnits.fold(0, (prev, c) => prev + c); + return _projectIcons[hash % _projectIcons.length]; +} + +// ─── Main widget ───────────────────────────────────────────── + +class HomeScreen extends StatefulWidget { + final VoidCallback onSessionLoaded; + const HomeScreen({super.key, required this.onSessionLoaded}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + List<_Project> _projects = []; + bool _scanning = true; + String _searchQuery = ''; + _Project? _selectedProject; + + @override + void initState() { + super.initState(); + _scanProjects(); + } + + /// Converts the encoded dir name to a human-readable project name. + /// "-Users-mathias-Documents-workspaces-svrnty-talos-rpi5" → "svrnty / talos-rpi5" + /// "-Users-mathias" → "~ (home)" + /// "-Applications-Auto-Claude-app-Contents-Resources-backend" → "Auto-Claude / backend" + String _parsePrettyName(String dirName) { + // Reconstruct the original path: leading - is /, inner - are / + // But careful: "a-gent-maf-debug" is a single folder name with dashes. + // The trick: the encoded path uses - as separator for EVERY path component. + // We know the common prefixes so we can strip them. + String path = dirName; + + // Strip known prefixes to get to the interesting part + final prefixes = [ + '-Users-mathias-Documents-workspaces-', + '-Users-mathias-Documents-', + '-Users-mathias-', + '-Applications-', + ]; + + String prefix = ''; + for (final p in prefixes) { + if (path.startsWith(p)) { + prefix = p; + path = path.substring(p.length); + break; + } + } + + if (path.isEmpty) { + if (dirName == '-Users-mathias') return '~ (home)'; + return dirName; + } + + // Now `path` is something like "svrnty-talos-rpi5--out-fondation" + // Double dashes (--) were actual dashes in folder names? No — they represent + // a subfolder that itself has a dash. We need to figure out the actual + // filesystem path. Let's just check if the reconstructed path exists. + final home = Platform.environment['HOME'] ?? ''; + final reconstructed = _reconstructPath(dirName, home); + if (reconstructed != null) { + // Get last 2 meaningful segments + final segs = reconstructed.split('/').where((s) => s.isNotEmpty).toList(); + // Remove common uninteresting segments + final skip = {'Users', 'mathias', 'Documents', 'workspaces', 'Applications', 'Contents', 'Resources'}; + final meaningful = segs.where((s) => !skip.contains(s)).toList(); + if (meaningful.length >= 2) { + return '${meaningful[meaningful.length - 2]} / ${meaningful.last}'; + } + if (meaningful.isNotEmpty) return meaningful.last; + } + + // Fallback: just clean up the raw name + return path.replaceAll('--', ' / ').replaceAll('-', ' '); + } + + /// Try to reconstruct the actual filesystem path from the encoded dir name. + String? _reconstructPath(String dirName, String home) { + // The encoding is: replace / with - and prepend - + // So /Users/mathias/foo becomes -Users-mathias-foo + // Problem: folder names with dashes are ambiguous. + // Strategy: try splitting greedily from left, checking if dirs exist. + if (!dirName.startsWith('-')) return null; + final candidate = dirName.replaceFirst('-', '/'); + + // Try the obvious: replace all - with / + final simple = candidate.replaceAll('-', '/'); + if (Directory(simple).existsSync()) return simple; + + // Otherwise, do a greedy left-to-right directory walk + final parts = dirName.substring(1).split('-'); + String current = ''; + final resolved = []; + + for (int i = 0; i < parts.length; i++) { + if (current.isEmpty) { + current = parts[i]; + } else { + current = '$current-${parts[i]}'; + } + + final testPath = '/${resolved.join('/')}/$current'; + if (Directory(testPath).existsSync() || File(testPath).existsSync()) { + resolved.add(current); + current = ''; + } + } + if (current.isNotEmpty) resolved.add(current); + + final result = '/${resolved.join('/')}'; + if (Directory(result).existsSync()) return result; + return result; // return anyway as best guess + } + + Future _scanProjects() async { + final home = Platform.environment['HOME']; + if (home == null) return; + final claudeDir = Directory('$home/.claude/projects'); + if (!await claudeDir.exists()) { + setState(() => _scanning = false); + return; + } + + final projects = <_Project>[]; + + await for (final projectDir in claudeDir.list()) { + if (projectDir is! Directory) continue; + final dirName = projectDir.path.split('/').last; + + final sessions = <_SessionFile>[]; + + // Scan .jsonl files in the project dir + try { + await for (final entity in projectDir.list()) { + if (entity is File && + entity.path.endsWith('.jsonl') && + !entity.path.endsWith('sessions-index.json')) { + try { + final stat = entity.statSync(); + final fileName = entity.path.split('/').last; + sessions.add(_SessionFile( + file: entity, + stat: stat, + sessionId: fileName.replaceAll('.jsonl', ''), + )); + } catch (_) {} + } + } + } catch (_) {} + + // Also scan sessions/ subdirectory + final sessionsDir = Directory('${projectDir.path}/sessions'); + if (await sessionsDir.exists()) { + try { + await for (final entity in sessionsDir.list()) { + if (entity is File && entity.path.endsWith('.jsonl')) { + try { + final stat = entity.statSync(); + final fileName = entity.path.split('/').last; + sessions.add(_SessionFile( + file: entity, + stat: stat, + sessionId: fileName.replaceAll('.jsonl', ''), + )); + } catch (_) {} + } + } + } catch (_) {} + } + + if (sessions.isEmpty) continue; + + sessions.sort((a, b) => b.stat.modified.compareTo(a.stat.modified)); + + final fullPath = _reconstructPath(dirName, home) ?? dirName; + + projects.add(_Project( + dirPath: projectDir.path, + rawDirName: dirName, + displayName: _parsePrettyName(dirName), + fullPath: fullPath, + sessions: sessions, + lastModified: sessions.first.stat.modified, + )); + } + + projects.sort((a, b) => b.lastModified.compareTo(a.lastModified)); + + if (mounted) { + setState(() { + _projects = projects; + _scanning = false; + }); + } + } + + Future _pickFile() async { + final home = Platform.environment['HOME'] ?? ''; + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jsonl'], + initialDirectory: '$home/.claude/projects', + ); + if (result != null && result.files.single.path != null) { + await _loadFile(result.files.single.path!); + } + } + + Future _loadFile(String path) async { + final provider = context.read(); + await provider.loadSession(path); + if (provider.error == null && mounted) { + widget.onSessionLoaded(); + } + } + + List<_Project> get _filteredProjects { + if (_searchQuery.isEmpty) return _projects; + final q = _searchQuery.toLowerCase(); + return _projects.where((p) => + p.displayName.toLowerCase().contains(q) || + p.fullPath.toLowerCase().contains(q)).toList(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Scaffold( + backgroundColor: AppColors.background, + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header with breadcrumb + _buildHeader(provider), + const SizedBox(height: 16), + _buildSearchBar(), + const SizedBox(height: 16), + + if (provider.isLoading) + const Padding( + padding: EdgeInsets.only(bottom: 12), + child: LinearProgressIndicator( + color: AppColors.assistant, + backgroundColor: AppColors.surfaceLight, + ), + ), + + if (provider.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.errorBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.error.withAlpha(80)), + ), + child: Text( + provider.error!, + style: const TextStyle(color: AppColors.error, fontSize: 13), + ), + ), + ), + + // Content + if (_scanning) + const Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: AppColors.assistant), + SizedBox(height: 12), + Text('Scanning projects...', + style: TextStyle(color: AppColors.textMuted, fontSize: 13)), + ], + ), + ), + ) + else if (_selectedProject != null) + Expanded(child: _buildSessionList(_selectedProject!)) + else + Expanded(child: _buildProjectGrid()), + ], + ), + ), + ); + } + + // ─── Header with breadcrumb ────────────────────────────── + + Widget _buildHeader(SessionProvider provider) { + return Row( + children: [ + const Icon(Icons.terminal, size: 24, color: AppColors.assistant), + const SizedBox(width: 10), + // Breadcrumb + InkWell( + onTap: () => setState(() => _selectedProject = null), + borderRadius: BorderRadius.circular(4), + child: Text( + 'Projects', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: _selectedProject != null + ? AppColors.assistant + : AppColors.textPrimary, + decoration: _selectedProject != null + ? TextDecoration.underline + : TextDecoration.none, + decorationColor: AppColors.assistant, + ), + ), + ), + if (_selectedProject != null) ...[ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.chevron_right, size: 18, color: AppColors.textMuted), + ), + Flexible( + child: Text( + _selectedProject!.displayName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + const Spacer(), + Text( + _selectedProject != null + ? '${_selectedProject!.sessionCount} session${_selectedProject!.sessionCount == 1 ? '' : 's'}' + : '${_projects.length} projects', + style: const TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _pickFile, + icon: const Icon(Icons.folder_open, size: 14), + label: const Text('Browse'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.surfaceLight, + foregroundColor: AppColors.textPrimary, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6), + side: const BorderSide(color: AppColors.surfaceBorder), + ), + textStyle: const TextStyle(fontSize: 12), + ), + ), + ], + ); + } + + // ─── Search bar ────────────────────────────────────────── + + Widget _buildSearchBar() { + return TextField( + onChanged: (v) => setState(() => _searchQuery = v), + style: const TextStyle(fontSize: 13, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: _selectedProject != null + ? 'Search sessions...' + : 'Search projects...', + hintStyle: const TextStyle(fontSize: 13, color: AppColors.textMuted), + prefixIcon: const Icon(Icons.search, size: 18, color: AppColors.textMuted), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.surfaceBorder), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.surfaceBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.assistant), + ), + ), + ); + } + + // ─── Project grid ──────────────────────────────────────── + + Widget _buildProjectGrid() { + final filtered = _filteredProjects; + if (filtered.isEmpty) { + return const Center( + child: Text('No projects found', style: TextStyle(color: AppColors.textMuted)), + ); + } + + return LayoutBuilder(builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth > 900 + ? 4 + : constraints.maxWidth > 600 + ? 3 + : 2; + + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.6, + ), + itemCount: filtered.length, + itemBuilder: (context, index) => _ProjectCard( + project: filtered[index], + onTap: () => setState(() { + _selectedProject = filtered[index]; + _searchQuery = ''; + }), + ), + ); + }); + } + + // ─── Session list (inside a project) ───────────────────── + + Widget _buildSessionList(_Project project) { + final sessions = _searchQuery.isEmpty + ? project.sessions + : project.sessions.where((s) => + s.sessionId.toLowerCase().contains(_searchQuery.toLowerCase())).toList(); + + if (sessions.isEmpty) { + return const Center( + child: Text('No matching sessions', style: TextStyle(color: AppColors.textMuted)), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Project info bar + Container( + padding: const EdgeInsets.all(14), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.surfaceBorder), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _colorForProject(project.displayName).withAlpha(25), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _iconForProject(project.displayName), + size: 18, + color: _colorForProject(project.displayName), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project.displayName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + Text( + project.fullPath, + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontFamily: 'JetBrains Mono', + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + // Session list + Expanded( + child: ListView.builder( + itemCount: sessions.length, + itemBuilder: (context, index) { + final session = sessions[index]; + return _SessionTile( + session: session, + index: index, + isActive: context.read().filePath == session.file.path, + onTap: () => _loadFile(session.file.path), + ); + }, + ), + ), + ], + ); + } + + // ─── Formatting helpers ────────────────────────────────── + + static String formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return '${months[date.month - 1]} ${date.day}'; + } + + static String formatDateTime(DateTime date) { + final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return '${months[date.month - 1]} ${date.day}, ${date.year} at ' + '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; + } + + static String formatSize(int bytes) { + final kb = bytes / 1024; + final mb = kb / 1024; + if (mb >= 1) return '${mb.toStringAsFixed(1)} MB'; + return '${kb.toStringAsFixed(0)} KB'; + } +} + +// ─── Project card widget ─────────────────────────────────── + +class _ProjectCard extends StatefulWidget { + final _Project project; + final VoidCallback onTap; + + const _ProjectCard({required this.project, required this.onTap}); + + @override + State<_ProjectCard> createState() => _ProjectCardState(); +} + +class _ProjectCardState extends State<_ProjectCard> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final color = _colorForProject(widget.project.displayName); + final icon = _iconForProject(widget.project.displayName); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _hovered ? AppColors.surfaceLight : AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _hovered ? color.withAlpha(60) : AppColors.surfaceBorder, + width: _hovered ? 1.5 : 1, + ), + boxShadow: _hovered + ? [BoxShadow(color: color.withAlpha(15), blurRadius: 20, spreadRadius: 2)] + : null, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withAlpha(25), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: color), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withAlpha(15), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${widget.project.sessionCount}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ), + ], + ), + const Spacer(), + Text( + widget.project.displayName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + _HomeScreenState.formatDate(widget.project.lastModified), + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + ), + ); + } +} + +// ─── Session tile widget ─────────────────────────────────── + +class _SessionTile extends StatefulWidget { + final _SessionFile session; + final int index; + final bool isActive; + final VoidCallback onTap; + + const _SessionTile({ + required this.session, + required this.index, + required this.isActive, + required this.onTap, + }); + + @override + State<_SessionTile> createState() => _SessionTileState(); +} + +class _SessionTileState extends State<_SessionTile> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final dt = widget.session.stat.modified; + final isActive = widget.isActive; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + margin: const EdgeInsets.only(bottom: 6), + decoration: BoxDecoration( + color: isActive + ? AppColors.assistant.withAlpha(12) + : _hovered + ? AppColors.surfaceLight + : AppColors.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isActive + ? AppColors.assistant.withAlpha(50) + : _hovered + ? AppColors.surfaceBorder.withAlpha(200) + : AppColors.surfaceBorder, + ), + ), + child: Row( + children: [ + // Session number circle + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isActive + ? AppColors.assistant.withAlpha(30) + : AppColors.surfaceLight, + borderRadius: BorderRadius.circular(18), + ), + child: Center( + child: Text( + '${widget.index + 1}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isActive ? AppColors.assistant : AppColors.textSecondary, + ), + ), + ), + ), + const SizedBox(width: 14), + // Session info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _HomeScreenState.formatDateTime(dt), + style: TextStyle( + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, + color: isActive ? AppColors.assistant : AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + widget.session.sessionId, + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontFamily: 'JetBrains Mono', + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + // Size + relative time + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _HomeScreenState.formatSize(widget.session.stat.size), + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontFamily: 'JetBrains Mono', + ), + ), + const SizedBox(height: 2), + Text( + _HomeScreenState.formatDate(dt), + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + ), + ), + ], + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + size: 18, + color: isActive + ? AppColors.assistant + : _hovered + ? AppColors.textSecondary + : Colors.transparent, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/timeline/timeline_screen.dart b/lib/screens/timeline/timeline_screen.dart new file mode 100644 index 0000000..4f6e874 --- /dev/null +++ b/lib/screens/timeline/timeline_screen.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/log_entry.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; +import 'widgets/assistant_message_card.dart'; +import 'widgets/system_message_card.dart'; +import 'widgets/user_message_card.dart'; + +class TimelineScreen extends StatelessWidget { + const TimelineScreen({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final session = provider.session; + if (session == null) { + return const Center( + child: Text('No session loaded', style: TextStyle(color: AppColors.textMuted)), + ); + } + + final entries = provider.filteredEntries; + + return Scaffold( + backgroundColor: AppColors.background, + body: Column( + children: [ + _FilterBar(provider: provider), + Expanded( + child: entries.isEmpty + ? const Center( + child: Text('No matching entries', + style: TextStyle(color: AppColors.textMuted)), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 24), + itemCount: entries.length, + itemBuilder: (context, index) { + return _buildEntryCard(entries[index], index); + }, + ), + ), + ], + ), + ); + } + + Widget _buildEntryCard(LogEntry entry, int index) { + if (entry is UserEntry && !entry.isToolResult) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: UserMessageCard(entry: entry, index: index), + ); + } + if (entry is AssistantEntry) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: AssistantMessageCard(entry: entry, index: index), + ); + } + if (entry is SystemEntry) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SystemMessageCard(entry: entry), + ); + } + return const SizedBox.shrink(); + } +} + +class _FilterBar extends StatelessWidget { + final SessionProvider provider; + + const _FilterBar({required this.provider}); + + @override + Widget build(BuildContext context) { + final session = provider.session!; + + return Container( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 12), + decoration: const BoxDecoration( + color: AppColors.surface, + border: Border( + bottom: BorderSide(color: AppColors.surfaceBorder, width: 1), + ), + ), + child: Row( + children: [ + // Session info + Expanded( + child: Row( + children: [ + Text( + session.sessionId?.substring(0, 8) ?? 'Session', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 12), + if (session.version != null) + _InfoChip(label: 'v${session.version}'), + const SizedBox(width: 8), + _InfoChip( + label: '${provider.filteredEntries.length} entries'), + const SizedBox(width: 8), + _InfoChip( + label: + '${_formatTokens(session.totalUsage.totalTokens)} tokens'), + ], + ), + ), + // Agent filter + if (session.agents.length > 1) ...[ + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.surfaceBorder), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: provider.selectedAgentId, + isDense: true, + dropdownColor: AppColors.surfaceLight, + style: const TextStyle( + fontSize: 12, color: AppColors.textPrimary), + hint: const Text('All Agents', + style: TextStyle( + fontSize: 12, color: AppColors.textSecondary)), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All Agents'), + ), + ...session.agents.map((a) => DropdownMenuItem( + value: a.id, + child: Text(a.name, + overflow: TextOverflow.ellipsis), + )), + ], + onChanged: (v) => provider.selectAgent(v), + ), + ), + ), + ], + const SizedBox(width: 12), + // Type toggles + _TypeToggle( + label: 'User', + color: AppColors.user, + active: provider.visibleTypes.contains('user'), + onTap: () => provider.toggleTypeFilter('user'), + ), + const SizedBox(width: 4), + _TypeToggle( + label: 'Assistant', + color: AppColors.assistant, + active: provider.visibleTypes.contains('assistant'), + onTap: () => provider.toggleTypeFilter('assistant'), + ), + const SizedBox(width: 4), + _TypeToggle( + label: 'System', + color: AppColors.system, + active: provider.visibleTypes.contains('system'), + onTap: () => provider.toggleTypeFilter('system'), + ), + const SizedBox(width: 12), + // Search + SizedBox( + width: 180, + child: TextField( + onChanged: provider.setSearchQuery, + style: const TextStyle(fontSize: 12, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'Search...', + hintStyle: + const TextStyle(fontSize: 12, color: AppColors.textMuted), + prefixIcon: const Icon(Icons.search, + size: 16, color: AppColors.textMuted), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + filled: true, + fillColor: AppColors.surfaceLight, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: + const BorderSide(color: AppColors.surfaceBorder), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: + const BorderSide(color: AppColors.surfaceBorder), + ), + ), + ), + ), + ], + ), + ); + } + + String _formatTokens(int tokens) { + if (tokens >= 1000000) return '${(tokens / 1000000).toStringAsFixed(1)}M'; + if (tokens >= 1000) return '${(tokens / 1000).toStringAsFixed(1)}K'; + return '$tokens'; + } +} + +class _InfoChip extends StatelessWidget { + final String label; + const _InfoChip({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ); + } +} + +class _TypeToggle extends StatelessWidget { + final String label; + final Color color; + final bool active; + final VoidCallback onTap; + + const _TypeToggle({ + required this.label, + required this.color, + required this.active, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: active ? color.withAlpha(25) : Colors.transparent, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: active ? color.withAlpha(80) : AppColors.surfaceBorder, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: active ? color : AppColors.textMuted, + fontWeight: active ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ); + } +} diff --git a/lib/screens/timeline/widgets/assistant_message_card.dart b/lib/screens/timeline/widgets/assistant_message_card.dart new file mode 100644 index 0000000..49a1320 --- /dev/null +++ b/lib/screens/timeline/widgets/assistant_message_card.dart @@ -0,0 +1,706 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:provider/provider.dart'; +import '../../../models/agent_info.dart'; +import '../../../models/content_block.dart'; +import '../../../models/log_entry.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 AssistantMessageCard extends StatelessWidget { + final AssistantEntry entry; + final int index; + + const AssistantMessageCard({ + super.key, + required this.entry, + required this.index, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.assistantBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.assistant.withAlpha(40)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 8), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: AppColors.assistant.withAlpha(40), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(Icons.smart_toy, + size: 14, color: AppColors.assistant), + ), + const SizedBox(width: 8), + Text( + entry.model ?? 'Assistant', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.assistant, + ), + ), + const SizedBox(width: 8), + Text( + '#$index', + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted), + ), + const Spacer(), + if (entry.usage != null) ...[ + _TokenBadge( + label: 'in', + value: entry.usage!.inputTokens, + color: AppColors.user, + ), + const SizedBox(width: 6), + _TokenBadge( + label: 'out', + value: entry.usage!.outputTokens, + color: AppColors.assistant, + ), + if (entry.usage!.cacheReadInputTokens > 0) ...[ + const SizedBox(width: 6), + _TokenBadge( + label: 'cache', + value: entry.usage!.cacheReadInputTokens, + color: AppColors.tool, + ), + ], + const SizedBox(width: 12), + ], + if (entry.timestamp != null) + Text( + _formatTime(entry.timestamp!), + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + + // Thinking blocks + if (entry.hasThinking) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 6), + child: ExpandableCard( + backgroundColor: AppColors.surfaceLight, + borderColor: AppColors.surfaceBorder, + header: Row( + children: [ + const Icon(Icons.psychology, + size: 14, color: AppColors.agent), + const SizedBox(width: 6), + const Text( + 'Thinking', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.agent, + ), + ), + const SizedBox(width: 8), + Text( + entry.thinkingBlocks.first.thinking.isEmpty + ? '(encrypted)' + : '${entry.thinkingBlocks.first.thinking.length} chars', + style: const TextStyle( + fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + child: entry.thinkingBlocks.first.thinking.isEmpty + ? const Text( + 'Thinking content is encrypted and not available.', + style: TextStyle( + fontSize: 12, + color: AppColors.textMuted, + fontStyle: FontStyle.italic, + ), + ) + : SelectableText( + entry.thinkingBlocks.first.thinking, + style: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ), + + // Text blocks + for (final block in entry.textBlocks) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: MarkdownBody( + data: block.text, + selectable: true, + styleSheet: MarkdownStyleSheet( + p: const TextStyle( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.5, + ), + code: const TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + color: AppColors.tool, + backgroundColor: AppColors.surfaceLight, + ), + codeblockDecoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.surfaceBorder), + ), + codeblockPadding: const EdgeInsets.all(12), + h1: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary), + h2: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary), + h3: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary), + listBullet: const TextStyle( + fontSize: 13, color: AppColors.textSecondary), + blockquoteDecoration: BoxDecoration( + border: Border( + left: BorderSide( + color: AppColors.assistant.withAlpha(80), width: 3), + ), + ), + ), + ), + ), + + // Tool use blocks + if (entry.hasToolUse) + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: Column( + children: entry.toolUseBlocks + .map((block) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _ToolUseCard(block: block), + )) + .toList(), + ), + ), + + // Raw JSON expandable + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 8), + child: ExpandableCard( + backgroundColor: AppColors.surface, + borderColor: AppColors.surfaceBorder, + header: const Row( + children: [ + Icon(Icons.data_object, size: 14, color: AppColors.textMuted), + SizedBox(width: 6), + Text( + 'Raw JSON', + style: TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + child: JsonTreeView(data: entry.raw), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime dt) { + return '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } +} + +// ─── Tool use card (handles both regular tools and Agent calls) ─── + +class _ToolUseCard extends StatelessWidget { + final ToolUseBlock block; + + const _ToolUseCard({required this.block}); + + @override + Widget build(BuildContext context) { + final isAgent = block.isAgentCall; + final color = isAgent ? AppColors.agent : AppColors.tool; + final bgColor = isAgent ? AppColors.agentBg : AppColors.toolBg; + final hasResult = block.linkedResult != null; + final isError = block.linkedResult?.isError ?? false; + + // Look up subagent info if this is an Agent call + AgentInfo? subagent; + if (isAgent) { + final session = context.read().session; + subagent = session?.findAgentForToolUse(block.id); + } + + return ExpandableCard( + backgroundColor: bgColor, + borderColor: color.withAlpha(40), + header: Row( + children: [ + Icon( + isAgent ? Icons.smart_toy_outlined : Icons.build_outlined, + size: 14, + color: color, + ), + const SizedBox(width: 6), + Text( + block.name, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, + ), + ), + if (isAgent && block.subagentType != null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withAlpha(20), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + block.subagentType!, + style: TextStyle(fontSize: 10, color: color), + ), + ), + ], + if (isAgent && block.agentDescription != null) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + block.agentDescription!, + style: const TextStyle( + fontSize: 11, color: AppColors.textSecondary), + overflow: TextOverflow.ellipsis, + ), + ), + ] else + const Spacer(), + // Subagent stats + if (subagent != null) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + '${subagent.messages.where((m) => m.type == 'assistant').length} turns', + style: const TextStyle(fontSize: 10, color: AppColors.textMuted), + ), + ), + const SizedBox(width: 4), + if (subagent.toolsUsed.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + '${subagent.toolsUsed.length} tools', + style: const TextStyle(fontSize: 10, color: AppColors.textMuted), + ), + ), + const SizedBox(width: 4), + ], + 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: [ + // Arguments + const Text( + 'ARGUMENTS', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 1, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(6), + ), + child: JsonTreeView( + data: block.input, + initiallyExpanded: true, + ), + ), + + // Subagent conversation (inline nested timeline) + if (subagent != null) ...[ + const SizedBox(height: 12), + _SubagentTimeline(agent: subagent), + ], + + // Result + if (hasResult) ...[ + const SizedBox(height: 12), + Text( + isError ? 'ERROR' : 'RESULT', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: isError ? AppColors.error : AppColors.textMuted, + letterSpacing: 1, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(maxHeight: 400), + decoration: BoxDecoration( + color: isError ? AppColors.errorBg : AppColors.surface, + borderRadius: BorderRadius.circular(6), + border: isError + ? Border.all(color: AppColors.error.withAlpha(40)) + : null, + ), + child: SingleChildScrollView( + child: SelectableText( + block.linkedResult!.textContent, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + color: isError + ? AppColors.error + : AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ), + ], + + // Raw tool_use JSON + const SizedBox(height: 12), + ExpandableCard( + backgroundColor: AppColors.surface, + borderColor: AppColors.surfaceBorder, + header: const Row( + children: [ + Icon(Icons.data_object, size: 12, color: AppColors.textMuted), + SizedBox(width: 4), + Text( + 'Raw Tool JSON', + style: TextStyle(fontSize: 10, color: AppColors.textMuted), + ), + ], + ), + child: JsonTreeView(data: block.raw), + ), + ], + ), + ); + } +} + +// ─── Inline subagent conversation ──────────────────────────── + +class _SubagentTimeline extends StatelessWidget { + final AgentInfo agent; + + const _SubagentTimeline({required this.agent}); + + @override + Widget build(BuildContext context) { + // Filter to user and assistant messages only + final messages = agent.messages + .where((m) => m.type == 'user' || m.type == 'assistant') + .toList(); + + if (messages.isEmpty) { + return const Text( + 'No subagent conversation data available.', + style: TextStyle(fontSize: 12, color: AppColors.textMuted, fontStyle: FontStyle.italic), + ); + } + + return ExpandableCard( + backgroundColor: AppColors.agentBg, + borderColor: AppColors.agent.withAlpha(30), + header: Row( + children: [ + const Icon(Icons.account_tree_outlined, size: 14, color: AppColors.agent), + const SizedBox(width: 6), + Text( + 'Subagent Conversation (${messages.length} messages)', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.agent, + ), + ), + if (agent.model != null) ...[ + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppColors.surfaceLight, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + agent.model!, + style: const TextStyle( + fontSize: 10, + color: AppColors.textSecondary, + fontFamily: 'JetBrains Mono', + ), + ), + ), + ], + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Toolbelt summary + if (agent.uniqueToolNames.isNotEmpty) ...[ + Wrap( + spacing: 4, + runSpacing: 4, + children: agent.toolUsageCounts.entries.map((e) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.tool.withAlpha(15), + borderRadius: BorderRadius.circular(3), + border: Border.all(color: AppColors.tool.withAlpha(30)), + ), + child: Text( + '${e.key} (${e.value})', + style: const TextStyle( + fontSize: 10, + color: AppColors.tool, + fontFamily: 'JetBrains Mono', + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 10), + ], + // Messages + for (int i = 0; i < messages.length; i++) ...[ + _SubagentMessageTile(entry: messages[i], index: i), + if (i < messages.length - 1) const SizedBox(height: 4), + ], + ], + ), + ); + } +} + +class _SubagentMessageTile extends StatelessWidget { + final LogEntry entry; + final int index; + + const _SubagentMessageTile({required this.entry, required this.index}); + + @override + Widget build(BuildContext context) { + if (entry is UserEntry) { + return _buildUserTile(entry as UserEntry); + } + if (entry is AssistantEntry) { + return _buildAssistantTile(entry as AssistantEntry); + } + return const SizedBox.shrink(); + } + + Widget _buildUserTile(UserEntry user) { + if (user.isToolResult) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.userBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.user.withAlpha(25)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.person, size: 12, color: AppColors.user), + const SizedBox(width: 6), + Expanded( + child: SelectableText( + user.promptText, + style: const TextStyle(fontSize: 12, color: AppColors.textPrimary, height: 1.4), + ), + ), + ], + ), + ); + } + + Widget _buildAssistantTile(AssistantEntry assistant) { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppColors.surfaceBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header row + Row( + children: [ + const Icon(Icons.smart_toy, size: 12, color: AppColors.assistant), + const SizedBox(width: 6), + Text( + assistant.model ?? 'assistant', + style: const TextStyle(fontSize: 10, color: AppColors.assistant, fontWeight: FontWeight.w600), + ), + const Spacer(), + if (assistant.usage != null) + Text( + 'in:${_fmt(assistant.usage!.inputTokens)} out:${_fmt(assistant.usage!.outputTokens)}', + style: const TextStyle(fontSize: 9, color: AppColors.textMuted, fontFamily: 'JetBrains Mono'), + ), + ], + ), + // Text content + for (final block in assistant.textBlocks) ...[ + const SizedBox(height: 6), + SelectableText( + block.text, + style: const TextStyle(fontSize: 12, color: AppColors.textPrimary, height: 1.4), + ), + ], + // Tool uses + for (final tool in assistant.toolUseBlocks) ...[ + const SizedBox(height: 6), + ExpandableCard( + backgroundColor: AppColors.toolBg, + borderColor: AppColors.tool.withAlpha(30), + header: Row( + children: [ + const Icon(Icons.build_outlined, size: 12, color: AppColors.tool), + const SizedBox(width: 4), + Text( + tool.name, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.tool), + ), + const Spacer(), + if (tool.linkedResult != null) + Icon( + tool.linkedResult!.isError ? Icons.error_outline : Icons.check_circle_outline, + size: 12, + color: tool.linkedResult!.isError ? AppColors.error : AppColors.assistant, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + JsonTreeView(data: tool.input, initiallyExpanded: true), + if (tool.linkedResult != null) ...[ + const SizedBox(height: 8), + Container( + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: SelectableText( + tool.linkedResult!.textContent, + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 11, + color: tool.linkedResult!.isError ? AppColors.error : AppColors.textSecondary, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ], + ), + ); + } + + String _fmt(int v) { + if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K'; + return '$v'; + } +} + +// ─── Token badge ───────────────────────────────────────────── + +class _TokenBadge extends StatelessWidget { + final String label; + final int value; + final Color color; + + const _TokenBadge({ + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withAlpha(15), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + '$label: ${_format(value)}', + style: TextStyle( + fontSize: 10, + color: color.withAlpha(200), + fontFamily: 'JetBrains Mono', + ), + ), + ); + } + + String _format(int v) { + if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K'; + return '$v'; + } +} diff --git a/lib/screens/timeline/widgets/system_message_card.dart b/lib/screens/timeline/widgets/system_message_card.dart new file mode 100644 index 0000000..2517ef1 --- /dev/null +++ b/lib/screens/timeline/widgets/system_message_card.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import '../../../models/log_entry.dart'; +import '../../../theme/app_theme.dart'; +import '../../../widgets/common/expandable_card.dart'; +import '../../../widgets/common/json_tree_view.dart'; + +class SystemMessageCard extends StatelessWidget { + final SystemEntry entry; + + const SystemMessageCard({super.key, required this.entry}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.systemBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.system.withAlpha(30)), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.info_outline, + size: 14, color: AppColors.system), + const SizedBox(width: 6), + Text( + entry.subtype ?? 'System', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.system, + ), + ), + if (entry.durationMs != null) ...[ + const Spacer(), + Text( + '${(entry.durationMs! / 1000).toStringAsFixed(1)}s', + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + fontFamily: 'JetBrains Mono', + ), + ), + ], + ], + ), + const SizedBox(height: 6), + ExpandableCard( + backgroundColor: AppColors.surface, + borderColor: AppColors.surfaceBorder, + header: const Text( + 'Raw', + style: TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + child: JsonTreeView(data: entry.raw), + ), + ], + ), + ); + } +} diff --git a/lib/screens/timeline/widgets/user_message_card.dart b/lib/screens/timeline/widgets/user_message_card.dart new file mode 100644 index 0000000..203ef76 --- /dev/null +++ b/lib/screens/timeline/widgets/user_message_card.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import '../../../models/log_entry.dart'; +import '../../../theme/app_theme.dart'; + +class UserMessageCard extends StatelessWidget { + final UserEntry entry; + final int index; + + const UserMessageCard({super.key, required this.entry, required this.index}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AppColors.userBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.user.withAlpha(40)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 8), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: AppColors.user.withAlpha(40), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(Icons.person, size: 14, color: AppColors.user), + ), + const SizedBox(width: 8), + const Text( + 'User', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.user, + ), + ), + const SizedBox(width: 8), + Text( + '#$index', + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + const Spacer(), + if (entry.timestamp != null) + Text( + _formatTime(entry.timestamp!), + style: const TextStyle(fontSize: 11, color: AppColors.textMuted), + ), + ], + ), + ), + // Content + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: SelectableText( + entry.promptText, + style: const TextStyle( + fontSize: 13, + color: AppColors.textPrimary, + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime dt) { + return '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/screens/tokens/tokens_screen.dart b/lib/screens/tokens/tokens_screen.dart new file mode 100644 index 0000000..3c863eb --- /dev/null +++ b/lib/screens/tokens/tokens_screen.dart @@ -0,0 +1,374 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/log_entry.dart'; +import '../../providers/session_provider.dart'; +import '../../theme/app_theme.dart'; + +class TokensScreen extends StatelessWidget { + const TokensScreen({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final session = provider.session; + if (session == null) { + return const Center( + child: Text('No session loaded', + style: TextStyle(color: AppColors.textMuted)), + ); + } + + final usage = session.totalUsage; + final assistantEntries = session.allEntries + .whereType() + .where((e) => e.usage != null) + .toList(); + + return Scaffold( + backgroundColor: AppColors.background, + body: ListView( + padding: const EdgeInsets.all(24), + children: [ + const Text( + 'Token Usage', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 24), + + // Summary cards + Row( + children: [ + Expanded( + child: _TokenCard( + label: 'Input Tokens', + value: usage.inputTokens, + color: AppColors.user, + icon: Icons.arrow_downward, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _TokenCard( + label: 'Output Tokens', + value: usage.outputTokens, + color: AppColors.assistant, + icon: Icons.arrow_upward, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _TokenCard( + label: 'Cache Created', + value: usage.cacheCreationInputTokens, + color: AppColors.tool, + icon: Icons.cached, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _TokenCard( + label: 'Cache Read', + value: usage.cacheReadInputTokens, + color: AppColors.agent, + icon: Icons.speed, + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.surfaceBorder), + ), + child: Row( + children: [ + const Text( + 'Total Tokens: ', + style: TextStyle( + fontSize: 14, color: AppColors.textSecondary), + ), + Text( + _formatTokens(usage.totalTokens), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + if (session.duration != null) ...[ + const Text( + 'Session Duration: ', + style: TextStyle( + fontSize: 14, color: AppColors.textSecondary), + ), + Text( + _formatDuration(session.duration!), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + ), + ], + ], + ), + ), + const SizedBox(height: 24), + + // Per-agent breakdown + if (session.agents.length > 1) ...[ + const Text( + 'TOKENS BY AGENT', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 12), + Container( + height: 200, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.surfaceBorder), + ), + child: Row( + children: [ + Expanded( + child: PieChart( + PieChartData( + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: session.agents + .where( + (a) => a.aggregatedUsage.totalTokens > 0) + .toList() + .asMap() + .entries + .map((e) { + final agent = e.value; + final color = AppColors.chartPalette[ + e.key % AppColors.chartPalette.length]; + return PieChartSectionData( + color: color, + value: + agent.aggregatedUsage.totalTokens.toDouble(), + title: '', + radius: 30, + ); + }).toList(), + ), + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: session.agents + .where( + (a) => a.aggregatedUsage.totalTokens > 0) + .toList() + .asMap() + .entries + .map((e) { + final agent = e.value; + final color = AppColors.chartPalette[ + e.key % AppColors.chartPalette.length]; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + agent.name, + style: const TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + _formatTokens( + agent.aggregatedUsage.totalTokens), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + fontFamily: 'JetBrains Mono', + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + + // Per-message table + const Text( + 'PER-MESSAGE BREAKDOWN', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.surfaceBorder), + ), + child: DataTable( + headingRowColor: WidgetStateProperty.all(AppColors.surfaceLight), + dataRowColor: WidgetStateProperty.all(Colors.transparent), + columnSpacing: 24, + headingTextStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + ), + dataTextStyle: const TextStyle( + fontSize: 12, + color: AppColors.textPrimary, + fontFamily: 'JetBrains Mono', + ), + columns: const [ + DataColumn(label: Text('#')), + DataColumn(label: Text('Time')), + DataColumn(label: Text('Model')), + DataColumn(label: Text('Input'), numeric: true), + DataColumn(label: Text('Output'), numeric: true), + DataColumn(label: Text('Cache Create'), numeric: true), + DataColumn(label: Text('Cache Read'), numeric: true), + DataColumn(label: Text('Total'), numeric: true), + ], + rows: assistantEntries.asMap().entries.map((e) { + final entry = e.value; + final u = entry.usage!; + return DataRow(cells: [ + DataCell(Text('${e.key + 1}')), + DataCell(Text(entry.timestamp != null + ? '${entry.timestamp!.hour.toString().padLeft(2, '0')}:${entry.timestamp!.minute.toString().padLeft(2, '0')}:${entry.timestamp!.second.toString().padLeft(2, '0')}' + : '-')), + DataCell(Text(entry.model ?? '-', + style: const TextStyle(fontSize: 10))), + DataCell(Text(_formatTokens(u.inputTokens))), + DataCell(Text(_formatTokens(u.outputTokens))), + DataCell( + Text(_formatTokens(u.cacheCreationInputTokens))), + DataCell(Text(_formatTokens(u.cacheReadInputTokens))), + DataCell(Text( + _formatTokens(u.totalTokens), + style: const TextStyle(fontWeight: FontWeight.w600), + )), + ]); + }).toList(), + ), + ), + ], + ), + ); + } + + String _formatTokens(int tokens) { + if (tokens >= 1000000) return '${(tokens / 1000000).toStringAsFixed(1)}M'; + if (tokens >= 1000) return '${(tokens / 1000).toStringAsFixed(1)}K'; + return '$tokens'; + } + + String _formatDuration(Duration d) { + final minutes = d.inMinutes; + final seconds = d.inSeconds % 60; + if (minutes > 0) return '${minutes}m ${seconds}s'; + return '${seconds}s'; + } +} + +class _TokenCard extends StatelessWidget { + final String label; + final int value; + final Color color; + final IconData icon; + + const _TokenCard({ + required this.label, + required this.value, + required this.color, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.surfaceBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Text( + label, + style: + const TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + ], + ), + const SizedBox(height: 8), + Text( + _format(value), + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: color, + fontFamily: 'JetBrains Mono', + ), + ), + ], + ), + ); + } + + String _format(int v) { + if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)}M'; + if (v >= 1000) return '${(v / 1000).toStringAsFixed(1)}K'; + return '$v'; + } +} diff --git a/lib/screens/toolbelt/toolbelt_screen.dart b/lib/screens/toolbelt/toolbelt_screen.dart new file mode 100644 index 0000000..385c4fd --- /dev/null +++ b/lib/screens/toolbelt/toolbelt_screen.dart @@ -0,0 +1,286 @@ +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(); + 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(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 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(), + ), + ); + } +} diff --git a/lib/services/jsonl_parser.dart b/lib/services/jsonl_parser.dart new file mode 100644 index 0000000..28c0edd --- /dev/null +++ b/lib/services/jsonl_parser.dart @@ -0,0 +1,278 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../models/agent_info.dart'; +import '../models/content_block.dart'; +import '../models/log_entry.dart'; +import '../models/token_usage.dart'; + +class JsonlParser { + Future parseFile(String filePath) async { + final file = File(filePath); + final lines = await file.readAsLines(encoding: utf8); + + final entries = []; + for (final line in lines) { + if (line.trim().isEmpty) continue; + try { + final json = jsonDecode(line) as Map; + entries.add(LogEntry.fromJson(json)); + } catch (_) {} + } + + // Resolve the subagents directory from the session file path + // e.g. /path/to/project/64a8386a.jsonl → /path/to/project/64a8386a/subagents/ + final sessionFileName = file.uri.pathSegments.last.replaceAll('.jsonl', ''); + final parentDir = file.parent.path; + final subagentsDir = Directory('$parentDir/$sessionFileName/subagents'); + + // Load all subagent .jsonl files + final subagentFiles = >{}; + final subagentMeta = >{}; + if (await subagentsDir.exists()) { + await for (final entity in subagentsDir.list()) { + if (entity is File && entity.path.endsWith('.jsonl')) { + final agentFileName = entity.uri.pathSegments.last; + // agent-a014e30b71de602bb.jsonl → a014e30b71de602bb + final agentId = agentFileName + .replaceAll('.jsonl', '') + .replaceFirst('agent-', ''); + + final agentEntries = []; + try { + final agentLines = await entity.readAsLines(encoding: utf8); + for (final line in agentLines) { + if (line.trim().isEmpty) continue; + try { + final json = jsonDecode(line) as Map; + agentEntries.add(LogEntry.fromJson(json)); + } catch (_) {} + } + } catch (_) {} + if (agentEntries.isNotEmpty) { + subagentFiles[agentId] = agentEntries; + } + } else if (entity is File && entity.path.endsWith('.meta.json')) { + try { + final metaContent = await entity.readAsString(); + final meta = jsonDecode(metaContent) as Map; + final agentFileName = entity.uri.pathSegments.last; + final agentId = agentFileName + .replaceAll('.meta.json', '') + .replaceFirst('agent-', ''); + subagentMeta[agentId] = meta; + } catch (_) {} + } + } + } + + return _buildSessionLog(entries, subagentFiles, subagentMeta); + } + + SessionLog _buildSessionLog( + List entries, + Map> subagentFiles, + Map> subagentMeta, + ) { + // Extract metadata from first user/assistant entry + String? sessionId, cwd, version, gitBranch; + for (final entry in entries) { + if (entry.sessionId != null) { + sessionId = entry.sessionId; + cwd = entry.cwd; + version = entry.version; + gitBranch = entry.gitBranch; + break; + } + } + + // Link tool results to tool use blocks in main session + _linkToolResults(entries); + + // Link tool results inside each subagent session + for (final agentEntries in subagentFiles.values) { + _linkToolResults(agentEntries); + } + + // Separate main conversation from progress entries + final mainConversation = entries + .where((e) => e.type != 'progress' && e.type != 'file-history-snapshot') + .toList(); + + // Build agent info + final agents = _buildAgents( + mainConversation, + subagentFiles, + subagentMeta, + ); + final mainAgent = agents.firstWhere((a) => a.id == 'main'); + + // Compute total usage + var totalUsage = const TokenUsage(); + for (final agent in agents) { + totalUsage = totalUsage + agent.aggregatedUsage; + } + + // Build tools by name index + final toolsByName = >{}; + for (final agent in agents) { + for (final tool in agent.toolsUsed) { + toolsByName.putIfAbsent(tool.name, () => []).add(tool); + } + } + + // Timestamps + final timestamps = entries + .where((e) => e.timestamp != null) + .map((e) => e.timestamp!) + .toList(); + timestamps.sort(); + + return SessionLog( + sessionId: sessionId, + cwd: cwd, + version: version, + gitBranch: gitBranch, + allEntries: entries, + mainConversation: mainConversation, + agents: agents, + mainAgent: mainAgent, + totalUsage: totalUsage, + toolsByName: toolsByName, + startTime: timestamps.isNotEmpty ? timestamps.first : null, + endTime: timestamps.isNotEmpty ? timestamps.last : null, + ); + } + + void _linkToolResults(List entries) { + final resultMap = {}; + for (final entry in entries) { + if (entry is UserEntry && entry.isToolResult) { + for (final result in entry.toolResults) { + resultMap[result.toolUseId] = result; + } + } + } + for (final entry in entries) { + if (entry is AssistantEntry) { + for (final block in entry.toolUseBlocks) { + block.linkedResult = resultMap[block.id]; + } + } + } + } + + List _buildAgents( + List mainEntries, + Map> subagentFiles, + Map> subagentMeta, + ) { + final agents = []; + + // Main agent tools and usage + final mainToolUses = []; + String? mainModel; + var mainUsage = const TokenUsage(); + + for (final entry in mainEntries) { + if (entry is AssistantEntry) { + mainModel ??= entry.model; + if (entry.usage != null) { + mainUsage = mainUsage + entry.usage!; + } + for (final block in entry.toolUseBlocks) { + if (!block.isAgentCall) { + mainToolUses.add(block); + } + } + } + } + + agents.add(AgentInfo( + id: 'main', + name: 'Main Assistant', + model: mainModel, + messages: mainEntries, + toolsUsed: mainToolUses, + aggregatedUsage: mainUsage, + )); + + // Find Agent tool calls from the main conversation to get descriptions + final agentToolCalls = {}; + for (final entry in mainEntries) { + if (entry is AssistantEntry) { + for (final block in entry.toolUseBlocks) { + if (block.isAgentCall) { + agentToolCalls[block.id] = block; + } + } + } + } + + // Build subagent infos from the actual subagent .jsonl files + for (final entry in subagentFiles.entries) { + final agentId = entry.key; + final agentEntries = entry.value; + final meta = subagentMeta[agentId] ?? {}; + + // Extract tool uses, model, and usage from subagent entries + final subToolUses = []; + var subUsage = const TokenUsage(); + String? subModel; + + for (final e in agentEntries) { + if (e is AssistantEntry) { + subModel ??= e.model; + if (e.usage != null) { + subUsage = subUsage + e.usage!; + } + for (final block in e.toolUseBlocks) { + if (!block.isAgentCall) { + subToolUses.add(block); + } + } + } + } + + // Try to match this subagent to an Agent tool call for description/prompt. + // The agentId in the file might be a prefix of the tool_use id. + ToolUseBlock? matchedCall; + for (final call in agentToolCalls.entries) { + if (call.key.contains(agentId) || agentId.contains(call.key)) { + matchedCall = call.value; + break; + } + } + + // Also check the slug from the first entry to match + String? slug; + for (final e in agentEntries) { + if (e.raw.containsKey('slug')) { + slug = e.raw['slug'] as String?; + break; + } + } + + final agentType = meta['agentType'] as String? ?? + matchedCall?.subagentType; + final description = matchedCall?.agentDescription ?? + slug ?? + agentType ?? + 'Subagent'; + + agents.add(AgentInfo( + id: agentId, + name: description, + subagentType: agentType, + description: matchedCall?.agentDescription, + prompt: matchedCall?.agentPrompt, + model: subModel, + messages: agentEntries, + toolsUsed: subToolUses, + aggregatedUsage: subUsage, + )); + } + + return agents; + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart new file mode 100644 index 0000000..2905d89 --- /dev/null +++ b/lib/theme/app_theme.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +class AppColors { + // Surfaces + static const Color background = Color(0xFF0F1117); + static const Color surface = Color(0xFF161923); + static const Color surfaceLight = Color(0xFF1E2130); + static const Color surfaceBorder = Color(0xFF2A2D3A); + + // Text + static const Color textPrimary = Color(0xFFF0F0F5); + static const Color textSecondary = Color(0xFF9CA3AF); + static const Color textMuted = Color(0xFF6B7280); + + // Role colors + static const Color user = Color(0xFF3B82F6); + static const Color userBg = Color(0xFF1E293B); + static const Color assistant = Color(0xFF10B981); + static const Color assistantBg = Color(0xFF0F2922); + static const Color system = Color(0xFF6B7280); + static const Color systemBg = Color(0xFF1F2028); + static const Color agent = Color(0xFFA855F7); + static const Color agentBg = Color(0xFF1E1530); + static const Color tool = Color(0xFFF59E0B); + static const Color toolBg = Color(0xFF271F0F); + static const Color error = Color(0xFFEF4444); + static const Color errorBg = Color(0xFF2D1515); + + // Chart colors + static const List chartPalette = [ + Color(0xFF3B82F6), + Color(0xFF10B981), + Color(0xFFA855F7), + Color(0xFFF59E0B), + Color(0xFFEF4444), + Color(0xFF06B6D4), + Color(0xFFF97316), + Color(0xFF8B5CF6), + Color(0xFFEC4899), + Color(0xFF14B8A6), + ]; + + static Color roleColor(String type) { + switch (type) { + case 'user': + return user; + case 'assistant': + return assistant; + case 'system': + return system; + case 'agent': + return agent; + case 'tool': + return tool; + default: + return textMuted; + } + } + + static Color roleBgColor(String type) { + switch (type) { + case 'user': + return userBg; + case 'assistant': + return assistantBg; + case 'system': + return systemBg; + case 'agent': + return agentBg; + case 'tool': + return toolBg; + default: + return surface; + } + } +} + +class AppTheme { + static ThemeData get dark { + return ThemeData.dark().copyWith( + scaffoldBackgroundColor: AppColors.background, + cardColor: AppColors.surface, + dividerColor: AppColors.surfaceBorder, + colorScheme: const ColorScheme.dark( + primary: AppColors.assistant, + surface: AppColors.surface, + error: AppColors.error, + ), + textTheme: ThemeData.dark().textTheme.apply( + fontFamily: 'Inter', + bodyColor: AppColors.textPrimary, + displayColor: AppColors.textPrimary, + ), + ); + } +} diff --git a/lib/widgets/common/expandable_card.dart b/lib/widgets/common/expandable_card.dart new file mode 100644 index 0000000..d1861ff --- /dev/null +++ b/lib/widgets/common/expandable_card.dart @@ -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 createState() => _ExpandableCardState(); +} + +class _ExpandableCardState extends State + with SingleTickerProviderStateMixin { + late bool _expanded; + late AnimationController _controller; + late Animation _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(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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/common/json_tree_view.dart b/lib/widgets/common/json_tree_view.dart new file mode 100644 index 0000000..e9a4771 --- /dev/null +++ b/lib/widgets/common/json_tree_view.dart @@ -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) { + return _MapNode( + map: data as Map, + 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 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, +); diff --git a/lib/widgets/navigation/app_shell.dart b/lib/widgets/navigation/app_shell.dart new file mode 100644 index 0000000..f53e605 --- /dev/null +++ b/lib/widgets/navigation/app_shell.dart @@ -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 createState() => _AppShellState(); +} + +class _AppShellState extends State { + 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(); + 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(); + } + } +} diff --git a/lib/widgets/navigation/sidebar.dart b/lib/widgets/navigation/sidebar.dart new file mode 100644 index 0000000..d346f1e --- /dev/null +++ b/lib/widgets/navigation/sidebar.dart @@ -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 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..438e7e5 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_picker +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..6ebac85 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - file_picker (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + +EXTERNAL SOURCES: + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + FlutterMacOS: + :path: Flutter/ephemeral + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + +SPEC CHECKSUMS: + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c7878b9 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,825 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 130C39CAC9CC7AA143C489DF /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C8D4F4D6A14DBC06CABB730 /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 79F3DEC2140214566E19F388 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6CB8C54BF040E1E6BF05BCBD /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 153C2DDE069AD579210ED2C4 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* claude_session_viewer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = claude_session_viewer.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 4BD835AFFC7FFFD2AC416CAE /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 4C8D4F4D6A14DBC06CABB730 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4EB19377A730AE2C095D7A54 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 6CB8C54BF040E1E6BF05BCBD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 881127F55C6D6CFE2534841B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A5398467BE6DA3EC050E790F /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + C0A0ADC8E8ED2668AF72715E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 79F3DEC2140214566E19F388 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 130C39CAC9CC7AA143C489DF /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 8208943E7AB9C5C3402F88ED /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* claude_session_viewer.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 8208943E7AB9C5C3402F88ED /* Pods */ = { + isa = PBXGroup; + children = ( + 153C2DDE069AD579210ED2C4 /* Pods-Runner.debug.xcconfig */, + C0A0ADC8E8ED2668AF72715E /* Pods-Runner.release.xcconfig */, + 4BD835AFFC7FFFD2AC416CAE /* Pods-Runner.profile.xcconfig */, + 881127F55C6D6CFE2534841B /* Pods-RunnerTests.debug.xcconfig */, + 4EB19377A730AE2C095D7A54 /* Pods-RunnerTests.release.xcconfig */, + A5398467BE6DA3EC050E790F /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4C8D4F4D6A14DBC06CABB730 /* Pods_Runner.framework */, + 6CB8C54BF040E1E6BF05BCBD /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 2D5E86AC9DD4210FFD8C9DE8 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + DDC6FD45F59CD5AA33826D72 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 2C302F33045D329C15CB5562 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* claude_session_viewer.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 2C302F33045D329C15CB5562 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 2D5E86AC9DD4210FFD8C9DE8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + DDC6FD45F59CD5AA33826D72 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 881127F55C6D6CFE2534841B /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionViewer.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/claude_session_viewer.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/claude_session_viewer"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4EB19377A730AE2C095D7A54 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionViewer.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/claude_session_viewer.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/claude_session_viewer"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A5398467BE6DA3EC050E790F /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionViewer.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/claude_session_viewer.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/claude_session_viewer"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..a839ef7 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..4dfff64 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = claude_session_viewer + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.svrnty.claudeSessionViewer + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.svrnty. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..bdc98fb --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-only + + com.apple.security.files.home-directory.read-only + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..79904a8 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,17 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + self.minSize = NSSize(width: 1200, height: 700) + self.title = "Claude Session Viewer" + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..eb4f4d2 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.files.home-directory.read-only + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..731eeb0 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,594 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.dev" + source: hosted + version: "10.3.10" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e + url: "https://pub.dev" + source: hosted + version: "8.0.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" + url: "https://pub.dev" + source: hosted + version: "7.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.7 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..0a1d4a2 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,96 @@ +name: claude_session_viewer +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.7 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + provider: ^6.1.5+1 + file_picker: ^10.3.10 + flutter_markdown: ^0.7.7+1 + google_fonts: ^8.0.2 + intl: ^0.20.2 + fl_chart: ^1.1.1 + shared_preferences: ^2.5.4 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..f575efa --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,9 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:claude_session_viewer/main.dart'; + +void main() { + testWidgets('App launches', (WidgetTester tester) async { + await tester.pumpWidget(const ClaudeSessionViewerApp()); + expect(find.text('Claude Session Viewer'), findsOneWidget); + }); +}