Add new features and update configurations for improved functionality

This commit is contained in:
ImBenji
2026-04-11 12:34:00 +01:00
parent fa4415553d
commit 0b6b604c56
125 changed files with 14119 additions and 1664 deletions
+151 -18
View File
@@ -3,48 +3,130 @@ import "dart:convert";
import "../../src/chat/tool_loop_service.dart";
import "../../src/api/openrouter_client.dart";
import "../../src/hooks/hook_loader.dart";
import "../../src/hooks/hook_runner.dart";
import "../../src/hooks/hook_types.dart";
import "../../src/permissions/permission_types.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";
enum QueuePriority {
now(0),
next(1),
later(2);
final int order;
const QueuePriority(this.order);
}
class QueuedMessage {
final String text;
final QueuePriority priority;
const QueuedMessage({required this.text, required this.priority});
}
class ChatProvider extends ChangeNotifier {
ChatProvider(this._settingsProvider);
ChatProvider(this._settingsProvider) {
_initHooks();
}
final SettingsProvider _settingsProvider;
final ToolLoopService _toolLoopService = ToolLoopService();
ToolLoopService _toolLoopService = ToolLoopService();
HookRunner? _hookRunner;
ConversationHistory? _conversationHistory;
OpenRouterClient? _client;
bool _stopRequested = false;
PendingPermission? _pendingPermission;
PendingPermission? get pendingPermission => _pendingPermission;
Future<void> _initHooks() async {
try {
final hooks = await HookLoader.loadHooks();
_hookRunner = HookRunner(hooks: hooks);
_toolLoopService = ToolLoopService(hookRunner: _hookRunner);
} catch (e) {
// hooks are optional, carry on without them
print("Hook init failed: $e");
}
}
List<Message> _messages = <Message>[];
List<Map<String, dynamic>> _apiMessages = <Map<String, dynamic>>[];
bool isLoading = false;
final List<QueuedMessage> _messageQueue = [];
List<Message> get messages => _messages;
int get messageCount => _messages.length;
List<Message> get messages => _conversationHistory?.getMessages() ?? const [];
int get messageCount => messages.length;
String? get workingDirectory => _conversationHistory?.session?.workingDirectory;
/// Context window size from the last API response — derived from persisted
/// message data, same as Claude Code (walks backwards to find the last
/// assistant message that has contextTokens set).
int get contextTokens {
final msgs = messages;
for (var i = msgs.length - 1; i >= 0; i--) {
final ct = msgs[i].contextTokens;
if (ct != null && ct > 0) return ct;
}
return 0;
}
bool get hasConversation => _conversationHistory != null;
bool get isStopping => _stopRequested;
int get queuedMessageCount => _messageQueue.length;
// only user-visible messages (priority != now)
List<String> get queuedMessages =>
List.unmodifiable(_messageQueue.map((m) => m.text));
void removeQueuedMessage(int index) {
if (index < 0 || index >= _messageQueue.length) return;
_messageQueue.removeAt(index);
notifyListeners();
}
QueuedMessage? _dequeue() {
if (_messageQueue.isEmpty) return null;
int bestIdx = 0;
for (int i = 1; i < _messageQueue.length; i++) {
if (_messageQueue[i].priority.order < _messageQueue[bestIdx].priority.order) {
bestIdx = i;
}
}
final cmd = _messageQueue[bestIdx];
_messageQueue.removeAt(bestIdx);
return cmd;
}
void setConversation(ConversationHistory history) {
_conversationHistory = history;
_messages = history.getMessages();
_apiMessages = _buildApiMessages(_messages);
_apiMessages = _buildApiMessages(history.getMessages());
notifyListeners();
}
void clearConversation() {
_conversationHistory = null;
_messages = <Message>[];
_apiMessages = <Map<String, dynamic>>[];
_messageQueue.clear();
isLoading = false;
notifyListeners();
}
Future<void> sendMessage(String text) async {
Future<void> sendMessage(String text, {QueuePriority priority = QueuePriority.next}) async {
if (text.isEmpty || _conversationHistory == null) return;
if (isLoading) {
_messageQueue.add(QueuedMessage(text: text, priority: priority));
notifyListeners();
return;
}
final apiKey = _settingsProvider.settings.openRouterApiKey;
if (apiKey == null || apiKey.isEmpty) {
throw Exception(
@@ -72,25 +154,35 @@ class ChatProvider extends ChangeNotifier {
}
}
// fire UserPromptSubmit hook
await _hookRunner?.runHooksForKind(
HookKind.userPromptSubmit,
input: {"message": 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 advisorModel = _settingsProvider.settings.advisorModel;
final toolLoopResult = await _toolLoopService.runTurn(
client: _client!,
model: model,
apiKey: apiKey,
getSettings: () => _settingsProvider.settings,
apiMessages: _apiMessages.take(_apiMessages.length - 1).toList(),
userText: text,
workingDirectory: workingDirectory,
advisorModel: advisorModel,
onToolCall: (toolName, input) {
_conversationHistory!.addMessage(
"tool",
_formatToolCall(toolName, input),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onToolResult: (toolName, result) {
@@ -98,7 +190,6 @@ class ChatProvider extends ChangeNotifier {
"tool",
_formatToolResult(toolName, result),
);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onAssistantTextDelta: (delta) {
@@ -107,26 +198,38 @@ class ChatProvider extends ChangeNotifier {
hasStreamingAssistantMessage = true;
}
_conversationHistory!.appendToLastMessage(delta);
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
_messages = _conversationHistory!.getMessages();
notifyListeners();
},
onPermissionRequired: (toolName, input) async {
final pending = PendingPermission(toolName: toolName, input: input);
_pendingPermission = pending;
notifyListeners();
final decision = await pending.future;
_pendingPermission = null;
notifyListeners();
return decision;
},
);
_apiMessages = toolLoopResult.apiMessages;
final ct = toolLoopResult.response.contextTokens;
// add assistant message to visible conversation
if (!toolLoopResult.finalResponseWasStreamed) {
_conversationHistory!.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
contextTokens: ct,
);
} else {
// streamed message was built incrementally — patch contextTokens onto it
_conversationHistory!.setLastMessageContextTokens(ct);
}
_messages = _conversationHistory!.getMessages();
// track cost (set to 0 for now — OpenRouter pricing varies by model)
final inputTokens = toolLoopResult.response.inputTokens ?? 0;
@@ -138,6 +241,8 @@ class ChatProvider extends ChangeNotifier {
outputTokens: outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
@@ -154,7 +259,7 @@ class ChatProvider extends ChangeNotifier {
if (error is RequestCancelledException) {
_conversationHistory!.addMessage("assistant", "Generation stopped.");
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
@@ -171,7 +276,7 @@ class ChatProvider extends ChangeNotifier {
);
final session = _conversationHistory!.session;
_messages = _conversationHistory!.getMessages();
if (session != null) {
await SessionStore.instance.saveSession(session);
}
@@ -183,6 +288,26 @@ class ChatProvider extends ChangeNotifier {
isLoading = false;
notifyListeners();
}
final next = _dequeue();
if (next != null) {
notifyListeners();
await sendMessage(next.text, priority: next.priority);
}
}
void resolvePermission(PermissionDecision decision) async {
final pending = _pendingPermission;
if (pending == null) return;
if (decision == PermissionDecision.allowAlways) {
// persist to settings so this tool is auto-allowed from now on
await _settingsProvider.addAlwaysAllowRule(pending.toolName);
}
pending.resolve(decision);
_pendingPermission = null;
notifyListeners();
}
void stopGenerating() {
@@ -190,10 +315,15 @@ class ChatProvider extends ChangeNotifier {
return;
}
_pendingPermission?.resolve(PermissionDecision.reject);
_pendingPermission = null;
_messageQueue.clear();
_stopRequested = true;
print("Stopping active turn");
_client?.cancelActiveRequest();
notifyListeners();
_hookRunner?.runHooksForKind(HookKind.stop);
}
@override
@@ -232,7 +362,10 @@ class ChatProvider extends ChangeNotifier {
String _formatToolCall(String toolName, Map<String, dynamic> input) {
const encoder = JsonEncoder.withIndent(" ");
return "$toolName call\n${encoder.convert(input)}";
final visibleInput = Map<String, dynamic>.fromEntries(
input.entries.where((entry) => !entry.key.startsWith("_")),
);
return "$toolName call\n${encoder.convert(visibleInput)}";
}
String _formatToolResult(String toolName, String result) {
+126
View File
@@ -0,0 +1,126 @@
import "package:file_picker/file_picker.dart";
import "package:flutter/foundation.dart";
import "../../src/project_store.dart";
import "../../src/session/session_types.dart";
import "chat_provider.dart";
import "projects_provider.dart";
import "session_provider.dart";
import "settings_provider.dart";
class HomeCoordinator extends ChangeNotifier {
HomeCoordinator(this._projects, this._session, this._chat, this._settings);
final ProjectsProvider _projects;
final SessionProvider _session;
final ChatProvider _chat;
final SettingsProvider _settings;
String? _error;
String? get error => _error;
void clearError() {
_error = null;
notifyListeners();
}
void _setError(String msg) {
_error = msg;
notifyListeners();
}
Future<void> pickProjectDirectory() async {
try {
final selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: "Select project directory",
);
if (selectedDirectory == null) return;
final project = await _projects.addProject(selectedDirectory);
if (project == null) {
_setError("The selected folder could not be added as a project.");
return;
}
_projects.selectProject(project.id);
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
_chat.clearConversation();
await _settings.setActiveProject(project.workingDirectory);
} catch (e, st) {
print("Project directory picker failed: $e");
print(st);
_setError(e.toString());
}
}
Future<void> createNewChat() async {
final selectedProject = _projects.selectedProject;
if (selectedProject == null) {
_setError("Choose a project first so the new chat has a working directory.");
return;
}
await _session.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
}
Future<void> selectProject(ProjectRecord project) async {
_projects.selectProject(project.id);
await _settings.setActiveProject(project.workingDirectory);
if (_session.currentSession?.workingDirectory == project.workingDirectory) return;
_session.clearCurrentSession(workingDirectory: project.workingDirectory);
_settings.setThreadModel(null);
_chat.clearConversation();
}
Future<void> openSession(SessionSummary session) async {
await _session.loadSession(session);
_chat.setConversation(_session.getConversationHistory());
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
_settings.setThreadModel(_session.currentSession?.model);
}
Future<void> sendMessage(String text) async {
if (text.isEmpty) return;
if (_session.currentSession == null) {
final selectedProject = _projects.selectedProject;
if (selectedProject == null) {
_setError("Pick a project before starting a chat.");
return;
}
await _session.createNewSession(
workingDirectory: selectedProject.workingDirectory,
name: "New Chat",
model: _settings.settings.model,
);
_settings.setThreadModel(_settings.settings.model);
_chat.setConversation(_session.getConversationHistory());
}
try {
await _chat.sendMessage(text);
} catch (e, st) {
print("Failed to send message: $e");
print(st);
_setError(e.toString());
} finally {
await _session.refreshSessions();
}
}
Future<void> deleteSession(SessionSummary session) async {
await _session.deleteSession(session);
}
}
+37 -10
View File
@@ -1,15 +1,17 @@
import "package:flutter/foundation.dart";
import "package:uuid/uuid.dart";
import "../../src/project_store.dart";
import "../../src/session/conversation_history.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
class SessionProvider extends ChangeNotifier {
SessionProvider() {
SessionProvider(this._projectStore) {
_loadSessions();
}
final ProjectStore _projectStore;
final SessionStore _sessionStore = SessionStore.instance;
final ConversationHistory _conversationHistory = ConversationHistory();
@@ -59,7 +61,12 @@ class SessionProvider extends ChangeNotifier {
Future<void> _loadSessions() async {
try {
_sessions = await _sessionStore.listSessions();
final workingDirs = _projectStore.projects
.map((p) => p.workingDirectory)
.where((d) => d.isNotEmpty)
.toList();
_sessions = await _sessionStore.listAllSessions(workingDirs);
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to load sessions", error, stackTrace);
@@ -70,6 +77,7 @@ class SessionProvider extends ChangeNotifier {
Future<void> createNewSession({
String? workingDirectory,
String? name,
String? model,
}) async {
try {
const uuid = Uuid();
@@ -86,6 +94,7 @@ class SessionProvider extends ChangeNotifier {
normalizedDirectory == null || normalizedDirectory.isEmpty
? null
: normalizedDirectory,
model: model,
);
await _sessionStore.saveSession(newSession);
@@ -101,29 +110,38 @@ class SessionProvider extends ChangeNotifier {
}
}
Future<void> loadSession(String id) async {
Future<void> loadSession(SessionSummary summary) async {
try {
final session = await _sessionStore.loadSession(id);
final workingDir = summary.workingDirectory;
if (workingDir == null || workingDir.isEmpty) return;
final session = await _sessionStore.loadSession(
summary.id,
workingDirectory: workingDir,
);
if (session != null) {
_conversationHistory.setSession(session);
_currentSession = session;
_currentSessionId = id;
_currentSessionId = summary.id;
_activeWorkingDirectory = session.workingDirectory;
notifyListeners();
}
} catch (error, stackTrace) {
_logException("Failed to load session $id", error, stackTrace);
_logException("Failed to load session ${summary.id}", error, stackTrace);
_currentSession = null;
_currentSessionId = null;
_activeWorkingDirectory = null;
}
}
Future<void> deleteSession(String id) async {
Future<void> deleteSession(SessionSummary summary) async {
try {
await _sessionStore.deleteSession(id);
final workingDir = summary.workingDirectory;
if (workingDir == null || workingDir.isEmpty) return;
if (_currentSessionId == id) {
await _sessionStore.deleteSession(summary.id, workingDirectory: workingDir);
if (_currentSessionId == summary.id) {
_conversationHistory.setSession(
ConversationSession(
id: "",
@@ -140,7 +158,7 @@ class SessionProvider extends ChangeNotifier {
await _loadSessions();
notifyListeners();
} catch (error, stackTrace) {
_logException("Failed to delete session $id", error, stackTrace);
_logException("Failed to delete session ${summary.id}", error, stackTrace);
}
}
@@ -152,6 +170,15 @@ class SessionProvider extends ChangeNotifier {
}
}
// Updates the model on the current in-memory session and persists it
Future<void> updateSessionModel(String model) async {
final session = _currentSession;
if (session == null) return;
session.model = model;
await _sessionStore.saveSession(session);
}
ConversationHistory getConversationHistory() => _conversationHistory;
void _logException(String message, Object error, StackTrace stackTrace) {
+70 -9
View File
@@ -1,16 +1,31 @@
import "package:flutter/foundation.dart";
import "../../src/local_state.dart";
import "../../src/project_settings_store.dart";
class SettingsProvider extends ChangeNotifier {
SettingsProvider(this._settingsStore) : settings = _settingsStore.settings;
SettingsProvider(this._settingsStore) : _globalSettings = _settingsStore.settings;
static const Map<String, String> _legacyModelAliases = {
"google/gemini-2.0-flash": "google/gemini-2.0-flash-001",
};
final SettingsStore _settingsStore;
LocalSettings settings;
LocalSettings _globalSettings;
LocalSettings? _projectSettings;
String? _threadModel;
String? _activeProjectDir;
// Effective settings: global → project override → thread model
LocalSettings get settings {
var merged = _globalSettings.mergeWith(_projectSettings);
if (_threadModel != null && _threadModel!.isNotEmpty) {
merged = merged.copyWith(model: _threadModel);
}
return merged;
}
String normalizeModelId(String? modelId) {
if (modelId == null || modelId.isEmpty) {
@@ -20,12 +35,36 @@ class SettingsProvider extends ChangeNotifier {
return _legacyModelAliases[modelId] ?? modelId;
}
// Called when the active project changes
Future<void> setActiveProject(String? workingDirectory) async {
_activeProjectDir = workingDirectory;
_projectSettings = null;
_threadModel = null;
if (workingDirectory != null && workingDirectory.isNotEmpty) {
_projectSettings = await ProjectSettingsStore.instance.load(workingDirectory);
}
notifyListeners();
}
// Called when a thread is loaded or cleared
void setThreadModel(String? model) {
_threadModel = model != null ? normalizeModelId(model) : null;
notifyListeners();
}
Future<void> updateModel(String newModel) async {
final normalizedModel = normalizeModelId(newModel);
final normalized = normalizeModelId(newModel);
// update thread model in memory
_threadModel = normalized;
// also persist to global settings as the new default
await _settingsStore.update(
(current) => current.copyWith(model: normalizedModel),
(current) => current.copyWith(model: normalized),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
@@ -33,13 +72,13 @@ class SettingsProvider extends ChangeNotifier {
await _settingsStore.update(
(current) => current.copyWith(openRouterApiKey: newKey),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateTheme(String newTheme) async {
await _settingsStore.update((current) => current.copyWith(theme: newTheme));
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
@@ -47,13 +86,35 @@ class SettingsProvider extends ChangeNotifier {
await _settingsStore.update(
(current) => current.copyWith(effortLevel: newLevel),
);
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> addAlwaysAllowRule(String toolName) async {
final current = _globalSettings.alwaysAllowRules;
if (current.contains(toolName)) return;
await _settingsStore.update(
(s) => s.copyWith(alwaysAllowRules: [...current, toolName]),
);
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> resetToDefaults() async {
await _settingsStore.update((_) => const LocalSettings());
settings = _settingsStore.settings;
_globalSettings = _settingsStore.settings;
_projectSettings = null;
_threadModel = null;
notifyListeners();
}
// Save project-level settings override
Future<void> updateProjectSetting(LocalSettings projectOverride) async {
final dir = _activeProjectDir;
if (dir == null || dir.isEmpty) return;
await ProjectSettingsStore.instance.save(dir, projectOverride);
_projectSettings = projectOverride;
notifyListeners();
}
}