@tduncan / Cortana / commits / d6878cf

feat: implement chat background service for cross-platform inference, migrate to syntax_highlight, and add UI enhancements.

abdulmominsakib committed Apr 20, 2026 at 14:23 UTC d6878cfb5619b5e5dce0a22a61d7d7a9fafff185
19 files changed +478 -174
android/app/src/main/AndroidManifest.xml
+10
@@ -1,6 +1,10 @@
1 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2 xmlns:tools="http://schemas.android.com/tools">
3 <uses-permission android:name="android.permission.INTERNET" />
4 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5 + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
6 + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7 + <uses-permission android:name="android.permission.WAKE_LOCK" />
8
9 <application
10 android:label="LocalMind"
@@ -34,6 +38,12 @@
38 <meta-data
39 android:name="flutterEmbedding"
40 android:value="2" />
41 +
42 + <service
43 + android:name=".ChatForegroundService"
44 + android:enabled="true"
45 + android:exported="false"
46 + android:foregroundServiceType="dataSync" />
47 </application>
48 <!-- Required to query activities that can process text, see:
49 https://developer.android.com/training/package-visibility and
android/app/src/main/kotlin/pro/momin/localmind/ChatForegroundService.kt new
+68
@@ -0,0 +1,68 @@
1 +package pro.momin.localmind
2 +
3 +import android.app.*
4 +import android.content.Context
5 +import android.content.Intent
6 +import android.os.Build
7 +import android.os.IBinder
8 +import androidx.core.app.NotificationCompat
9 +
10 +class ChatForegroundService : Service() {
11 + companion object {
12 + private const val CHANNEL_ID = "chat_generation_channel"
13 + private const val NOTIFICATION_ID = 1001
14 +
15 + fun startService(context: Context) {
16 + val intent = Intent(context, ChatForegroundService::class.java)
17 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
18 + context.startForegroundService(intent)
19 + } else {
20 + context.startService(intent)
21 + }
22 + }
23 +
24 + fun stopService(context: Context) {
25 + val intent = Intent(context, ChatForegroundService::class.java)
26 + context.stopService(intent)
27 + }
28 + }
29 +
30 + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
31 + createNotificationChannel()
32 +
33 + val notificationIntent = Intent(this, MainActivity::class.java)
34 + val pendingIntent = PendingIntent.getActivity(
35 + this, 0, notificationIntent,
36 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
37 + )
38 +
39 + val notification = NotificationCompat.Builder(this, CHANNEL_ID)
40 + .setContentTitle("LocalMind")
41 + .setContentText("Generating response…")
42 + .setSmallIcon(android.R.drawable.stat_notify_chat)
43 + .setOngoing(true)
44 + .setContentIntent(pendingIntent)
45 + .setPriority(NotificationCompat.PRIORITY_LOW)
46 + .build()
47 +
48 + startForeground(NOTIFICATION_ID, notification)
49 +
50 + return START_STICKY
51 + }
52 +
53 + override fun onBind(intent: Intent?): IBinder? = null
54 +
55 + private fun createNotificationChannel() {
56 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
57 + val serviceChannel = NotificationChannel(
58 + CHANNEL_ID,
59 + "Chat Generation Service Channel",
60 + NotificationManager.IMPORTANCE_LOW
61 + ).apply {
62 + description = "Shows a notification when AI is generating a response in the background"
63 + }
64 + val manager = getSystemService(NotificationManager::class.java)
65 + manager.createNotificationChannel(serviceChannel)
66 + }
67 + }
68 +}
android/app/src/main/kotlin/pro/momin/localmind/MainActivity.kt
+25 -1
@@ -1,5 +1,29 @@
1 package pro.momin.localmind
2
3 +import androidx.annotation.NonNull
4 import io.flutter.embedding.android.FlutterActivity
5 +import io.flutter.embedding.engine.FlutterEngine
6 +import io.flutter.plugin.common.MethodChannel
7
5 -class MainActivity : FlutterActivity()
8 +class MainActivity : FlutterActivity() {
9 + private val CHANNEL = "localmind/chat_background"
10 +
11 + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
12 + super.configureFlutterEngine(flutterEngine)
13 + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
14 + when (call.method) {
15 + "startForeground" -> {
16 + ChatForegroundService.startService(this)
17 + result.success(null)
18 + }
19 + "stopForeground" -> {
20 + ChatForegroundService.stopService(this)
21 + result.success(null)
22 + }
23 + else -> {
24 + result.notImplemented()
25 + }
26 + }
27 + }
28 + }
29 +}
ios/Runner/AppDelegate.swift
+43 -7
@@ -2,11 +2,47 @@ import Flutter
2 import UIKit
3
4 @main
5 -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
6 - override func application(
7 - _ application: UIApplication,
8 - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
9 - ) -> Bool {
10 - return super.application(application, didFinishLaunchingWithOptions: launchOptions)
11 - }
5 +@objc class AppDelegate: FlutterAppDelegate {
6 + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
7 +
8 + override func application(
9 + _ application: UIApplication,
10 + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
11 + ) -> Bool {
12 + let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
13 + let channel = FlutterMethodChannel(name: "localmind/chat_background", binaryMessenger: controller.binaryMessenger)
14 +
15 + channel.setMethodCallHandler({
16 + (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
17 + switch call.method {
18 + case "startForeground":
19 + // On iOS, we start a background task assertion
20 + self.startBackgroundTask()
21 + result(nil)
22 + case "stopForeground":
23 + // On iOS, we end the background task assertion
24 + self.stopBackgroundTask()
25 + result(nil)
26 + default:
27 + result(FlutterMethodNotImplemented)
28 + }
29 + })
30 +
31 + GeneratedPluginRegistrant.register(with: self)
32 + return super.application(application, didFinishLaunchingWithOptions: launchOptions)
33 + }
34 +
35 + private func startBackgroundTask() {
36 + stopBackgroundTask()
37 + backgroundTask = UIApplication.shared.beginBackgroundTask { [weak self] in
38 + self?.stopBackgroundTask()
39 + }
40 + }
41 +
42 + private func stopBackgroundTask() {
43 + if backgroundTask != .invalid {
44 + UIApplication.shared.endBackgroundTask(backgroundTask)
45 + backgroundTask = .invalid
46 + }
47 + }
48 }
ios/Runner/Info.plist
+1
@@ -77,6 +77,7 @@
77 <array>
78 <string>fetch</string>
79 <string>processing</string>
80 + <string>audio</string>
81 </array>
82 </dict>
83 </plist>
lib/core/providers/chat_background_service_provider.dart new
+6
@@ -0,0 +1,6 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import '../services/chat_background_service.dart';
3 +
4 +final chatBackgroundServiceProvider = Provider<ChatBackgroundService>((ref) {
5 + return ChatBackgroundService();
6 +});
lib/core/providers/highlighter_provider.dart new
+40
@@ -0,0 +1,40 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import 'package:syntax_highlight/syntax_highlight.dart';
3 +
4 +const _languages = [
5 + 'css',
6 + 'dart',
7 + 'go',
8 + 'html',
9 + 'java',
10 + 'javascript',
11 + 'json',
12 + 'kotlin',
13 + 'python',
14 + 'rust',
15 + 'sql',
16 + 'swift',
17 + 'typescript',
18 + 'yaml',
19 +];
20 +
21 +final highlighterThemesProvider = FutureProvider<HighlighterThemes>((ref) {
22 + return HighlighterThemes.load();
23 +});
24 +
25 +class HighlighterThemes {
26 + final HighlighterTheme light;
27 + final HighlighterTheme dark;
28 +
29 + HighlighterThemes({required this.light, required this.dark});
30 +
31 + static Future<HighlighterThemes> load() async {
32 + final light = await HighlighterTheme.loadLightTheme();
33 + final dark = await HighlighterTheme.loadDarkTheme();
34 + return HighlighterThemes(light: light, dark: dark);
35 + }
36 +}
37 +
38 +Future<void> initializeHighlighter() async {
39 + await Highlighter.initialize(_languages);
40 +}
lib/core/services/chat_background_service.dart new
+32
@@ -0,0 +1,32 @@
1 +import 'package:flutter/services.dart';
2 +import 'package:wakelock_plus/wakelock_plus.dart';
3 +import '../logger/app_logger.dart';
4 +
5 +class ChatBackgroundService {
6 + static const _channel = MethodChannel('localmind/chat_background');
7 + bool _isActive = false;
8 +
9 + Future<void> start() async {
10 + if (_isActive) return;
11 + try {
12 + Log.info('Starting background chat service');
13 + await _channel.invokeMethod('startForeground');
14 + await WakelockPlus.enable();
15 + _isActive = true;
16 + } catch (e) {
17 + Log.error('Failed to start background chat service: $e');
18 + }
19 + }
20 +
21 + Future<void> stop() async {
22 + if (!_isActive) return;
23 + try {
24 + Log.info('Stopping background chat service');
25 + await _channel.invokeMethod('stopForeground');
26 + await WakelockPlus.disable();
27 + _isActive = false;
28 + } catch (e) {
29 + Log.error('Failed to stop background chat service: $e');
30 + }
31 + }
32 +}
lib/features/chat/providers/chat_providers.dart
+9
@@ -22,6 +22,7 @@ import '../../conversations/data/models/conversation.dart';
22 import '../data/chat_service.dart';
23 import '../data/models/chat_parameters.dart';
24 import '../data/models/message.dart';
25 +import '../../../core/providers/chat_background_service_provider.dart';
26
27 final selectedModelProvider =
28 NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
@@ -550,6 +551,9 @@ class ChatNotifier extends Notifier<ChatState> {
551 await _saveMessage(userMessage);
552 await _saveMessage(assistantMessage);
553
554 + // Start background service to prevent suspension
555 + ref.read(chatBackgroundServiceProvider).start();
556 +
557 _resetCheckpointMetrics();
558 _updateSavedMetrics(assistantMessage);
559
@@ -709,6 +713,7 @@ class ChatNotifier extends Notifier<ChatState> {
713 }
714 },
715 onDone: () async {
716 + ref.read(chatBackgroundServiceProvider).stop();
717 final streamConvId = assistantMessage.conversationId;
718 final isCurrentContext = _currentConversationId == streamConvId;
719 final streamingMessage = streamingAssistantMessage;
@@ -811,6 +816,8 @@ class ChatNotifier extends Notifier<ChatState> {
816 if (errorMessage != null) {
817 await _saveMessage(errorMessage);
818
819 + ref.read(chatBackgroundServiceProvider).stop();
820 +
821 final streamConvId = assistantMessage.conversationId;
822 if (_currentConversationId == streamConvId) {
823 final messageIndex = state.messages.indexWhere(
@@ -846,6 +853,7 @@ class ChatNotifier extends Notifier<ChatState> {
853 errorMessage: e.toString(),
854 clearStreaming: true,
855 );
856 + ref.read(chatBackgroundServiceProvider).stop();
857 }
858 }
859
@@ -959,6 +967,7 @@ class ChatNotifier extends Notifier<ChatState> {
967 _streamSubscription?.cancel();
968 _streamSubscription = null;
969 ref.read(chatServiceProvider)?.cancelStream();
970 + ref.read(chatBackgroundServiceProvider).stop();
971
972 _chunkCount = 0;
973 _lastCheckpointTime = null;
lib/features/chat/views/chat_screen.dart
+1 -5
@@ -230,11 +230,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
230 recentConversations: ref.watch(
231 conv.recentConversationsProvider,
232 ),
233 - onSeeAll: () {
234 - if (Scaffold.maybeOf(context)?.hasDrawer ?? false) {
235 - Scaffold.of(context).openDrawer();
236 - }
237 - },
233 + onSeeAll: () => context.push(AppRoutes.chatHistory),
234 selectedModel: selectedModel,
235 onModelTap: () => _showModelPicker(context),
236 selectedPersona: ref.watch(selectedPersonaProvider),
lib/features/chat/views/components/code_block.dart
+87 -80
@@ -1,9 +1,8 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter/services.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 -import 'package:flutter_syntax_view/flutter_syntax_view.dart';
5 -import '../../../../core/providers/app_providers.dart';
6 -import '../../../settings/data/models/app_settings.dart';
4 +import 'package:syntax_highlight/syntax_highlight.dart';
5 +import '../../../../core/providers/highlighter_provider.dart';
6
7 class CodeBlock extends ConsumerWidget {
8 const CodeBlock({super.key, required this.code, this.language});
@@ -11,69 +10,61 @@ class CodeBlock extends ConsumerWidget {
10 final String code;
11 final String? language;
12
14 - Syntax? _mapLanguage(String? lang) {
15 - if (lang == null || lang.isEmpty) return null;
13 + String _mapLanguage(String? lang) {
14 + if (lang == null || lang.isEmpty) return '';
15
17 - switch (lang.toLowerCase()) {
16 + final lower = lang.toLowerCase();
17 + switch (lower) {
18 case 'dart':
19 case 'flutter':
20 - return Syntax.DART;
20 + return 'dart';
21 case 'c':
22 - return Syntax.C;
22 + return 'c';
23 case 'cpp':
24 case 'c++':
25 case 'cxx':
26 - return Syntax.CPP;
26 + return 'cpp';
27 + case 'csharp':
28 + case 'cs':
29 + return 'csharp';
30 + case 'go':
31 + case 'golang':
32 + return 'go';
33 case 'java':
28 - return Syntax.JAVA;
34 + return 'java';
35 case 'javascript':
36 case 'js':
31 - return Syntax.JAVASCRIPT;
37 + return 'javascript';
38 + case 'typescript':
39 + case 'ts':
40 + return 'typescript';
41 case 'kotlin':
42 case 'kt':
34 - return Syntax.KOTLIN;
43 + return 'kotlin';
44 case 'lua':
36 - return Syntax.LUA;
45 + return 'lua';
46 case 'python':
47 case 'py':
39 - return Syntax.PYTHON;
48 + return 'python';
49 case 'rust':
50 case 'rs':
42 - return Syntax.RUST;
51 + return 'rust';
52 case 'swift':
44 - return Syntax.SWIFT;
53 + return 'swift';
54 case 'yaml':
55 case 'yml':
47 - return Syntax.YAML;
56 + return 'yaml';
57 + case 'html':
58 + case 'htm':
59 + return 'html';
60 + case 'css':
61 + return 'css';
62 + case 'json':
63 + return 'json';
64 + case 'sql':
65 + return 'sql';
66 default:
49 - return null;
50 - }
51 - }
52 -
53 - SyntaxTheme _getSyntaxTheme(SyntaxThemeName themeName) {
54 - switch (themeName) {
55 - case SyntaxThemeName.vscodeDark:
56 - return SyntaxTheme.vscodeDark();
57 - case SyntaxThemeName.vscodeLight:
58 - return SyntaxTheme.vscodeLight();
59 - case SyntaxThemeName.dracula:
60 - return SyntaxTheme.dracula();
61 - case SyntaxThemeName.monokaiSublime:
62 - return SyntaxTheme.monokaiSublime();
63 - case SyntaxThemeName.ayuLight:
64 - return SyntaxTheme.ayuLight();
65 - case SyntaxThemeName.ayuDark:
66 - return SyntaxTheme.ayuDark();
67 - case SyntaxThemeName.gravityLight:
68 - return SyntaxTheme.gravityLight();
69 - case SyntaxThemeName.gravityDark:
70 - return SyntaxTheme.gravityDark();
71 - case SyntaxThemeName.obsidian:
72 - return SyntaxTheme.obsidian();
73 - case SyntaxThemeName.oceanSunset:
74 - return SyntaxTheme.oceanSunset();
75 - case SyntaxThemeName.standard:
76 - return SyntaxTheme.standard();
67 + return lower;
68 }
69 }
70
@@ -86,9 +77,9 @@ class CodeBlock extends ConsumerWidget {
77 Widget build(BuildContext context, WidgetRef ref) {
78 final theme = Theme.of(context);
79 final isDark = theme.brightness == Brightness.dark;
89 - final settings = ref.watch(settingsProvider);
90 - final themeName = isDark ? settings.codeThemeDark : settings.codeThemeLight;
91 - final codeTheme = _getSyntaxTheme(themeName);
80 + final themes = ref.watch(highlighterThemesProvider);
81 +
82 + final languageName = _mapLanguage(language);
83
84 final backgroundColor = isDark
85 ? const Color(0xFF1E1E1E)
@@ -102,13 +93,6 @@ class CodeBlock extends ConsumerWidget {
93 ? const Color(0xFF3A3A3A)
94 : const Color(0xFFE0E0E0);
95
105 - final textColor = isDark
106 - ? const Color(0xFF9CDCFE)
107 - : const Color(0xFF001080);
108 -
109 - final syntax = _mapLanguage(language);
110 - final syntaxTheme = codeTheme.copyWith(backgroundColor: Colors.transparent);
111 -
96 return Container(
97 decoration: BoxDecoration(
98 color: backgroundColor,
@@ -144,31 +128,54 @@ class CodeBlock extends ConsumerWidget {
128 ],
129 ),
130 ),
147 - SingleChildScrollView(
148 - scrollDirection: Axis.horizontal,
149 - padding: const EdgeInsets.all(12),
150 - child: syntax != null
151 - ? SelectableText.rich(
152 - TextSpan(
153 - style: TextStyle(
154 - fontFamily: 'monospace',
155 - fontSize: 13,
156 - height: 1.5,
157 - ),
158 - children: <TextSpan>[
159 - getSyntax(syntax, syntaxTheme).format(code),
160 - ],
161 - ),
162 - )
163 - : SelectableText(
164 - code,
165 - style: TextStyle(
166 - fontFamily: 'monospace',
167 - fontSize: 13,
168 - height: 1.5,
169 - color: textColor,
170 - ),
171 - ),
131 + themes.when(
132 + data: (themesData) {
133 + final loadedTheme = isDark ? themesData.dark : themesData.light;
134 + final highlighter = Highlighter(
135 + language: languageName.isNotEmpty ? languageName : 'dart',
136 + theme: loadedTheme,
137 + );
138 + return SingleChildScrollView(
139 + scrollDirection: Axis.horizontal,
140 + padding: const EdgeInsets.all(12),
141 + child: SelectableText.rich(
142 + TextSpan(
143 + style: const TextStyle(
144 + fontFamily: 'monospace',
145 + fontSize: 13,
146 + height: 1.5,
147 + ),
148 + children: <TextSpan>[
149 + highlighter.highlight(code),
150 + ],
151 + ),
152 + ),
153 + );
154 + },
155 + loading: () => SingleChildScrollView(
156 + scrollDirection: Axis.horizontal,
157 + padding: const EdgeInsets.all(12),
158 + child: SelectableText(
159 + code,
160 + style: const TextStyle(
161 + fontFamily: 'monospace',
162 + fontSize: 13,
163 + height: 1.5,
164 + ),
165 + ),
166 + ),
167 + error: (e, _) => SingleChildScrollView(
168 + scrollDirection: Axis.horizontal,
169 + padding: const EdgeInsets.all(12),
170 + child: SelectableText(
171 + code,
172 + style: const TextStyle(
173 + fontFamily: 'monospace',
174 + fontSize: 13,
175 + height: 1.5,
176 + ),
177 + ),
178 + ),
179 ),
180 ],
181 ),
lib/features/conversations/views/components/conversation_tile.dart
+16
@@ -1,4 +1,5 @@
1 import 'package:flutter/material.dart';
2 +import 'package:hugeicons/hugeicons.dart';
3 import '../../data/models/conversation.dart';
4
5 class ConversationTile extends StatelessWidget {
@@ -126,6 +127,21 @@ class ConversationTile extends StatelessWidget {
127 : const Color(0xFF999999),
128 ),
129 ),
130 + const SizedBox(width: 8),
131 + IconButton(
132 + onPressed: () => _showContextMenu(context, isDark),
133 + icon: HugeIcon(
134 + icon: HugeIcons.strokeRoundedMoreVertical,
135 + size: 18,
136 + color: isDark
137 + ? const Color(0xFF888888)
138 + : const Color(0xFF666666),
139 + ),
140 + padding: EdgeInsets.zero,
141 + constraints: const BoxConstraints(),
142 + visualDensity: VisualDensity.compact,
143 + tooltip: 'Options',
144 + ),
145 ],
146 ),
147 ),
lib/features/on_device/data/on_device_chat_service.dart
+86 -28
@@ -15,6 +15,7 @@ class OnDeviceChatService implements ChatService {
15 final OnDeviceEngineService _engineService;
16 StreamSubscription<LiteLmMessage>? _currentSubscription;
17 StreamController<ChatResponse>? _streamController;
18 + LiteLmConversation? _activeConversation;
19 bool _isCancelled = false;
20
21 OnDeviceChatService(this._engineService);
@@ -28,6 +29,8 @@ class OnDeviceChatService implements ChatService {
29 List<McpIntegration>? integrations,
30 String? previousResponseId,
31 }) {
32 + // Cancel any previous inference before starting a new one
33 + cancelStream();
34 _isCancelled = false;
35 _streamController = StreamController<ChatResponse>();
36
@@ -68,10 +71,13 @@ class OnDeviceChatService implements ChatService {
71 samplerConfig: samplerConfig,
72 );
73
74 + // Store a reference so cancelStream can dispose it
75 + _activeConversation = conversation;
76 +
77 final liteMessages = _convertMessages(messages);
78
79 if (_isCancelled) {
74 - await conversation.dispose();
80 + _disposeConversation(conversation);
81 _streamController?.add(const ChatResponse(type: ChatResponseType.done));
82 await _streamController?.close();
83 return;
@@ -87,6 +93,7 @@ class OnDeviceChatService implements ChatService {
93 content: 'No user message found',
94 ),
95 );
96 + _disposeConversation(conversation);
97 _streamController?.add(const ChatResponse(type: ChatResponseType.done));
98 await _streamController?.close();
99 return;
@@ -94,39 +101,71 @@ class OnDeviceChatService implements ChatService {
101
102 final stream = conversation.sendMessageStream(lastUserMessage.text ?? '');
103
97 - final buffer = StringBuffer();
98 - await for (final delta in stream) {
99 - if (_isCancelled) {
100 - await conversation.dispose();
101 - _streamController?.add(
102 - const ChatResponse(type: ChatResponseType.done),
103 - );
104 - await _streamController?.close();
105 - return;
106 - }
107 -
108 - if (delta.text.isNotEmpty) {
109 - buffer.write(delta.text);
110 - _streamController?.add(
111 - ChatResponse(type: ChatResponseType.message, content: delta.text),
112 - );
113 - }
114 -
115 - if (delta.toolCalls.isNotEmpty) {
116 - for (final tc in delta.toolCalls) {
104 + // Use .listen() instead of `await for` so the subscription can be
105 + // cancelled externally without waiting for the next native token.
106 + final completer = Completer<void>();
107 +
108 + _currentSubscription = stream.listen(
109 + (delta) {
110 + if (_isCancelled) return;
111 +
112 + if (delta.text.isNotEmpty) {
113 _streamController?.add(
114 ChatResponse(
119 - type: ChatResponseType.toolCall,
120 - toolCall: ToolCallData(tool: tc.name, arguments: tc.arguments),
115 + type: ChatResponseType.message,
116 + content: delta.text,
117 ),
118 );
119 }
124 - }
125 - }
120
127 - _streamController?.add(const ChatResponse(type: ChatResponseType.done));
128 - await conversation.dispose();
129 - await _streamController?.close();
121 + if (delta.toolCalls.isNotEmpty) {
122 + for (final tc in delta.toolCalls) {
123 + _streamController?.add(
124 + ChatResponse(
125 + type: ChatResponseType.toolCall,
126 + toolCall:
127 + ToolCallData(tool: tc.name, arguments: tc.arguments),
128 + ),
129 + );
130 + }
131 + }
132 + },
133 + onDone: () {
134 + if (!_isCancelled) {
135 + _streamController?.add(
136 + const ChatResponse(type: ChatResponseType.done),
137 + );
138 + _disposeConversation(conversation);
139 + _streamController?.close();
140 + }
141 + _activeConversation = null;
142 + _currentSubscription = null;
143 + if (!completer.isCompleted) completer.complete();
144 + },
145 + onError: (error) {
146 + Log.error('OnDevice stream error: $error');
147 + if (!_isCancelled &&
148 + !(_streamController?.isClosed ?? true)) {
149 + _streamController?.add(
150 + ChatResponse(
151 + type: ChatResponseType.error,
152 + content: 'Inference error: ${error.toString()}',
153 + ),
154 + );
155 + _streamController?.add(
156 + const ChatResponse(type: ChatResponseType.done),
157 + );
158 + _streamController?.close();
159 + }
160 + _disposeConversation(conversation);
161 + _activeConversation = null;
162 + _currentSubscription = null;
163 + if (!completer.isCompleted) completer.complete();
164 + },
165 + cancelOnError: true,
166 + );
167 +
168 + await completer.future;
169 } catch (e) {
170 Log.error('OnDevice inference error: $e');
171 if (!(_streamController?.isClosed ?? true)) {
@@ -142,6 +181,13 @@ class OnDeviceChatService implements ChatService {
181 }
182 }
183
184 + /// Dispose a conversation without blocking — fire and forget.
185 + void _disposeConversation(LiteLmConversation conversation) {
186 + conversation.dispose().catchError((e) {
187 + Log.error('Error disposing conversation: $e');
188 + });
189 + }
190 +
191 List<({String role, String? text})> _convertMessages(List<Message> messages) {
192 return messages
193 .where(
@@ -157,8 +203,20 @@ class OnDeviceChatService implements ChatService {
203 @override
204 void cancelStream() {
205 _isCancelled = true;
206 +
207 + // Cancel the native stream subscription first — this stops the
208 + // EventChannel from delivering more tokens and unblocks the loop.
209 _currentSubscription?.cancel();
210 _currentSubscription = null;
211 +
212 + // Dispose the active conversation (fire-and-forget to avoid blocking
213 + // the UI thread if the native side is mid-inference).
214 + final conversation = _activeConversation;
215 + _activeConversation = null;
216 + if (conversation != null) {
217 + _disposeConversation(conversation);
218 + }
219 +
220 if (!(_streamController?.isClosed ?? true)) {
221 _streamController?.add(const ChatResponse(type: ChatResponseType.done));
222 _streamController?.close();
lib/features/settings/data/models/app_settings.dart
+4 -13
@@ -5,17 +5,8 @@ import 'package:flutter/material.dart';
5 import '../../../../core/models/enums.dart';
6
7 enum SyntaxThemeName {
8 - vscodeDark,
9 - vscodeLight,
10 - dracula,
11 - monokaiSublime,
12 - ayuLight,
13 - ayuDark,
14 - gravityLight,
15 - gravityDark,
16 - obsidian,
17 - oceanSunset,
18 - standard,
8 + light,
9 + dark,
10 }
11
12 class AppSettings {
@@ -56,8 +47,8 @@ class AppSettings {
47 this.defaultPersonaId,
48 this.hasCompletedOnboarding = false,
49 this.mcpEnabled = true,
59 - this.codeThemeDark = SyntaxThemeName.vscodeDark,
60 - this.codeThemeLight = SyntaxThemeName.vscodeLight,
50 + this.codeThemeDark = SyntaxThemeName.dark,
51 + this.codeThemeLight = SyntaxThemeName.light,
52 this.preferredBackend = LiteLmBackendType.cpu,
53 });
54
lib/features/settings/screens/settings_screen.dart
+4 -22
@@ -762,28 +762,10 @@ class _CodeThemeDropdown extends StatelessWidget {
762
763 String _getDisplayName(SyntaxThemeName theme) {
764 switch (theme) {
765 - case SyntaxThemeName.vscodeDark:
766 - return 'VS Code Dark';
767 - case SyntaxThemeName.vscodeLight:
768 - return 'VS Code Light';
769 - case SyntaxThemeName.dracula:
770 - return 'Dracula';
771 - case SyntaxThemeName.monokaiSublime:
772 - return 'Monokai';
773 - case SyntaxThemeName.ayuLight:
774 - return 'Ayu Light';
775 - case SyntaxThemeName.ayuDark:
776 - return 'Ayu Dark';
777 - case SyntaxThemeName.gravityLight:
778 - return 'Gravity Light';
779 - case SyntaxThemeName.gravityDark:
780 - return 'Gravity Dark';
781 - case SyntaxThemeName.obsidian:
782 - return 'Obsidian';
783 - case SyntaxThemeName.oceanSunset:
784 - return 'Ocean Sunset';
785 - case SyntaxThemeName.standard:
786 - return 'Standard';
765 + case SyntaxThemeName.light:
766 + return 'Light';
767 + case SyntaxThemeName.dark:
768 + return 'Dark';
769 }
770 }
771
lib/features/sidebar/sidebar_widget.dart
+22 -8
@@ -21,7 +21,7 @@ class SidebarWidget extends ConsumerWidget {
21 Widget build(BuildContext context, WidgetRef ref) {
22 final theme = Theme.of(context);
23 final location = GoRouterState.of(context).uri.toString();
24 -
24 +
25 final isServers = location.startsWith(AppRoutes.servers);
26 final isPersonas = location.startsWith(AppRoutes.personas);
27 final themeMode = ref.watch(themeModeProvider);
@@ -37,7 +37,7 @@ class SidebarWidget extends ConsumerWidget {
37 child: Column(
38 children: [
39 const ConversationDrawerHeader(),
40 -
40 +
41 // New Chat Button
42 Padding(
43 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -54,17 +54,28 @@ class SidebarWidget extends ConsumerWidget {
54 child: const Text('New Chat'),
55 ),
56 ),
57 -
57 +
58 const SidebarSearchButton(),
59 const SizedBox(height: 8),
60 const Divider(height: 1, indent: 16, endIndent: 16),
61 const SizedBox(height: 12),
62 -
62 +
63 // Primary Navigation
64 Expanded(
65 child: SingleChildScrollView(
66 child: Column(
67 children: [
68 + DrawerNavItem(
69 + iconData: HugeIcons.strokeRoundedChatting01,
70 + label: 'History',
71 + isSelected: isServers,
72 + onTap: () {
73 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
74 + Navigator.pop(context);
75 + }
76 + context.go(AppRoutes.chatHistory);
77 + },
78 + ),
79 DrawerNavItem(
80 iconData: HugeIcons.strokeRoundedServerStack01,
81 label: 'Servers',
@@ -106,16 +117,19 @@ class SidebarWidget extends ConsumerWidget {
117 label: 'Appearance: ${_getThemeLabel(themeMode)}',
118 isSelected: false,
119 onTap: () {
109 - final nextMode = AppThemeType.values[
110 - (themeMode.index + 1) % AppThemeType.values.length];
111 - ref.read(themeModeProvider.notifier).setThemeMode(nextMode);
120 + final nextMode =
121 + AppThemeType.values[(themeMode.index + 1) %
122 + AppThemeType.values.length];
123 + ref
124 + .read(themeModeProvider.notifier)
125 + .setThemeMode(nextMode);
126 },
127 ),
128 ],
129 ),
130 ),
131 ),
118 -
132 +
133 // Bottom Section
134 const GitHubRepoCard(),
135 const Divider(height: 1),
lib/main.dart
+3
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:shared_preferences/shared_preferences.dart';
4
5 import 'app.dart';
6 +import 'core/providers/highlighter_provider.dart';
7 import 'core/providers/storage_providers.dart';
8 import 'core/storage/objectbox_store.dart';
9 import 'features/on_device/providers/on_device_providers.dart';
@@ -10,6 +11,8 @@ import 'features/on_device/providers/on_device_providers.dart';
11 Future<void> main() async {
12 WidgetsFlutterBinding.ensureInitialized();
13
14 + await initializeHighlighter();
15 +
16 final prefs = await SharedPreferences.getInstance();
17 final database = await ObjectBoxStore.create();
18
pubspec.lock
+17 -8
@@ -137,6 +137,14 @@ packages:
137 url: "https://pub.dev"
138 source: hosted
139 version: "0.2.0"
140 + clipboard:
141 + dependency: transitive
142 + description:
143 + name: clipboard
144 + sha256: c0ba5e7214035b824549473fa134870bc544fff0d7b89170d4756c0ac2c0239d
145 + url: "https://pub.dev"
146 + source: hosted
147 + version: "3.0.14"
148 clock:
149 dependency: transitive
150 description:
@@ -451,14 +459,6 @@ packages:
459 url: "https://pub.dev"
460 source: hosted
461 version: "2.2.4"
454 - flutter_syntax_view:
455 - dependency: "direct main"
456 - description:
457 - name: flutter_syntax_view
458 - sha256: c5017bbedfdcf538daba765e16541fcb26434071655ca00cea7cbc205a70246a
459 - url: "https://pub.dev"
460 - source: hosted
461 - version: "4.1.7"
462 flutter_test:
463 dependency: "direct dev"
464 description: flutter
@@ -1098,6 +1098,15 @@ packages:
1098 url: "https://pub.dev"
1099 source: hosted
1100 version: "1.4.1"
1101 + syntax_highlight:
1102 + dependency: "direct main"
1103 + description:
1104 + path: "."
1105 + ref: HEAD
1106 + resolved-ref: "52dc4dd9a95658e2abefd7dc527543c71836faeb"
1107 + url: "https://github.com/abdulmominsakib/syntax_highlight.git"
1108 + source: git
1109 + version: "0.5.0"
1110 term_glyph:
1111 dependency: transitive
1112 description:
pubspec.yaml
+4 -2
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 # In Windows, build-name is used as the major, minor, and patch parts
18 # of the product and file versions while build-number is used as the build suffix.
19 -version: 1.1.0+12
19 +version: 1.2.0+15
20
21 environment:
22 sdk: ^3.11.0
@@ -47,7 +47,9 @@ dependencies:
47 haptic_feedback: ^0.6.4+3
48 hugeicons: ^1.1.5
49 google_fonts: ^6.2.1
50 - flutter_syntax_view: 4.1.7
50 + syntax_highlight:
51 + git:
52 + url: https://github.com/abdulmominsakib/syntax_highlight.git
53 animations: 2.1.2
54 logger: ^2.7.0
55 shared_preferences: ^2.5.5