Add initial project files and configurations for clawd_code

This commit is contained in:
ImBenji
2026-04-03 17:48:07 +01:00
parent 7541a5279b
commit c88a1badc7
273 changed files with 28339 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
import "package:flutter/foundation.dart";
import "dart:convert";
import "../../src/chat/tool_loop_service.dart";
import "../../src/api/openrouter_client.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
import "settings_provider.dart";
class ChatProvider extends ChangeNotifier {
ChatProvider(this._settingsProvider);
final SettingsProvider _settingsProvider;
final ToolLoopService _toolLoopService = ToolLoopService();
ConversationHistory? _conversationHistory;
OpenRouterClient? _client;
bool _stopRequested = false;
List<Message> _messages = <Message>[];
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
bool isLoading = false;
List<Message> get messages => _messages;
int get messageCount => _messages.length;
bool get hasConversation => _conversationHistory != null;
bool get isStopping => _stopRequested;
void setConversation(ConversationHistory history) {
_conversationHistory = history;
_messages = history.getMessages();
_apiMessages = _buildApiMessages(_messages);
notifyListeners();
}
void clearConversation() {
_conversationHistory = null;
_messages = <Message>[];
_apiMessages = <Map<String, dynamic>>[];
isLoading = false;
notifyListeners();
}
Future<void> sendMessage(String text) async {
if (text.isEmpty || _conversationHistory == null) return;
final apiKey = _settingsProvider.settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
throw Exception(
"OpenRouter API key not set. Please configure it in settings.",
);
}
final savedModel = _settingsProvider.settings.model;
final model = _settingsProvider.normalizeModelId(savedModel);
if (savedModel != model) {
print("Normalizing legacy model ID from $savedModel to $model");
await _settingsProvider.updateModel(model);
}
try {
_stopRequested = false;
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
final session = _conversationHistory!.session;
final workingDirectory = session?.workingDirectory;
if (session != null) {
session.model = model;
if (session.name == "New Chat") {
session.name = _buildSessionName(text);
}
}
// add user message to conversation
_conversationHistory!.addMessage("user", text);
_messages = _conversationHistory!.getMessages();
_apiMessages.add(<String, dynamic>{"role": "user", "content": text});
isLoading = true;
notifyListeners();
final toolLoopResult = await _toolLoopService.runTurn(
client: _client!,
model: model,
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
userText: text,
workingDirectory: workingDirectory,
onToolCall: (toolName, input) {
_conversationHistory!.addMessage(
"tool",
_formatToolCall(toolName, input),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onToolResult: (toolName, result) {
_conversationHistory!.addMessage(
"tool",
_formatToolResult(toolName, result),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
);
_apiMessages = toolLoopResult.apiMessages;
// add assistant message to visible conversation
_conversationHistory!.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
);
_messages = _conversationHistory!.getMessages();
// track cost (set to 0 for now — OpenRouter pricing varies by model)
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
final outputTokens = toolLoopResult.response.outputTokens ?? 0;
cost_tracker.addToTotalSessionCost(
cost: 0.0,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
model: toolLoopResult.response.model,
);
// save session
if (session != null) {
await SessionStore.instance.saveSession(session);
}
notifyListeners();
} catch (error, stackTrace) {
print("Failed to send message: $error");
print(stackTrace);
if (error is RequestCancelledException) {
_conversationHistory!.addMessage("assistant", "Generation stopped.");
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
return;
}
if (error is ToolLoopException) {
_apiMessages = List<Map<String, dynamic>>.from(error.apiMessages);
}
_conversationHistory!.addMessage(
"assistant",
_buildTurnFailureMessage(error),
);
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
rethrow;
} finally {
_client?.close();
_client = null;
_stopRequested = false;
isLoading = false;
notifyListeners();
}
}
void stopGenerating() {
if (!isLoading) {
return;
}
_stopRequested = true;
print("Stopping active turn");
_client?.cancelActiveRequest();
notifyListeners();
}
@override
void dispose() {
_client?.close();
super.dispose();
}
String _buildSessionName(String text) {
final sanitized = text.replaceAll(RegExp(r"\s+"), " ").trim();
if (sanitized.isEmpty) {
return "New Chat";
}
const maxLength = 48;
if (sanitized.length <= maxLength) {
return sanitized;
}
return "${sanitized.substring(0, maxLength - 1).trimRight()}";
}
List<Map<String, dynamic>> _buildApiMessages(List<Message> messages) {
return messages
.where(
(message) => message.role == "user" || message.role == "assistant",
)
.map(
(message) => <String, dynamic>{
"role": message.role,
"content": message.content,
},
)
.toList(growable: true);
}
String _formatToolCall(String toolName, Map<String, dynamic> input) {
const encoder = JsonEncoder.withIndent(" ");
return "$toolName call\n${encoder.convert(input)}";
}
String _formatToolResult(String toolName, String result) {
return "$toolName result\n$result";
}
String _buildTurnFailureMessage(Object error) {
return "This turn failed before the assistant could finish: $error";
}
}
+19
View File
@@ -0,0 +1,19 @@
import "package:flutter/foundation.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
class CostProvider extends ChangeNotifier {
CostProvider();
double getTotalCostUsd() => cost_tracker.getTotalCostUsd();
int getTotalInputTokens() => cost_tracker.getTotalInputTokens();
int getTotalOutputTokens() => cost_tracker.getTotalOutputTokens();
String getFormattedTotalCost() => cost_tracker.formatTotalCost();
Future<void> refreshCost() async {
// read current values from cost tracker
final _ = cost_tracker.getTotalCostUsd();
notifyListeners();
}
}
+129
View File
@@ -0,0 +1,129 @@
import "dart:io";
import "package:flutter/foundation.dart";
import "package:path/path.dart" as path;
import "package:uuid/uuid.dart";
import "../../src/project_store.dart";
class ProjectsProvider extends ChangeNotifier {
ProjectsProvider(this._projectStore) {
_projects = List<ProjectRecord>.from(_projectStore.projects);
}
final ProjectStore _projectStore;
List<ProjectRecord> _projects = <ProjectRecord>[];
String? _selectedProjectId;
List<ProjectRecord> get projects =>
List<ProjectRecord>.unmodifiable(_projects);
String? get selectedProjectId => _selectedProjectId;
ProjectRecord? get selectedProject {
final selectedId = _selectedProjectId;
if (selectedId == null) {
return null;
}
for (final project in _projects) {
if (project.id == selectedId) {
return project;
}
}
return null;
}
Future<ProjectRecord?> addProject(String workingDirectory) async {
final normalizedDirectory = workingDirectory.trim();
if (normalizedDirectory.isEmpty) {
return null;
}
final directory = Directory(normalizedDirectory);
if (!await directory.exists()) {
return null;
}
final existing = _projects.cast<ProjectRecord?>().firstWhere(
(project) => project?.workingDirectory == normalizedDirectory,
orElse: () => null,
);
if (existing != null) {
_selectedProjectId = existing.id;
notifyListeners();
return existing;
}
const uuid = Uuid();
final project = ProjectRecord(
id: uuid.v4(),
name: _projectNameForDirectory(normalizedDirectory),
workingDirectory: normalizedDirectory,
createdAt: DateTime.now().toUtc(),
);
await _projectStore.update(
(current) => <ProjectRecord>[project, ...current],
);
_projects = List<ProjectRecord>.from(_projectStore.projects);
_selectedProjectId = project.id;
notifyListeners();
return project;
}
void selectProject(String id) {
if (_selectedProjectId == id) {
return;
}
final exists = _projects.any((project) => project.id == id);
if (!exists) {
return;
}
_selectedProjectId = id;
notifyListeners();
}
void selectProjectByWorkingDirectory(String? workingDirectory) {
final normalizedDirectory = workingDirectory?.trim();
if (normalizedDirectory == null || normalizedDirectory.isEmpty) {
if (_selectedProjectId != null) {
_selectedProjectId = null;
notifyListeners();
}
return;
}
final match = _projects.cast<ProjectRecord?>().firstWhere(
(project) => project?.workingDirectory == normalizedDirectory,
orElse: () => null,
);
final nextSelectedId = match?.id;
if (_selectedProjectId == nextSelectedId) {
return;
}
_selectedProjectId = nextSelectedId;
notifyListeners();
}
Future<void> removeProject(String id) async {
await _projectStore.update(
(current) => current.where((project) => project.id != id).toList(),
);
_projects = List<ProjectRecord>.from(_projectStore.projects);
if (_selectedProjectId == id) {
_selectedProjectId = null;
}
notifyListeners();
}
String _projectNameForDirectory(String workingDirectory) {
final baseName = path.basename(workingDirectory);
return baseName.isEmpty ? workingDirectory : baseName;
}
}
+161
View File
@@ -0,0 +1,161 @@
import "package:flutter/foundation.dart";
import "package:uuid/uuid.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
class SessionProvider extends ChangeNotifier {
SessionProvider() {
_loadSessions();
}
final SessionStore _sessionStore = SessionStore.instance;
final ConversationHistory _conversationHistory = ConversationHistory();
List<SessionSummary> _sessions = <SessionSummary>[];
String? _currentSessionId;
ConversationSession? _currentSession;
String? _activeWorkingDirectory;
List<SessionSummary> get sessions => _sessions;
String? get currentSessionId => _currentSessionId;
ConversationSession? get currentSession => _currentSession;
String? get activeWorkingDirectory => _activeWorkingDirectory;
List<SessionSummary> sessionsForWorkingDirectory(String? workingDirectory) {
final normalizedDirectory = workingDirectory?.trim();
if (normalizedDirectory == null || normalizedDirectory.isEmpty) {
return List<SessionSummary>.unmodifiable(_sessions);
}
return List<SessionSummary>.unmodifiable(
_sessions.where(
(session) => session.workingDirectory == normalizedDirectory,
),
);
}
void selectWorkingDirectory(String? workingDirectory) {
_activeWorkingDirectory = workingDirectory?.trim();
notifyListeners();
}
void clearCurrentSession({String? workingDirectory}) {
_conversationHistory.setSession(
ConversationSession(
id: "",
name: "",
created: DateTime.now().toUtc(),
updated: DateTime.now().toUtc(),
workingDirectory: workingDirectory?.trim(),
),
);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = workingDirectory?.trim();
notifyListeners();
}
Future<void> _loadSessions() async {
try {
_sessions = await _sessionStore.listSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to load sessions", error, stackTrace);
_sessions = <SessionSummary>[];
}
}
Future<void> createNewSession({
String? workingDirectory,
String? name,
}) async {
try {
const uuid = Uuid();
final newSessionId = uuid.v4();
final now = DateTime.now().toUtc();
final normalizedDirectory = workingDirectory?.trim();
final newSession = ConversationSession(
id: newSessionId,
name: name ?? "New Chat",
created: now,
updated: now,
workingDirectory:
normalizedDirectory == null || normalizedDirectory.isEmpty
? null
: normalizedDirectory,
);
await _sessionStore.saveSession(newSession);
_conversationHistory.setSession(newSession);
_currentSession = newSession;
_currentSessionId = newSessionId;
_activeWorkingDirectory = newSession.workingDirectory;
await _loadSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to create a new session", error, stackTrace);
}
}
Future<void> loadSession(String id) async {
try {
final session = await _sessionStore.loadSession(id);
if (session != null) {
_conversationHistory.setSession(session);
_currentSession = session;
_currentSessionId = id;
_activeWorkingDirectory = session.workingDirectory;
notifyListeners();
}
} catch (error, stackTrace) {
_logException("Failed to load session $id", error, stackTrace);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = null;
}
}
Future<void> deleteSession(String id) async {
try {
await _sessionStore.deleteSession(id);
if (_currentSessionId == id) {
_conversationHistory.setSession(
ConversationSession(
id: "",
name: "",
created: DateTime.now().toUtc(),
updated: DateTime.now().toUtc(),
),
);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = null;
}
await _loadSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to delete session $id", error, stackTrace);
}
}
Future<void> refreshSessions() async {
try {
await _loadSessions();
} catch (error, stackTrace) {
_logException("Failed to refresh sessions", error, stackTrace);
}
}
ConversationHistory getConversationHistory() => _conversationHistory;
void _logException(String message, Object error, StackTrace stackTrace) {
print("$message: $error");
print(stackTrace);
}
}
+59
View File
@@ -0,0 +1,59 @@
import "package:flutter/foundation.dart";
import "../../src/local_state.dart";
class SettingsProvider extends ChangeNotifier {
SettingsProvider(this._settingsStore) : settings = _settingsStore.settings;
static const Map<String, String> _legacyModelAliases = {
"google/gemini-2.0-flash": "google/gemini-2.0-flash-001",
};
final SettingsStore _settingsStore;
LocalSettings settings;
String normalizeModelId(String? modelId) {
if (modelId == null || modelId.isEmpty) {
return "anthropic/claude-sonnet-4.6";
}
return _legacyModelAliases[modelId] ?? modelId;
}
Future<void> updateModel(String newModel) async {
final normalizedModel = normalizeModelId(newModel);
await _settingsStore.update(
(current) => current.copyWith(model: normalizedModel),
);
settings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateApiKey(String newKey) async {
await _settingsStore.update(
(current) => current.copyWith(openRouterApiKey: newKey),
);
settings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateTheme(String newTheme) async {
await _settingsStore.update((current) => current.copyWith(theme: newTheme));
settings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateEffortLevel(String newLevel) async {
await _settingsStore.update(
(current) => current.copyWith(effortLevel: newLevel),
);
settings = _settingsStore.settings;
notifyListeners();
}
Future<void> resetToDefaults() async {
await _settingsStore.update((_) => const LocalSettings());
settings = _settingsStore.settings;
notifyListeners();
}
}
+34
View File
@@ -0,0 +1,34 @@
import "package:flutter/foundation.dart";
import "settings_provider.dart";
class ThemeProvider extends ChangeNotifier {
ThemeProvider(this._settingsProvider) : _currentTheme = _settingsProvider.settings.theme {
_settingsProvider.addListener(_onSettingsChanged);
}
final SettingsProvider _settingsProvider;
late String _currentTheme;
String get currentTheme => _currentTheme;
bool get isDark => _currentTheme == "dark" || _currentTheme == "dark-ansi";
void _onSettingsChanged() {
if (_currentTheme != _settingsProvider.settings.theme) {
_currentTheme = _settingsProvider.settings.theme;
notifyListeners();
}
}
Future<void> setTheme(String themeName) async {
await _settingsProvider.updateTheme(themeName);
_currentTheme = themeName;
notifyListeners();
}
@override
void dispose() {
_settingsProvider.removeListener(_onSettingsChanged);
super.dispose();
}
}